mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 10:47:04 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
196f780bb3 | ||
|
|
088d5a2941 | ||
|
|
47abdaed6f | ||
|
|
30f6055a69 | ||
|
|
9d4b750dfd | ||
|
|
d1308bc625 | ||
|
|
db40256bb4 | ||
|
|
70f1ebadfc | ||
|
|
43adaf85f5 | ||
|
|
8d5e78a510 | ||
|
|
9d6d893373 | ||
|
|
9840982aa2 | ||
|
|
8e216ebe46 | ||
|
|
889ae39e3b | ||
|
|
265eff98bf | ||
|
|
ef7d1dcfb3 | ||
|
|
607ebc0025 | ||
|
|
0f0bc9569d | ||
|
|
169e892678 | ||
|
|
fe249e2704 | ||
|
|
b8be5d5e38 | ||
|
|
77d6c93b90 | ||
|
|
4fedaf1cea | ||
|
|
b2220f885e | ||
|
|
3181f9c4e7 | ||
|
|
153d0d326d | ||
|
|
7f9b349de6 | ||
|
|
b3a50f2b27 | ||
|
|
fb49628375 | ||
|
|
394a74c3a0 | ||
|
|
370bf826c0 | ||
|
|
38ca1499e2 | ||
|
|
2deced699b | ||
|
|
6405af7ec7 | ||
|
|
fa2bdfca12 | ||
|
|
872e7ce704 | ||
|
|
110d3c1007 | ||
|
|
7a27b70c29 | ||
|
|
2ffe5a137c | ||
|
|
bf0246ec26 | ||
|
|
b71feca802 | ||
|
|
da1fbeeca4 | ||
|
|
53174db351 | ||
|
|
19d2ad4cb2 | ||
|
|
3325536bbc | ||
|
|
4220177b02 | ||
|
|
796aca21cf | ||
|
|
3510768786 | ||
|
|
6197642d5e | ||
|
|
b60f8bfd57 | ||
|
|
9437d16908 | ||
|
|
de9077e0d2 | ||
|
|
4a6e946809 | ||
|
|
935c578ef6 | ||
|
|
0e06ff52ba | ||
|
|
fa4590d8f6 | ||
|
|
5620b118f4 | ||
|
|
d53b31ca40 | ||
|
|
a4a5d53f66 | ||
|
|
9f4c8c9088 | ||
|
|
472172c504 | ||
|
|
bc08656806 | ||
|
|
bd6f1f3570 | ||
|
|
716b446272 | ||
|
|
96a9e5b938 | ||
|
|
83e31e6352 | ||
|
|
49d0dbe86d | ||
|
|
e141a7ca3f | ||
|
|
adad668157 | ||
|
|
267f23cad8 | ||
|
|
f605801555 | ||
|
|
19e81ff78a | ||
|
|
9232a95e00 | ||
|
|
6dcef6b199 | ||
|
|
e355bd10b1 | ||
|
|
ceb4ed52c0 | ||
|
|
227750efad | ||
|
|
8a76558f01 | ||
|
|
db7c865c76 | ||
|
|
8e35e0d6d9 | ||
|
|
c9d50f39b5 | ||
|
|
77ac893a7c | ||
|
|
ac60a436f1 | ||
|
|
2ba9f0f4c8 | ||
|
|
47c3aa4842 | ||
|
|
d2edf4b89d | ||
|
|
11a0c246dc | ||
|
|
cba93b3d55 | ||
|
|
fa9b6c6d97 | ||
|
|
bef48eca62 | ||
|
|
e42ff03c4b | ||
|
|
ec628e4a0d | ||
|
|
8865a48936 | ||
|
|
d3a9c3bd95 | ||
|
|
90bff59af3 | ||
|
|
9efe9860ae | ||
|
|
f6a418499b | ||
|
|
66740bcb8d | ||
|
|
035e257926 | ||
|
|
e63549158b | ||
|
|
7fb64e690f | ||
|
|
54798c30b1 | ||
|
|
4c0f7b61e4 | ||
|
|
206ac373d2 | ||
|
|
c626e84f7d | ||
|
|
90ec5fa80b | ||
|
|
d496b46b6f | ||
|
|
7af1fdcf6b | ||
|
|
199e156e53 | ||
|
|
f5578ee788 | ||
|
|
c0a3628b3b | ||
|
|
fa8922e0a6 | ||
|
|
bf6991ce06 | ||
|
|
7f49245f15 | ||
|
|
f55313e0cd | ||
|
|
69c388f243 | ||
|
|
35788657a8 | ||
|
|
d531698867 | ||
|
|
cc4177bf14 | ||
|
|
d377067441 | ||
|
|
b2f026c212 | ||
|
|
07cdb0788c | ||
|
|
01f919ddd3 | ||
|
|
4b1fda9680 | ||
|
|
79d03233ae | ||
|
|
522b40aff1 | ||
|
|
06c878dd2c | ||
|
|
f5c589373d | ||
|
|
2689568650 | ||
|
|
fabf649608 | ||
|
|
080af86e1b | ||
|
|
e5b58cd5ff | ||
|
|
3500389628 | ||
|
|
1129f0d60b | ||
|
|
b3a6a47a6a | ||
|
|
bdb3844bcc | ||
|
|
f0bb25efc1 | ||
|
|
bfd23db3ff | ||
|
|
9c63177f03 | ||
|
|
24b4e32f50 | ||
|
|
2e7bde8861 | ||
|
|
56c626e8a1 | ||
|
|
c12eb9f68a | ||
|
|
8bc94e7c10 | ||
|
|
e8e6161911 | ||
|
|
d4f6b1a003 | ||
|
|
6cafbc1ffe | ||
|
|
b3336289e8 | ||
|
|
3b92b43cb3 | ||
|
|
2f3dca8f4c | ||
|
|
0c76f76a5b | ||
|
|
d37e0390fb | ||
|
|
a185dbe644 | ||
|
|
5b9fb1ea00 | ||
|
|
5106b7c97d | ||
|
|
d88cb02d94 | ||
|
|
7fd128ac49 | ||
|
|
a637dca083 | ||
|
|
23a33c7722 | ||
|
|
918a0d73b9 | ||
|
|
ca9f991bcb | ||
|
|
2e9388d3c7 | ||
|
|
383196bdd3 | ||
|
|
e69e66f5f7 | ||
|
|
b9c5c3c931 | ||
|
|
edb9104d77 | ||
|
|
e0feb45f48 | ||
|
|
bc211822aa | ||
|
|
e33ae0f81b | ||
|
|
8467fb98d6 | ||
|
|
62cb2c7217 | ||
|
|
f2331261cf | ||
|
|
26ae7bcbdf | ||
|
|
291b93b7d7 | ||
|
|
73fccbb2bb | ||
|
|
7e92f8ac50 | ||
|
|
da767213cb | ||
|
|
d81eddba44 | ||
|
|
83599539b5 | ||
|
|
c145aa9499 | ||
|
|
ffeaba6a18 | ||
|
|
c68df74943 | ||
|
|
1c3e98678f | ||
|
|
b1a1e07f7d | ||
|
|
46f8c72bef | ||
|
|
a4f2902365 | ||
|
|
bcda936585 | ||
|
|
c16a5f1701 | ||
|
|
569f803ab2 | ||
|
|
7bd797cb3a | ||
|
|
1e875540fc | ||
|
|
bcde6907c8 | ||
|
|
a19c772bba | ||
|
|
24ca684db3 | ||
|
|
6fd8b83d39 | ||
|
|
3ecc87431a | ||
|
|
082e41bd76 | ||
|
|
6f0bfa6b22 | ||
|
|
bcb8669898 | ||
|
|
4bdb4f47fa | ||
|
|
a51cbc51de | ||
|
|
060d00ccc4 | ||
|
|
1ddc51f6c7 | ||
|
|
b222cbe41c | ||
|
|
53b4c0db69 | ||
|
|
ebaef28f06 | ||
|
|
0948b52957 | ||
|
|
bc51c59fe1 | ||
|
|
17a3d45e92 | ||
|
|
2fd8416887 | ||
|
|
46f11af717 | ||
|
|
c0670e9025 | ||
|
|
bc9cc4ba2a | ||
|
|
38ee4c6f12 | ||
|
|
72aa528db2 | ||
|
|
5d63b8c239 | ||
|
|
f78c1ea75f | ||
|
|
38989689f5 | ||
|
|
1e5094f3ff | ||
|
|
067178e94d | ||
|
|
58002958bf | ||
|
|
ca030688cb | ||
|
|
b883f5b9e2 | ||
|
|
85f815c538 | ||
|
|
b2ddd5f286 | ||
|
|
11bbcc7993 | ||
|
|
70f7b862e3 | ||
|
|
544eb92a6b | ||
|
|
7a123b68db | ||
|
|
b89925e86b | ||
|
|
fc1f3cdb55 | ||
|
|
19717aeb79 | ||
|
|
60a115b7d8 | ||
|
|
bc85b354a0 | ||
|
|
1b611fa7c4 | ||
|
|
34ba3ffdde | ||
|
|
af4d2dbfd0 | ||
|
|
0b12f65949 | ||
|
|
b8beb6e8be | ||
|
|
09ba8663e6 | ||
|
|
b7558c33da | ||
|
|
9174f3c9d0 | ||
|
|
2c8c04387b | ||
|
|
b1e5502b04 | ||
|
|
ddf2c2b2ac | ||
|
|
2a70602f7e | ||
|
|
c128d8ab0c | ||
|
|
0e57c37296 | ||
|
|
e0c0ec6fc3 | ||
|
|
31bb92c089 | ||
|
|
417385f231 | ||
|
|
62bfab0960 | ||
|
|
809f807da8 | ||
|
|
8f098d435a | ||
|
|
574b1eec92 | ||
|
|
7bd8629706 | ||
|
|
9d7c60e92d | ||
|
|
12cf05cb50 | ||
|
|
acd7162aa1 | ||
|
|
efbb8376ef | ||
|
|
bc4fa60f67 | ||
|
|
f13b0f2255 | ||
|
|
4ea8615fb5 | ||
|
|
7877eaa89b | ||
|
|
a8a50ac948 | ||
|
|
4206286c21 | ||
|
|
eec9a4c9e6 | ||
|
|
469ffa145a | ||
|
|
e1fdab42c0 | ||
|
|
39238a6491 | ||
|
|
fdc662fedf | ||
|
|
9d805f63d5 | ||
|
|
68273ea295 | ||
|
|
def5e4fa4f | ||
|
|
f3b1240990 | ||
|
|
8c25037cb8 | ||
|
|
31936d6e6c | ||
|
|
cc622ef189 | ||
|
|
6ef0448d9f | ||
|
|
01dc3137d8 | ||
|
|
543e963d1d | ||
|
|
c12a66eeab | ||
|
|
a9f2ba776a | ||
|
|
e7525006b5 | ||
|
|
ded71bf4f9 | ||
|
|
1b9cf8b604 | ||
|
|
01e7353ef4 | ||
|
|
73294af296 | ||
|
|
c3dc844167 | ||
|
|
1eaa276b76 | ||
|
|
2b37e08f19 | ||
|
|
de52b525fb | ||
|
|
8c39b6e3df | ||
|
|
75c6bf2b68 | ||
|
|
0308a10690 | ||
|
|
eab8bac68c | ||
|
|
19783ccbd6 | ||
|
|
10799e52ce | ||
|
|
92bb4af5ec | ||
|
|
846c2a4962 | ||
|
|
84300389f9 | ||
|
|
cb2846a256 | ||
|
|
9cfa2357b0 | ||
|
|
eaa67ba1fe | ||
|
|
d8a5f57787 | ||
|
|
13651650ab | ||
|
|
901f1bfe42 | ||
|
|
8bb5032ee4 | ||
|
|
bf59d127f9 | ||
|
|
4fb0aa3936 | ||
|
|
ed8a1c2f57 | ||
|
|
059358cff4 | ||
|
|
939acad30c | ||
|
|
4d09b3deb1 | ||
|
|
a5c4dad3d4 | ||
|
|
2cb5e4e173 | ||
|
|
708bcab2f3 | ||
|
|
2de077a3ab | ||
|
|
63084d1855 | ||
|
|
1575b803c0 | ||
|
|
afe041f969 | ||
|
|
9644692084 | ||
|
|
b52c3c6c68 | ||
|
|
0260fc6fbc | ||
|
|
73bd76bcdb | ||
|
|
2960f85671 | ||
|
|
34c742555d | ||
|
|
3b1066fade | ||
|
|
6753cedd33 | ||
|
|
2951ccdad5 | ||
|
|
93e405c370 | ||
|
|
016fe1c3d3 | ||
|
|
c816f7a923 | ||
|
|
43b6453fcb | ||
|
|
559fb30da9 | ||
|
|
273ab97d3f | ||
|
|
fcac511a28 | ||
|
|
b811be4284 | ||
|
|
1a19e56523 | ||
|
|
e5a235ba41 | ||
|
|
1448584f2f | ||
|
|
4433855047 | ||
|
|
02f63ee5f0 | ||
|
|
36f0299e51 | ||
|
|
170056cc55 | ||
|
|
a212d2142c | ||
|
|
d89575b25d | ||
|
|
8f9306a3db | ||
|
|
5f9e631011 | ||
|
|
b290c88c1d | ||
|
|
2e888525c6 | ||
|
|
d54c08dc4e | ||
|
|
821076ae59 | ||
|
|
13e11e4e09 | ||
|
|
0ab53a7825 | ||
|
|
15cb472189 | ||
|
|
39a6e2c38e | ||
|
|
7c6c0a2b81 | ||
|
|
3c63323814 | ||
|
|
44f00e042e | ||
|
|
3a99205194 | ||
|
|
c2e550e33d | ||
|
|
b01a03c6ce | ||
|
|
0251bfc842 | ||
|
|
89d4cadb61 | ||
|
|
e33fdffd99 | ||
|
|
082996b152 | ||
|
|
e9c3953085 | ||
|
|
9f2ced6bfd | ||
|
|
281549add3 | ||
|
|
91945a3b27 | ||
|
|
162c7c9e59 | ||
|
|
ba46a2b801 | ||
|
|
13af8688a3 | ||
|
|
fcf7c5c056 | ||
|
|
ab8c52bb75 | ||
|
|
08d4e301d2 | ||
|
|
428a0bbad5 | ||
|
|
4da4cf5259 | ||
|
|
72a24b2770 | ||
|
|
9141f3d63f | ||
|
|
c34edbd3ac | ||
|
|
eea2024549 | ||
|
|
ee74c5ea5b | ||
|
|
158ceca599 | ||
|
|
b8f3c3eece | ||
|
|
e835498e59 | ||
|
|
0250546ab5 | ||
|
|
f820d05082 | ||
|
|
9ba239caf0 | ||
|
|
51a59fe2d4 | ||
|
|
6753d8a073 | ||
|
|
46719cd32a | ||
|
|
47b8f75447 | ||
|
|
ea68c8e82c | ||
|
|
8d1745d47e | ||
|
|
aa93ae6918 | ||
|
|
66cb197b53 | ||
|
|
94cf7ebea8 | ||
|
|
5405da365b | ||
|
|
a0df425115 | ||
|
|
62cf1265f8 | ||
|
|
ba7e772e56 | ||
|
|
8f8c91e0b0 | ||
|
|
5bbe93bcec | ||
|
|
561b59d235 | ||
|
|
38870ca3f7 | ||
|
|
cbe7b42fb8 | ||
|
|
ad1ad80026 | ||
|
|
a6c3ac36ef | ||
|
|
a9e88f6943 | ||
|
|
77ac1453f3 | ||
|
|
e094267223 | ||
|
|
b03589f6f0 | ||
|
|
3486dde8c1 | ||
|
|
c08bf359f5 | ||
|
|
002d0d4bc0 | ||
|
|
7db02aa579 | ||
|
|
b47e6436e7 | ||
|
|
7b6e291a0a | ||
|
|
b8f6394d94 | ||
|
|
0b5c2e42fb | ||
|
|
fce5f19100 | ||
|
|
05ad24c8f5 | ||
|
|
5295aa37be | ||
|
|
3e12d6bb9d | ||
|
|
019514b699 | ||
|
|
a32ee2a344 | ||
|
|
ea480aa365 | ||
|
|
d70a980058 | ||
|
|
3157bc5e1b | ||
|
|
e72b703ab2 | ||
|
|
c1f3c0dc6b | ||
|
|
30f7db1bc2 | ||
|
|
07a147a043 | ||
|
|
9488602f5b | ||
|
|
a0c9895122 | ||
|
|
c1bd91a7f9 | ||
|
|
1d65ff3e0c | ||
|
|
f7fac5ad26 | ||
|
|
e45f055493 | ||
|
|
8946f115c0 | ||
|
|
86353dc1cf | ||
|
|
5168fb4098 | ||
|
|
c04192e15e | ||
|
|
638c2c73da | ||
|
|
f368769068 | ||
|
|
9f347fa1f2 | ||
|
|
b44f897528 | ||
|
|
efd1a80ee1 | ||
|
|
a70100c835 | ||
|
|
f51e365ec4 | ||
|
|
a5e0f33f02 | ||
|
|
dc85b49f9c | ||
|
|
72c90b3e92 | ||
|
|
df566709c8 | ||
|
|
3617e11792 | ||
|
|
b243b12d1c | ||
|
|
d0d28baee0 | ||
|
|
001b0c356f | ||
|
|
4984c522ba | ||
|
|
be64934eb4 | ||
|
|
7669c2d76b | ||
|
|
00244846cb | ||
|
|
480fda34ce | ||
|
|
103c1b4338 | ||
|
|
2acee91524 | ||
|
|
6f42e2835c | ||
|
|
e724069cbd | ||
|
|
417858f21a | ||
|
|
a362301515 | ||
|
|
86fd714923 | ||
|
|
871b746cce | ||
|
|
d12440f60a | ||
|
|
4bb78b2532 | ||
|
|
3cd23cd718 | ||
|
|
59abda933f | ||
|
|
47f5f86da1 | ||
|
|
6094875ffb | ||
|
|
0644c9a67f | ||
|
|
c91ab29a23 | ||
|
|
8f4e33fd5a | ||
|
|
8cf5012602 | ||
|
|
23a23e36bb | ||
|
|
0480445bae | ||
|
|
ae07b8de4c | ||
|
|
4c746cbb86 | ||
|
|
890a4d142b | ||
|
|
ff9bda29cf | ||
|
|
5ca9e29f3d | ||
|
|
c804a3d564 | ||
|
|
369ba08780 | ||
|
|
0692933148 | ||
|
|
26852981ba | ||
|
|
8199bd550d | ||
|
|
c56eaa258d | ||
|
|
d4e8ec125f | ||
|
|
3e3ed35fb0 | ||
|
|
3530e8d0f0 | ||
|
|
a1f6bbdf1f | ||
|
|
b053018958 | ||
|
|
5adc69977b | ||
|
|
a741cc305a | ||
|
|
96f5ad8b96 | ||
|
|
18dbb658ff | ||
|
|
512134162d | ||
|
|
7e1949ccc7 | ||
|
|
db0c4a1d71 | ||
|
|
1c64305678 | ||
|
|
95b8532660 | ||
|
|
9b3c7451b7 | ||
|
|
1911508552 | ||
|
|
f5ac301c7e | ||
|
|
e37b91c2e3 | ||
|
|
0a92dc0d2d | ||
|
|
754b16af10 | ||
|
|
b434bc4c78 | ||
|
|
dba2e2f170 | ||
|
|
312077a74a | ||
|
|
817d77a437 | ||
|
|
13b85e7164 | ||
|
|
352e174af2 | ||
|
|
aadcaedae5 | ||
|
|
40c69382a1 | ||
|
|
8f4ea5f2fd | ||
|
|
a11975adec | ||
|
|
578f6f4d0f | ||
|
|
ae267adf27 | ||
|
|
c76fc97922 | ||
|
|
730ad56677 | ||
|
|
80b161b9fc | ||
|
|
66016dedf0 | ||
|
|
7314f1d9b1 | ||
|
|
605edd547d | ||
|
|
864c29933a | ||
|
|
ff15a37e10 | ||
|
|
237f676af8 | ||
|
|
5e0e80e965 | ||
|
|
586cd84349 | ||
|
|
230b2df5fd | ||
|
|
033d361cea | ||
|
|
dbbde066f8 | ||
|
|
785e9650e5 | ||
|
|
0b697a94bd | ||
|
|
163c41f568 | ||
|
|
0582339332 | ||
|
|
062e6e9626 | ||
|
|
6dfb727fa2 | ||
|
|
d469f990f6 | ||
|
|
909474d4cb | ||
|
|
c9eee14b21 | ||
|
|
7fe35243db | ||
|
|
647c9240a5 | ||
|
|
fe0fdc0964 | ||
|
|
9dc4483d70 | ||
|
|
9ed2b79a4b | ||
|
|
28a9f9a849 | ||
|
|
5044a5be48 | ||
|
|
c4053b9ba8 | ||
|
|
53c00258ac | ||
|
|
99123b733d | ||
|
|
92c81d60db | ||
|
|
9218eb09d6 | ||
|
|
cae4836e17 | ||
|
|
172b410d82 | ||
|
|
371dcad568 | ||
|
|
32ee10445d | ||
|
|
f9d0659bd4 | ||
|
|
f6b4bb3923 | ||
|
|
9ccd183607 | ||
|
|
5e06a606ef | ||
|
|
2f6b186ea8 | ||
|
|
dd8875e951 | ||
|
|
232fc3140c | ||
|
|
8e6d2b9873 | ||
|
|
6239a72bce | ||
|
|
9e0c4ddb9a | ||
|
|
4a6b5e3aa9 | ||
|
|
a7d8f08522 | ||
|
|
6aa4686c30 | ||
|
|
f9ccb06d1f | ||
|
|
1f378137f1 | ||
|
|
7235291e69 | ||
|
|
1f39ecefe4 | ||
|
|
5a25f335cd | ||
|
|
88a6ab2d95 | ||
|
|
5208cf8678 | ||
|
|
857cba902a | ||
|
|
6753a72d97 | ||
|
|
14fb62bda4 | ||
|
|
21122c620b | ||
|
|
cb6ce4f183 | ||
|
|
884531700b | ||
|
|
6ca9e637dc | ||
|
|
0a4e696bc1 | ||
|
|
2cf0bda352 | ||
|
|
35d03c9e21 | ||
|
|
c10060decc | ||
|
|
d408df8b4c | ||
|
|
b3ecb1e9b9 | ||
|
|
9306773b5b | ||
|
|
1245fab7ae | ||
|
|
a6e8dd6632 | ||
|
|
56d8392f12 | ||
|
|
39e4bce254 | ||
|
|
b745ad2fa7 | ||
|
|
ff66d99fc6 | ||
|
|
f15bee34fb | ||
|
|
60164cfb78 | ||
|
|
d543e42268 | ||
|
|
9bd26ef3bf | ||
|
|
0d5b2aedc7 | ||
|
|
3ea872c6a4 | ||
|
|
e65582a432 | ||
|
|
88cc37a6af | ||
|
|
b40b7b2472 | ||
|
|
73f7e84192 | ||
|
|
c9d5b46ae8 | ||
|
|
d4e16048e9 | ||
|
|
daa549e650 | ||
|
|
fe562915cb | ||
|
|
fff42b61e1 | ||
|
|
90ee71c9ca | ||
|
|
a1c80c3d27 | ||
|
|
8b10f2cc4a | ||
|
|
28dde23115 | ||
|
|
b433129354 | ||
|
|
7e0fb82905 | ||
|
|
75bacd5586 | ||
|
|
21daf9f3bb | ||
|
|
fa625dc887 | ||
|
|
376b3f04e5 | ||
|
|
12ee9d381d | ||
|
|
b90938108a | ||
|
|
7b136e61a1 | ||
|
|
0f9f15b268 | ||
|
|
fdd8aac017 | ||
|
|
ce4f7a55b6 | ||
|
|
def75da1d8 | ||
|
|
2b81c71c5a | ||
|
|
436a519db8 | ||
|
|
c624136dc7 | ||
|
|
cd546b274f | ||
|
|
2f650af0f7 | ||
|
|
4ffb7cac7c | ||
|
|
6361b09129 | ||
|
|
cb87a3067c | ||
|
|
510eebd2f4 | ||
|
|
15a8e53e22 | ||
|
|
99fb44633e | ||
|
|
9a8c0399be | ||
|
|
3f264d6a49 | ||
|
|
818a6b38cb | ||
|
|
d60a102c0d | ||
|
|
0627f678ea | ||
|
|
4ad10deb00 | ||
|
|
f61725a021 | ||
|
|
6d2bb7b131 | ||
|
|
9db43db3cf | ||
|
|
7a22ce5af5 | ||
|
|
68239351b5 | ||
|
|
61eab0bca8 | ||
|
|
2aefe3f9e3 | ||
|
|
adb0bb2e69 | ||
|
|
b1a59aa871 | ||
|
|
0a19157dbb | ||
|
|
382a23b661 | ||
|
|
98107291f5 | ||
|
|
c6779a714b | ||
|
|
ef8f0fd72d | ||
|
|
634bd2ea07 | ||
|
|
f8f88ff8f2 | ||
|
|
8a2dc24f7e | ||
|
|
dcc3ab6e4e | ||
|
|
dabeab8612 | ||
|
|
66dbc5b2ed | ||
|
|
2e2ab9b2d9 | ||
|
|
662e0bb9ce | ||
|
|
3f40e94235 | ||
|
|
483b625f2d | ||
|
|
d18cf625d0 | ||
|
|
b42df81b48 | ||
|
|
536fed5448 | ||
|
|
dc01eff812 | ||
|
|
5471c7fa32 | ||
|
|
83e28b4dac | ||
|
|
af95897bde | ||
|
|
4c2f6bea25 | ||
|
|
fdf77a43cb | ||
|
|
f1855a569a | ||
|
|
a0434b6ed2 | ||
|
|
365c714213 | ||
|
|
875abf4d32 | ||
|
|
6b9bf76da2 | ||
|
|
610c75d274 | ||
|
|
81826d0549 | ||
|
|
8caf4069dc | ||
|
|
6c496e616d | ||
|
|
3d5dd54ec0 | ||
|
|
e7bfe81940 | ||
|
|
fb9c04c779 | ||
|
|
84a1af8100 | ||
|
|
b804e60bac | ||
|
|
604e751bf4 | ||
|
|
2ce083222e | ||
|
|
9a44b1fcfb | ||
|
|
9df47777d0 | ||
|
|
aa78bc4045 | ||
|
|
4bfe34ba53 | ||
|
|
ad0fc69596 | ||
|
|
cfe71e15a4 | ||
|
|
bf7a319770 | ||
|
|
cadc84b32f | ||
|
|
7bde92c7f1 | ||
|
|
965d36cdc5 | ||
|
|
f8ef0063e4 | ||
|
|
fb4e00afc4 | ||
|
|
6ed9a191b9 | ||
|
|
b503f4fa8f | ||
|
|
7020e5641f | ||
|
|
5b4601b671 | ||
|
|
3e952faad3 | ||
|
|
c51abc1c64 | ||
|
|
c7e2780d17 | ||
|
|
28e2139683 | ||
|
|
20c839394a | ||
|
|
c5e1a7d6ab | ||
|
|
579d7570af | ||
|
|
1d1d751d43 | ||
|
|
86664b5491 | ||
|
|
b7e9898163 | ||
|
|
d1fb5da002 | ||
|
|
17935fbe89 | ||
|
|
48578407db | ||
|
|
bda0c4ef1b | ||
|
|
7ea5e444b3 | ||
|
|
bef362935a | ||
|
|
f3144be382 | ||
|
|
818ee1d447 | ||
|
|
67e45ada85 | ||
|
|
bdc7132337 | ||
|
|
c5214f8d89 | ||
|
|
6910f3c95e | ||
|
|
e23f898c5f | ||
|
|
3cf81afc4a | ||
|
|
ca463a0f0e | ||
|
|
a76572ebe3 | ||
|
|
5bf83da726 | ||
|
|
393da234e4 |
No files matched your search
@@ -0,0 +1,53 @@
|
||||
name: CD Build Assets
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: dev
|
||||
workflow_dispatch:
|
||||
# schedule:
|
||||
# - cron: '0 0 * * *'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
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: Add fixed sharp
|
||||
run: yarn add [email protected] --ignore-engines
|
||||
|
||||
- name: Setup DepotDownloader
|
||||
run: yarn setup:depotdownloader
|
||||
|
||||
- name: Run DepotDownloader
|
||||
run: ./temp/depotDownloader/DepotDownloader.exe -app 444202 -username ${{ secrets.DEPOT_DOWNLOADER_USERNAME }} -password "${{ secrets.DEPOT_DOWNLOADER_PASSWORD }}"
|
||||
|
||||
- name: Build assets
|
||||
run: |
|
||||
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
|
||||
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
|
||||
yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }}
|
||||
@@ -1,8 +1,10 @@
|
||||
name: CD Host
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: main
|
||||
schedule:
|
||||
- cron: '0 */6 * * *'
|
||||
- cron: '0 */5 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -35,4 +35,4 @@ jobs:
|
||||
run: yarn
|
||||
|
||||
- name: Generate leaderboard
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=asia --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} yarn publish:ratingsleaderboard --region=asia --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
||||
@@ -35,4 +35,4 @@ jobs:
|
||||
run: yarn
|
||||
|
||||
- name: Generate leaderboard
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=eu --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} yarn publish:ratingsleaderboard --region=eu --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
||||
@@ -35,4 +35,4 @@ jobs:
|
||||
run: yarn
|
||||
|
||||
- name: Generate leaderboard
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=na --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} yarn publish:ratingsleaderboard --region=na --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
||||
@@ -1,32 +0,0 @@
|
||||
name: CI Version
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
host:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Use node
|
||||
uses: actions/setup-node@v3
|
||||
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 leaderboard
|
||||
run: yarn lint:version
|
||||
+4
-1
@@ -14,4 +14,7 @@ test.*
|
||||
servers.csv
|
||||
.next/
|
||||
tsconfig.tsbuildinfo
|
||||
temp/
|
||||
temp/
|
||||
/.yarn
|
||||
/.VSCodeCounter
|
||||
/.yarn
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "submodules/closedkrieg"]
|
||||
path = submodules/closedkrieg
|
||||
url = https://github.com/tresabhi/closedkrieg
|
||||
Vendored
+98
-1
@@ -1,49 +1,139 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"AABBOX",
|
||||
"Abhi",
|
||||
"accountinfo",
|
||||
"aeson",
|
||||
"alphatest",
|
||||
"APAC",
|
||||
"apcr",
|
||||
"artefacts",
|
||||
"atgm",
|
||||
"AYUV",
|
||||
"bbox",
|
||||
"bepaint",
|
||||
"betakrieg",
|
||||
"BINORMAL",
|
||||
"Binormals",
|
||||
"bkwd",
|
||||
"blitzstars",
|
||||
"Camo",
|
||||
"CDON",
|
||||
"classname",
|
||||
"cleandist",
|
||||
"cockroackdb",
|
||||
"Coef",
|
||||
"cooldown",
|
||||
"corsproxy",
|
||||
"CUBEMAP",
|
||||
"CUBETEXCOORD",
|
||||
"customperiod",
|
||||
"datasource",
|
||||
"Ddpf",
|
||||
"Ddsd",
|
||||
"debugflags",
|
||||
"deburr",
|
||||
"decalmask",
|
||||
"defence",
|
||||
"degressive",
|
||||
"devkrieg",
|
||||
"diacriticless",
|
||||
"donotremove",
|
||||
"drei",
|
||||
"Droodles",
|
||||
"dvpd",
|
||||
"dvpl",
|
||||
"DVPLCRC",
|
||||
"dvpm",
|
||||
"dxgi",
|
||||
"Everytime",
|
||||
"FASTNAME",
|
||||
"FOURCC",
|
||||
"fsformat",
|
||||
"Fullscreen",
|
||||
"fullstats",
|
||||
"fuzzysort",
|
||||
"gamemode",
|
||||
"glsl",
|
||||
"GLTF",
|
||||
"haha",
|
||||
"highp",
|
||||
"iife",
|
||||
"immer",
|
||||
"Inclusivity",
|
||||
"JOINTINDEX",
|
||||
"JOINTWEIGHT",
|
||||
"kranvagn",
|
||||
"krieg",
|
||||
"Leaderboard",
|
||||
"leaderboards",
|
||||
"lerp",
|
||||
"LINEARSIZE",
|
||||
"loddist",
|
||||
"lods",
|
||||
"metallicness",
|
||||
"minitelrose",
|
||||
"MIPMAPCOUNT",
|
||||
"modeltransform",
|
||||
"MURICA",
|
||||
"NEGATIVEX",
|
||||
"NEGATIVEY",
|
||||
"NEGATIVEZ",
|
||||
"nmatname",
|
||||
"noout",
|
||||
"normalmap",
|
||||
"Oopsy",
|
||||
"OPTML",
|
||||
"outdir",
|
||||
"outfile",
|
||||
"ownedtanks",
|
||||
"PIXELFORMAT",
|
||||
"playerachievements",
|
||||
"playerinfo",
|
||||
"pngjs",
|
||||
"POSITIVEX",
|
||||
"POSITIVEY",
|
||||
"POSITIVEZ",
|
||||
"Promisable",
|
||||
"Pyogenics",
|
||||
"ratingsleaderboard",
|
||||
"ratingsplot",
|
||||
"raycaster",
|
||||
"researchable",
|
||||
"Resvg",
|
||||
"roboto",
|
||||
"SCPG",
|
||||
"searchclans",
|
||||
"searchplayers",
|
||||
"searchtanks",
|
||||
"SHAREDEXP",
|
||||
"SKLLD",
|
||||
"Smri",
|
||||
"Specularity",
|
||||
"SSAO",
|
||||
"startcase",
|
||||
"statsfull",
|
||||
"steamapps",
|
||||
"Stringifying",
|
||||
"struct",
|
||||
"structs",
|
||||
"strvk",
|
||||
"subnation",
|
||||
"Synx",
|
||||
"tankaverages",
|
||||
"Tankerch",
|
||||
"tankopedia",
|
||||
"tankopedias",
|
||||
"tankstats",
|
||||
"texconv",
|
||||
"TEXCOORD",
|
||||
"tresabhi",
|
||||
"tungstentip",
|
||||
"TYPELESS",
|
||||
"ubjson",
|
||||
"Unicum",
|
||||
"UNLOCALIZED",
|
||||
"UNORM",
|
||||
"Unregistering",
|
||||
"vickers",
|
||||
"vitepress",
|
||||
@@ -51,9 +141,16 @@
|
||||
"wargaming",
|
||||
"webp",
|
||||
"winrate",
|
||||
"wireframe",
|
||||
"WOTB",
|
||||
"wotblitz",
|
||||
"xffn",
|
||||
"xkdkw",
|
||||
"XMLDVPL",
|
||||
"YAMLDVPL",
|
||||
"zustand"
|
||||
],
|
||||
"deno.enable": false,
|
||||
"deno.unstable": false
|
||||
"deno.unstable": false,
|
||||
"cSpell.enableFiletypes": ["glsl"]
|
||||
}
|
||||
@@ -14,31 +14,10 @@ Go to [application directory for Blitzkrieg](https://discord.com/application-dir
|
||||
|
||||
### https://blitz-krieg.vercel.app/
|
||||
|
||||

|
||||

|
||||
|
||||
## Visit the documentation website
|
||||
|
||||
### https://tresabhi.github.io/blitzkrieg/
|
||||
|
||||

|
||||
|
||||
## Run the bot
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```bash
|
||||
yarn
|
||||
```
|
||||
|
||||
### Create `.env`
|
||||
|
||||
```env
|
||||
GH_TOKEN={YOUR_GITHUB_TOKEN_HERE}
|
||||
DISCORD_TOKEN={YOUR_DISCORD_TOKE_HERE}
|
||||
```
|
||||
|
||||
### Run the command
|
||||
|
||||
```bash
|
||||
yarn dev:bot
|
||||
```
|
||||

|
||||
@@ -0,0 +1,3 @@
|
||||
files:
|
||||
- source: /src/lang/en-US.json
|
||||
translation: /src/lang/%locale%.json
|
||||
@@ -0,0 +1,23 @@
|
||||
# Blitzkrieg 1.15.10
|
||||
|
||||
The 10th patch for the `1.15` update is here, breaking the record of the most patches for a single update!
|
||||
|
||||
## Features
|
||||
|
||||
- Added `/today`
|
||||
- This is a direct alias for `/breakdown today`
|
||||
- Drastically simplified ratings command
|
||||
- `/ratings current` and `/ratings archive` have been now merged
|
||||
- Where the data is fetched from is now decided directly from the chosen season number
|
||||
- Simplified many error messages
|
||||
- Title bar description text is now centered when there is no image
|
||||
- It always felt awkward when there was no image on the left but the left still aligned to the right
|
||||
- Removed `/link`'s requirement to select a username explicitly from the search results
|
||||
- Full names for regions are now used in the `/link` command
|
||||
- So "NA" turns into "North America"
|
||||
|
||||
## Fixes
|
||||
|
||||
- Fixed contrast issues with WN8 percentile colors behind white text
|
||||
- Archived ratings neighbors now functions once again
|
||||
- Disabled tankopedia; this feature isn't finished yet and the changes were accidentally leaked
|
||||
@@ -0,0 +1,11 @@
|
||||
# Blitzkrieg 1.15.11
|
||||
|
||||
There's an easter egg somewhere in this update 🤔
|
||||
|
||||
## Features
|
||||
|
||||
- Added `/permissions` which lists all permissions required to run the bot and checks if the bot has them or not
|
||||
|
||||
## Changes
|
||||
|
||||
- Error messages now do no use embeds, instead utilize simple headings and paragraphs
|
||||
@@ -0,0 +1,8 @@
|
||||
# Blitzkrieg 1.15.12
|
||||
|
||||
Sometimes embeds need love too.
|
||||
|
||||
## Changes
|
||||
|
||||
- Session embeds are now translucent by default
|
||||
- Session embed titles are now the username of the player by default
|
||||
@@ -0,0 +1,7 @@
|
||||
# Blitzkrieg 1.15.13
|
||||
|
||||
I have run out of things to say here. This patch fixes like 1 thing haha.
|
||||
|
||||
# Technical Fixes
|
||||
|
||||
- Tank deltas are now filtered by last battle time
|
||||
@@ -0,0 +1,9 @@
|
||||
# Blitzkrieg 1.15.8
|
||||
|
||||
A request fulfilled \:\)
|
||||
|
||||
## Features
|
||||
|
||||
- Added custom titles in session tracker
|
||||
- Change the title through Options > Edit title...
|
||||
- Colors are shown when selecting a theme color for the session tracker
|
||||
@@ -0,0 +1,13 @@
|
||||
# Blitzkrieg 1.15.9
|
||||
|
||||
~~Curiosity killed the cat~~ Ratings killed the Blitzkrieg
|
||||
|
||||
## Fixes
|
||||
|
||||
- Deleted ratings players are now handled properly
|
||||
|
||||
## Technical Fixes
|
||||
|
||||
- Latest archived season number
|
||||
- Is now cached properly
|
||||
- Is now fetched in a chaining fashion correctly
|
||||
@@ -0,0 +1,72 @@
|
||||
# Blitzkrieg 1.16.0
|
||||
|
||||
This version introduces the long awaited (and desperately needed) Blitzkrieg Tankopedia.
|
||||
|
||||
## Credits
|
||||
|
||||
- [Pyogenics](https://github.com/pyogenics): technical documentation on WoTB 3d models
|
||||
- [aeson](https://discord.gg/WHdER7ZPAD): human encyclopedia on WoTB file layout
|
||||
- [\[OPTML\]](https://discord.gg/Su8w9KtEVE), [\_34](https://www.twitch.tv/34wotb), [Droodles_YT](https://www.youtube.com/@DroodlesBlitz): Tankopedia beta-testing
|
||||
- Minitelrose, ChickenMan7777: external module exceptions
|
||||
|
||||
## Features
|
||||
|
||||
- Added Tankopedia
|
||||
- Check out the tool here: https://blitz-krieg.vercel.app/tools/tankopedia
|
||||
- The root page features a search engine for efficiently finding tanks
|
||||
- Search bar: lets you search tanks via it's name
|
||||
- Sort: lets you sort tanks by their statistics and generic things like names and tiers
|
||||
- Reset button (trash icon): resets all filters and sorts
|
||||
- Test tanks: lets you control wether you want to include test tanks
|
||||
- Tree type filter: filters tech tree, premium, and collector tanks
|
||||
- Nations: filters tanks by nation
|
||||
- Tank type filter: filters tanks by tank designation
|
||||
- Tier filter: filters tanks by tier
|
||||
- The search result itself displays the national flag, tier, type, image, and name of the tank
|
||||
- Clicking on the tank redirects you to the tank's page
|
||||
- Tankopedia page
|
||||
- Tank sandbox: the first thing you'll see is a 3D model of the tank
|
||||
- On slim mobile devices, click "Load model" to reveal it
|
||||
- Model tab
|
||||
- This model has all PBR features and is identical to the one in the game
|
||||
- You can click and drag the turret to rotate it
|
||||
- You can do the same with the gun but with the added control of elevating and depressing it
|
||||
- Armor tab
|
||||
- Shows red where the tank (the protagonist) cannot be penetrated
|
||||
- You can switch the shells that the shooter (the antagonist) shoots
|
||||
- By default, the antagonist tank is the same as the protagonist
|
||||
- You can change antagonists using the antagonist bar
|
||||
- Searching through antagonists is identical to the root search page
|
||||
- You may also swap the protagonist and antagonist using the swap button
|
||||
- Click on the tank's armor anywhere to get intimate details about it
|
||||
- Shows spaced armor, gaps between layers of armor, ricochets, penetration losses over events, thickness, angled thicknesses, and angles
|
||||
- View button (eye icon): allows you to pose the tank in a few preset configurations
|
||||
- Sandbox settings
|
||||
- Green penetration: shows the penetrable parts of the tank as green instead of using transparency
|
||||
- Show grid: shows a grid under the tank
|
||||
- View environment: shows the lighting environment around the tank
|
||||
- Lighting: preset lighting environments
|
||||
- Wireframe: displays armor as vertices instead of faces
|
||||
- Fullscreen button: toggles fullscreen
|
||||
- Note that some functionalities are disabled in fullscreen
|
||||
- Modules: switch around modules to view it change live in the 3D model and affect stats
|
||||
- Provisions, equipments, miscellaneous, and consumables also affect stats
|
||||
- Stats
|
||||
- As you switch around the configurations of the tank, the change in stats are displayed using green and red arrows with delta numbers
|
||||
- Statistics also display their units
|
||||
- Fire section
|
||||
- This deals with statistics relating to the firepower of the tank
|
||||
- You can switch shells that the stats are based on using the buttons to the right of the title
|
||||
- You can also view the penetration loss over distance using the slider if available under the penetration section
|
||||
- Statistics for maneuverability, survivability, and crew training are also displayed
|
||||
|
||||
## Changes
|
||||
|
||||
- Navigation bar
|
||||
- Added credit to myself along with the logo and moved the whole thing to the left side
|
||||
- Added a link to the discord icon to the right
|
||||
- Removed polka dots from the background
|
||||
|
||||
## Fixes
|
||||
|
||||
- The `/ratings` command once again has the username field as an optional parameter
|
||||
@@ -0,0 +1,28 @@
|
||||
# Blitzkrieg 1.16.1
|
||||
|
||||
Armor sandbox gains 25% rendering speed and 40% memory efficiency with added accuracy and features.
|
||||
|
||||
## Features
|
||||
|
||||
- Multiple layers of spaced armors are now calculated properly by the visual armor highlighting
|
||||
- Added opaque mode to view armor highlighting without any transparency
|
||||
- Added breakdown on layers of armor penetrated
|
||||
- Penetration of each layer is shown represented by a color and an index
|
||||
- Green: penetration
|
||||
- Red: blocked
|
||||
- Yellow: ricochet
|
||||
- This also shows layers encountered after a ricochet
|
||||
|
||||
## Fixes
|
||||
|
||||
- Accuracy now doesn't drop the further the shot is placed from the origin
|
||||
- HE shells no longer loose penetration like how HEAT shells do when traveling between layers of armor
|
||||
- Persistent settings are now merged in the correct order making them truly persistent now
|
||||
- Enhanced armor and calibrated shells are now properly synced from the get go
|
||||
|
||||
## Technical Changes
|
||||
|
||||
- Spaced armor and external module scenes have been merged
|
||||
- They now use additive blending
|
||||
- Colors are now normalized by the penetration value instead of maximum armor plate thickness
|
||||
- Shell normalization now doesn't violate arccosine's domain
|
||||
@@ -0,0 +1,36 @@
|
||||
# Blitzkrieg 1.16.2
|
||||
|
||||
¡Hola desde el mundo del español!
|
||||
|
||||
## Features
|
||||
|
||||
- Added translations for Spanish to the bot
|
||||
- Your language will be inferred automatically from your Discord settings
|
||||
- Any untranslated languages and words we forgot to translate will be left in English
|
||||
- If you have your language set to Spanish and see English in any places, please let us know on [the official Discord server](https://discord.gg/nDt7AjGJQH)
|
||||
- All `/about` commands except for `commands` remain in English
|
||||
- Tank names are still in English
|
||||
- The website remains unchanged
|
||||
- The title bar (featured as the big text on top of most commands) has now been made more compact
|
||||
|
||||
## Changes
|
||||
|
||||
- "Tank type" is now called "tank class"
|
||||
- Removed `/player-achievements` and `search-tanks`
|
||||
- They will be reintroduced in the future in a far better way
|
||||
- Also removed a `SKLLD` server exclusive `/eligible` command
|
||||
- This will be generalized for all clans in the future
|
||||
|
||||
## Fixes
|
||||
|
||||
- Fixed the red glowing floor underneath the tank sandbox
|
||||
- This has been replaced with a static image
|
||||
- The refresh button is now properly removed when lacking permissions
|
||||
- Tank names that start with a number are now properly inferred from command inputs
|
||||
|
||||
## Technical Changes
|
||||
|
||||
- Command registration is now error tolerant meaning that if a single command fails to initiate, it will be discarded until the next restart
|
||||
- We are currently looking into how we can allow the command to retry registration after a fixed amount of time
|
||||
- Due to the implications of the code added which can be very easily exploited by hackers, a single file has been added to the repository as a closed source submodule and will remain closed unless a third party open-source implementation is made public
|
||||
- Removed preview commands
|
||||
@@ -0,0 +1,101 @@
|
||||
# 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).
|
||||
2. CDON further compressed with LZ4 (a clean 90% reduction).
|
||||
3. The raw JSON file as fetched from a random data source.
|
||||
4. CDON decompressed once again to JSON. Note that this file is a little larger than the original JSON file as it contains a lot more decimal places due to float precision (this shouldn't affect the size in memory).
|
||||
|
||||

|
||||
|
||||
FS stands for "Fast String".
|
||||
|
||||
```cpp
|
||||
primary CedillaObjectNotation {
|
||||
utf8"CDON" magic;
|
||||
Header header;
|
||||
primary Body<header.fsFormat> body;
|
||||
}
|
||||
|
||||
Header {
|
||||
uint16 version;
|
||||
FSFormat fsFormat;
|
||||
StringTable<fsFormat> stringTable;
|
||||
}
|
||||
|
||||
FSFormat enum<uint8> {
|
||||
Format8,
|
||||
Format16,
|
||||
Format32,
|
||||
}
|
||||
|
||||
FSPrimitive<FSFormat fsFormat> match(fsFormat) {
|
||||
Format8: uint8;
|
||||
Format16: uint16;
|
||||
Format32: uint32;
|
||||
}
|
||||
|
||||
StringTable<FSFormat fsFormat> {
|
||||
FSPrimitive<fsFormat> count;
|
||||
String[count] strings;
|
||||
}
|
||||
|
||||
Body<FSFormat fsFormat> {
|
||||
primary Value<fsFormat> object;
|
||||
}
|
||||
|
||||
ValueType enum<uint8> {
|
||||
Null,
|
||||
Boolean,
|
||||
Uint8,
|
||||
Uint16,
|
||||
Uint32,
|
||||
Uint64,
|
||||
Int8,
|
||||
Int16,
|
||||
Int32,
|
||||
Int64,
|
||||
Float32,
|
||||
Float64,
|
||||
String,
|
||||
FS,
|
||||
Array,
|
||||
Object,
|
||||
}
|
||||
|
||||
String {
|
||||
uint32 length;
|
||||
primary utf8<length> value;
|
||||
}
|
||||
|
||||
StringValue Value & { (ValueType.String | ValueType.FS) type }
|
||||
|
||||
Value<FSFormat fsFormat> {
|
||||
ValueType type;
|
||||
match<type> {
|
||||
Boolean: { uint8 value; }
|
||||
Uint8: { uint8 value; }
|
||||
Uint16: { uint16 value; }
|
||||
Uint32: { uint32 value; }
|
||||
Uint64: { uint64 value; }
|
||||
Int8: { int8 value; }
|
||||
Int16: { int16 value; }
|
||||
Int32: { int32 value; }
|
||||
Int64: { int64 value; }
|
||||
Float32: { float32 value; }
|
||||
Float64: { float64 value; }
|
||||
String: { String value; }
|
||||
FS: { FSPrimitive<fsFormat> index; }
|
||||
Array: {
|
||||
uint32 count;
|
||||
Value[count] values;
|
||||
}
|
||||
Object: {
|
||||
uint32 count;
|
||||
StringValue[count] keys;
|
||||
Value[count] values;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
+14
-4
@@ -1,5 +1,15 @@
|
||||
# Creators
|
||||
# Team
|
||||
|
||||
- **TrèsAbhi** `@tresabhi`: Creator and main programmer
|
||||
- **Prince_NA** `@prince_na`: Game advisor & tester
|
||||
- **Vovko**: Technical advisor
|
||||
- TrèsAbhi: creator & programmer
|
||||
- Prince_NA: game advisor & tester
|
||||
|
||||
# Contributors
|
||||
|
||||
- S0AP: bot verification process & initial advertizement platform
|
||||
- Vovko: discord bot rendering pipeline
|
||||
- Maddox: game file decompression
|
||||
- Pyogenics: tank model decompression
|
||||
- Aeson: game files layout
|
||||
- Minitelrose, ChickenMan7777: 3-caliber rule edge cases
|
||||
- \_34, HisRoyalFatness, Synx, xkdkw: embed UX
|
||||
- [OPTML], Aeson, \_34, Droodles: beta testing tankopedia
|
||||
@@ -0,0 +1,17 @@
|
||||
# Damage Per Minute (DPM)
|
||||
|
||||
The damage per minute of a tank is the average damage that can be dealt in that tank with the first shell (usually AP) in 60 seconds.
|
||||
|
||||
## Regular and Auto Loaders
|
||||
|
||||
The damage per minute for these types of guns are calculated by allowing the gun to shoot as soon as the shell is loaded (and in the case of auto loaders, the clip is dumped as fast as the inter-clip time will allow).
|
||||
|
||||
## Auto Reloaders
|
||||
|
||||
These types of guns have 3 DPM values.
|
||||
|
||||
- Damage per minute: calculated if you were to treat the shells like a normal auto loader where you dump all shells, wait for all shells to reload, and repeat.
|
||||
- Maximum: some tanks gain DPM the deeper they go into the clip and others maintain a higher DPM the less shells used. The "maximum" DPM is calculated if you were to treat the gun as a single-shot regular gun with the shell with the lowest reload. The shell with the highest DPM is listed as "Optimal shell index."
|
||||
- Effective: this is the maximum DPM tacked on with dumping the remaining shells at the beginning or the end of the 60 seconds.
|
||||
- If "Optimal shell index" is 1, the clip must be dumped first then used as a single shot.
|
||||
- If "Optimal shell index" is any other number, the gun should be treated as a single shot then dumped for the highest effective DPM.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Permissions
|
||||
|
||||
The following Discord permissions are required by the bot to ensure full functionality:
|
||||
|
||||
- Manage nicknames
|
||||
- Read messages / view channels
|
||||
- Attach files
|
||||
+10
-2
@@ -1,10 +1,18 @@
|
||||
import { createVanillaExtractPlugin } from '@vanilla-extract/next-plugin';
|
||||
|
||||
const withVanillaExtract = createVanillaExtractPlugin();
|
||||
const withVanillaExtract = createVanillaExtractPlugin({ tests: /\.css$/ });
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
...withVanillaExtract(),
|
||||
...withVanillaExtract({
|
||||
webpack: (config) => {
|
||||
config.module.rules.push({
|
||||
test: /\.glsl$/,
|
||||
type: 'asset/source',
|
||||
});
|
||||
return config;
|
||||
},
|
||||
}),
|
||||
|
||||
distDir: 'dist/website',
|
||||
reactStrictMode: false,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"main": "server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node --enable-source-maps server.cjs"
|
||||
}
|
||||
}
|
||||
+36
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "blitzkrieg",
|
||||
"version": "1.15.7",
|
||||
"version": "1.16.2",
|
||||
"description": "🎉 Tools for everything World of Tanks Blitz",
|
||||
"main": "src/server.ts",
|
||||
"type": "module",
|
||||
@@ -9,7 +9,9 @@
|
||||
"build:website": "next build",
|
||||
"build:docs": "vitepress build docs",
|
||||
"build:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard",
|
||||
"build:tankopedia": "vite-node --script scripts/buildTankopedia",
|
||||
"build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index",
|
||||
"build:test": "vite-node --script scripts/buildTest",
|
||||
"build:ratingsplot": "vite-node --script scripts/buildRatingsPlot",
|
||||
"dev:bot": "yarn build:bot && yarn start:bot",
|
||||
"dev:docs": "vitepress dev docs",
|
||||
"dev:website": "next dev",
|
||||
@@ -17,60 +19,84 @@
|
||||
"start:website": "next start",
|
||||
"host:docs": "vitepress preview docs",
|
||||
"publish:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard --publish",
|
||||
"zip": "zip -r dist.zip dist",
|
||||
"lint": "npx tsc",
|
||||
"lint:website": "next lint",
|
||||
"format": "prettier --write \"**/*.@(json|js|jsx|css|md|ts|tsx|html|xml|yml|gltf)\"",
|
||||
"lint:version": "vite-node --script scripts/checkVersionMissMatch",
|
||||
"knip": "knip",
|
||||
"update": "yarn upgrade --latest"
|
||||
"update": "yarn upgrade --latest",
|
||||
"setup:depotdownloader": "vite-node --script scripts/setupDepotDownloader",
|
||||
"tunnel": "cloudflared tunnel --url http://localhost:3000"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gltf-transform/core": "^3.9.0",
|
||||
"@octokit/plugin-throttling": "^8.1.3",
|
||||
"@octokit/rest": "^20.0.1",
|
||||
"@prisma/client": "^5.4.1",
|
||||
"@radix-ui/colors": "^3.0.0-rc.4",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/themes": "^1.1.2",
|
||||
"@radix-ui/themes": "^2.0.1",
|
||||
"@react-three/drei": "^9.93.0",
|
||||
"@react-three/fiber": "^8.15.14",
|
||||
"@resvg/resvg-js": "^2.4.1",
|
||||
"@types/three": "^0.160.0",
|
||||
"@vanilla-extract/css": "^1.13.0",
|
||||
"@vercel/analytics": "^1.2.2",
|
||||
"bepaint": "^0.1.0-beta.3",
|
||||
"decompress": "^4.2.1",
|
||||
"discord.js": "^14.13.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"esbuild": "^0.19.3",
|
||||
"express": "^4.18.2",
|
||||
"false": "^0.0.4",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"immer": "^10.0.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lz4js": "^0.2.0",
|
||||
"markdown-escape": "^2.0.0",
|
||||
"next": "^13.5.2",
|
||||
"next": "^14.1.0",
|
||||
"parse-dds": "^1.2.1",
|
||||
"postprocessing": "^6.34.1",
|
||||
"react": "^18.2.0",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-fuzzysort": "^1.0.1",
|
||||
"react-promise-suspense": "^0.3.4",
|
||||
"react-spinners": "^0.13.8",
|
||||
"satori": "^0.10.8",
|
||||
"sharp": "0.33.1",
|
||||
"three": "0.159.0",
|
||||
"three-stdlib": "^2.29.4",
|
||||
"vite-node": "^0.34.5",
|
||||
"vitepress": "^1.0.0-rc.20",
|
||||
"vitepress-plugin-auto-sidebar": "^1.1.0",
|
||||
"xml-js": "^1.6.11",
|
||||
"yaml": "^2.3.4",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@total-typescript/ts-reset": "^0.5.1",
|
||||
"@types/decompress": "^4.2.6",
|
||||
"@types/decompress": "^4.2.7",
|
||||
"@types/escape-html": "^1.0.2",
|
||||
"@types/express": "^4.17.18",
|
||||
"@types/js-yaml": "^4.0.8",
|
||||
"@types/lodash": "^4.14.199",
|
||||
"@types/lz4": "^0.6.4",
|
||||
"@types/markdown-escape": "^1.1.0",
|
||||
"@types/node": "^20.6.5",
|
||||
"@types/parse-dds": "^1.0.3",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/react": "^18.2.22",
|
||||
"@vanilla-extract/next-plugin": "^2.3.1",
|
||||
"compare-versions": "^6.1.0",
|
||||
"knip": "^2.27.1",
|
||||
"crc": "^4.3.2",
|
||||
"fast-xml-parser": "^4.3.2",
|
||||
"knip": "^3.4.0",
|
||||
"lz4": "^0.6.5",
|
||||
"prettier": "^3.0.3",
|
||||
"prisma": "^5.4.1",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
},
|
||||
"packageManager": "[email protected]"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,48 @@
|
||||
import { readdir } from 'fs/promises';
|
||||
import sharp from 'sharp';
|
||||
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
|
||||
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
|
||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||
import { DATA, POI } from './constants';
|
||||
|
||||
export async function boosterIcons(production: boolean) {
|
||||
const boosterFiles = (await readdir(`${DATA}/${POI.boosterIcons}`)).filter(
|
||||
(file) => !file.endsWith('@2x.txt.dvpl') && !file.startsWith('texture0'),
|
||||
);
|
||||
const image = sharp(
|
||||
await readDVPLFile(`${DATA}/${POI.boosterIcons}/texture0.packed.webp.dvpl`),
|
||||
);
|
||||
const changes: FileChange[] = [];
|
||||
|
||||
await Promise.all(
|
||||
boosterFiles.map(async (file) => {
|
||||
const name = file.match(/booster_(.+).txt.dvpl/)![1];
|
||||
const sizes = (
|
||||
await readStringDVPL(`${DATA}/${POI.boosterIcons}/${file}`)
|
||||
)
|
||||
.split('\n')[4]
|
||||
.split(' ')
|
||||
.map(Number);
|
||||
const content = (
|
||||
await image
|
||||
.clone()
|
||||
.extract({
|
||||
left: sizes[0],
|
||||
top: sizes[1],
|
||||
width: sizes[2],
|
||||
height: sizes[3],
|
||||
})
|
||||
.toBuffer()
|
||||
).toString('base64');
|
||||
|
||||
changes.push({
|
||||
path: `icons/boosters/${name}.webp`,
|
||||
content,
|
||||
encoding: 'base64',
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await commitAssets('booster icons', changes, production);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
|
||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||
import { DATA, POI } from './constants';
|
||||
|
||||
export async function camouflageIcons(production: boolean) {
|
||||
console.log('Building camouflage icons...');
|
||||
const content = await readDVPLFile(
|
||||
`${DATA}/${POI.defaultCamoIcon.replace('.webp', '.packed.webp.dvpl')}`,
|
||||
).then((content) => content.toString('base64'));
|
||||
const changes: FileChange[] = [
|
||||
{
|
||||
content,
|
||||
encoding: 'base64',
|
||||
path: 'icons/camo.webp',
|
||||
},
|
||||
];
|
||||
|
||||
await commitAssets('camo icons', changes, production);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readdir } from 'fs/promises';
|
||||
import { readBase64DVPL } from '../../src/core/blitz/readBase64DVPL';
|
||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||
import { DATA, POI } from './constants';
|
||||
|
||||
export async function circleFlags(production: boolean) {
|
||||
const flags = await readdir(`${DATA}/${POI.flags}`);
|
||||
const changes = await Promise.all(
|
||||
flags
|
||||
.filter(
|
||||
(flag) =>
|
||||
flag.startsWith('flag_profile-stat_') &&
|
||||
!flag.endsWith('@2x.packed.webp.dvpl'),
|
||||
)
|
||||
.map(async (flag) => {
|
||||
const content = await readBase64DVPL(`${DATA}/${POI.flags}/${flag}`);
|
||||
const name = flag.match(/flag_profile-stat_(.+)\.packed\.webp/)![1];
|
||||
|
||||
return {
|
||||
content,
|
||||
encoding: 'base64',
|
||||
path: `flags/circle/${name}.webp`,
|
||||
} satisfies FileChange;
|
||||
}),
|
||||
);
|
||||
|
||||
await commitAssets('circle flags', changes, production);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { argv } from 'process';
|
||||
|
||||
const isDepot = argv.includes('--depot');
|
||||
export const WOTB_WIN32_DEPOT = 444202;
|
||||
export const DATA = isDepot
|
||||
? // ? await (async () => {
|
||||
// const [installationVersion] = await readdir(`depots/${WOTB_WIN32_DEPOT}`);
|
||||
// return `depots/${WOTB_WIN32_DEPOT}/${installationVersion}/Data`;
|
||||
// })()
|
||||
`depots/${WOTB_WIN32_DEPOT}/Data`
|
||||
: 'C:/Program Files (x86)/Steam/steamapps/common/World of Tanks Blitz/Data';
|
||||
|
||||
export const POI = {
|
||||
vehicleDefinitions: 'XML/item_defs/vehicles',
|
||||
strings: 'Strings',
|
||||
cachedStrings: 'https://stufficons.wgcdn.co/localizations/en.yaml',
|
||||
tankParameters: '3d/Tanks/Parameters',
|
||||
smallIcons: 'Gfx/UI/BattleScreenHUD/SmallTankIcons',
|
||||
bigIcons: 'Gfx/UI/BigTankIcons',
|
||||
flags: 'Gfx/Lobby/flags',
|
||||
'3d': '3d',
|
||||
bigShellIcons: 'Gfx/Shared/tank-supply/ammunition/big',
|
||||
moduleIcons: 'Gfx/UI/ModulesTechTree',
|
||||
collisionMeshes: '3d/Tanks/CollisionMeshes',
|
||||
optionalDevices: 'XML/item_defs/vehicles/common/optional_devices.xml',
|
||||
optionalDeviceSlots:
|
||||
'XML/item_defs/vehicles/common/optional_device_slots.xml',
|
||||
optionalDeviceImageMappings:
|
||||
'UI/Screens3/Lobby/Inventory/OptionalDevices/OptionalDevicesItemImage.style.yaml',
|
||||
optionalDevicesImage:
|
||||
'Gfx/UI/InventoryIcons/Big/OptionalDevices/texture0.packed.webp',
|
||||
consumablesCommon: 'XML/item_defs/vehicles/common/consumables/common.xml',
|
||||
consumableIcons: 'Gfx/UI/InventoryIcons/Big/Consumables',
|
||||
provisionIcons: 'Gfx/UI/InventoryIcons/Big/Provisions',
|
||||
equipmentItemImageMappings:
|
||||
'UI/Screens3/Lobby/Inventory/Equipment/EquipmentItemImage.style.yaml',
|
||||
provisionsCommon: 'XML/item_defs/vehicles/common/provisions/common.xml',
|
||||
defaultCamoIcon: 'Gfx/UI/Hangar/IconCamouflage.webp',
|
||||
boosterIcons: 'Gfx/Shared/boosters',
|
||||
} as const;
|
||||
@@ -0,0 +1,164 @@
|
||||
import sharp from 'sharp';
|
||||
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
|
||||
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
|
||||
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
|
||||
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
|
||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||
import { DATA, POI } from './constants';
|
||||
import { ConsumablesCommon, ProvisionsCommon } from './definitions';
|
||||
|
||||
interface Mappings {
|
||||
Header: { version: number };
|
||||
StyleSheets: {
|
||||
selectors: string[];
|
||||
properties: { 'bg-sprite': string };
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function consumableProvisionIcons(production: boolean) {
|
||||
console.log('Building consumable and provision icons...');
|
||||
|
||||
const changes: FileChange[] = [];
|
||||
const consumablesTexture = sharp(
|
||||
await readDVPLFile(
|
||||
`${DATA}/${POI.consumableIcons}/texture0.packed.webp.dvpl`,
|
||||
),
|
||||
);
|
||||
const provisionsTexture = sharp(
|
||||
await readDVPLFile(
|
||||
`${DATA}/${POI.provisionIcons}/texture0.packed.webp.dvpl`,
|
||||
),
|
||||
);
|
||||
|
||||
const equipmentItemImageMappings = await readYAMLDVPL<Mappings>(
|
||||
`${DATA}/${POI.equipmentItemImageMappings}.dvpl`,
|
||||
);
|
||||
const consumablesCommon = await readXMLDVPL<{
|
||||
root: ConsumablesCommon;
|
||||
}>(`${DATA}/${POI.consumablesCommon}.dvpl`);
|
||||
const provisionsCommon = await readXMLDVPL<{
|
||||
root: ProvisionsCommon;
|
||||
}>(`${DATA}/${POI.provisionsCommon}.dvpl`);
|
||||
const styleSheets = Object.values(equipmentItemImageMappings.StyleSheets);
|
||||
|
||||
await Promise.all(
|
||||
Object.values(consumablesCommon.root).map(async (consumable) => {
|
||||
const styleSheet = styleSheets.find((styleSheet) =>
|
||||
styleSheet.selectors.includes(
|
||||
`.equipment_item.${consumable.icon} * .equipment-img`,
|
||||
),
|
||||
);
|
||||
|
||||
if (!styleSheet) {
|
||||
console.warn(
|
||||
`No style sheet found for consumable ${consumable.icon}; skipping...`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = styleSheet.properties['bg-sprite']
|
||||
.replace('~res:/', '')
|
||||
.replace('.psd', '')
|
||||
.replace('.txt', '');
|
||||
|
||||
if (configPath.startsWith('Gfx/Shared')) {
|
||||
const image = sharp(
|
||||
await readDVPLFile(`${DATA}/${configPath}.packed.webp.dvpl`),
|
||||
);
|
||||
const content = (
|
||||
await image.trim({ threshold: 100 }).toBuffer()
|
||||
).toString('base64');
|
||||
|
||||
changes.push({
|
||||
path: `icons/consumables/${consumable.id}.webp`,
|
||||
encoding: 'base64',
|
||||
content,
|
||||
});
|
||||
} else {
|
||||
const sizes = (await readStringDVPL(`${DATA}/${configPath}.txt.dvpl`))
|
||||
.split('\n')[4]
|
||||
.split(' ')
|
||||
.map(Number);
|
||||
const content = (
|
||||
await consumablesTexture
|
||||
.clone()
|
||||
.extract({
|
||||
left: sizes[0],
|
||||
top: sizes[1],
|
||||
width: sizes[2],
|
||||
height: sizes[3],
|
||||
})
|
||||
.toBuffer()
|
||||
).toString('base64');
|
||||
|
||||
changes.push({
|
||||
path: `icons/consumables/${consumable.id}.webp`,
|
||||
encoding: 'base64',
|
||||
content,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
Object.values(provisionsCommon.root).map(async (provision) => {
|
||||
const styleSheet = styleSheets.find((styleSheet) =>
|
||||
styleSheet.selectors.includes(
|
||||
`.equipment_item.${provision.icon} * .equipment-img`,
|
||||
),
|
||||
);
|
||||
|
||||
if (!styleSheet) {
|
||||
console.warn(
|
||||
`No style sheet found for provision ${provision.icon}; skipping...`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = styleSheet.properties['bg-sprite']
|
||||
.replace('~res:/', '')
|
||||
.replace('.psd', '')
|
||||
.replace('.txt', '');
|
||||
|
||||
if (configPath.startsWith('Gfx/Shared')) {
|
||||
const image = sharp(
|
||||
await readDVPLFile(`${DATA}/${configPath}.packed.webp.dvpl`),
|
||||
);
|
||||
const content = (
|
||||
await image.trim({ threshold: 100 }).toBuffer()
|
||||
).toString('base64');
|
||||
|
||||
changes.push({
|
||||
path: `icons/provisions/${provision.id}.webp`,
|
||||
encoding: 'base64',
|
||||
content,
|
||||
});
|
||||
} else {
|
||||
const sizes = (await readStringDVPL(`${DATA}/${configPath}.txt.dvpl`))
|
||||
.split('\n')[4]
|
||||
.split(' ')
|
||||
.map(Number);
|
||||
const content = (
|
||||
await provisionsTexture
|
||||
.clone()
|
||||
.extract({
|
||||
left: sizes[0],
|
||||
top: sizes[1],
|
||||
width: sizes[2],
|
||||
height: sizes[3],
|
||||
})
|
||||
.toBuffer()
|
||||
).toString('base64');
|
||||
|
||||
changes.push({
|
||||
path: `icons/provisions/${provision.id}.webp`,
|
||||
encoding: 'base64',
|
||||
content,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await commitAssets('consumable and provision icons', changes, production);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,93 @@
|
||||
import sharp from 'sharp';
|
||||
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
|
||||
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
|
||||
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
|
||||
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
|
||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||
import { DATA, POI } from './constants';
|
||||
import { OptionalDevices } from './definitions';
|
||||
|
||||
interface Mappings {
|
||||
Header: { version: number };
|
||||
|
||||
StyleSheets: {
|
||||
selectors: string[];
|
||||
properties: { 'bg-sprite': string };
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function equipmentIcons(production: boolean) {
|
||||
console.log('Building equipment icons...');
|
||||
|
||||
const changes: FileChange[] = [];
|
||||
const optionalDevices = await readXMLDVPL<{ root: OptionalDevices }>(
|
||||
`${DATA}/${POI.optionalDevices}.dvpl`,
|
||||
);
|
||||
const mappings = await readYAMLDVPL<Mappings>(
|
||||
`${DATA}/${POI.optionalDeviceImageMappings}.dvpl`,
|
||||
);
|
||||
const image = sharp(
|
||||
await readDVPLFile(`${DATA}/${POI.optionalDevicesImage}.dvpl`),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(optionalDevices.root).map(
|
||||
async ([optionalDeviceKey, optionalDevice]) => {
|
||||
if (optionalDeviceKey === 'nextAvailableId') return;
|
||||
|
||||
const mapping = mappings.StyleSheets.find((mapping) =>
|
||||
mapping.selectors.includes(
|
||||
`.optional_device_item.${optionalDevice.icon} #Img`,
|
||||
),
|
||||
);
|
||||
|
||||
if (!mapping) {
|
||||
console.warn(
|
||||
`No mapping found for ${optionalDevice.icon}; skipping...`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const configPathRaw = mapping.properties['bg-sprite'];
|
||||
const configPath = configPathRaw.replace('~res:/', '');
|
||||
|
||||
if (configPath.startsWith('Gfx/Lobby')) {
|
||||
const configPathWebp = configPath.replace('.txt', '');
|
||||
const image = sharp(
|
||||
await readDVPLFile(`${DATA}/${configPathWebp}.packed.webp.dvpl`),
|
||||
);
|
||||
const content = (await image.trim().toBuffer()).toString('base64');
|
||||
|
||||
changes.push({
|
||||
path: `icons/equipment/${optionalDevice.id}.webp`,
|
||||
encoding: 'base64',
|
||||
content,
|
||||
});
|
||||
} else {
|
||||
const config = await readStringDVPL(`${DATA}/${configPath}.dvpl`);
|
||||
const sizes = config.split('\n')[4].split(' ').map(Number);
|
||||
const content = (
|
||||
await image
|
||||
.clone()
|
||||
.extract({
|
||||
left: sizes[0],
|
||||
top: sizes[1],
|
||||
width: sizes[2],
|
||||
height: sizes[3],
|
||||
})
|
||||
.toBuffer()
|
||||
).toString('base64');
|
||||
|
||||
changes.push({
|
||||
path: `icons/equipment/${optionalDevice.id}.webp`,
|
||||
encoding: 'base64',
|
||||
content,
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await commitAssets('equipment icons', changes, production);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { config } from 'dotenv';
|
||||
import { argv } from 'process';
|
||||
|
||||
config();
|
||||
|
||||
const allTargets = argv.includes('--all-targets');
|
||||
const targets = argv
|
||||
.find((argument) => argument.startsWith('--target'))
|
||||
?.split('=')[1]
|
||||
.split(',');
|
||||
const production = argv.includes('--production');
|
||||
|
||||
import { boosterIcons } from './boosterIcons';
|
||||
import { camouflageIcons } from './camouflageIcons';
|
||||
import { circleFlags } from './circleFlags';
|
||||
import { consumableProvisionIcons } from './consumableProvisionIcons';
|
||||
import { definitions } from './definitions';
|
||||
import { equipmentIcons } from './equipmentIcons';
|
||||
import { moduleIcons } from './moduleIcons';
|
||||
import { scratchedFlags } from './scratchedFlags';
|
||||
import { shellIcons } from './shellIcons';
|
||||
import { tankArmors } from './tankArmors';
|
||||
import { tankIcons } from './tankIcons';
|
||||
import { tankModels } from './tankModels';
|
||||
|
||||
if (!targets && !allTargets) throw new Error('No target(s) specified');
|
||||
|
||||
const methods = [
|
||||
definitions,
|
||||
tankIcons,
|
||||
scratchedFlags,
|
||||
circleFlags,
|
||||
shellIcons,
|
||||
moduleIcons,
|
||||
equipmentIcons,
|
||||
consumableProvisionIcons,
|
||||
camouflageIcons,
|
||||
boosterIcons,
|
||||
tankArmors,
|
||||
tankModels,
|
||||
];
|
||||
|
||||
for (const method of methods) {
|
||||
if (allTargets || targets?.includes(method.name)) await method(production);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readdir } from 'fs/promises';
|
||||
import { readBase64DVPL } from '../../src/core/blitz/readBase64DVPL';
|
||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||
import { DATA, POI } from './constants';
|
||||
|
||||
export async function moduleIcons(production: boolean) {
|
||||
console.log('Building module icons...');
|
||||
|
||||
const changes = await Promise.all(
|
||||
(await readdir(`${DATA}/${POI.moduleIcons}`))
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.endsWith('@2x.packed.webp.dvpl') && file.startsWith('vehicle'),
|
||||
)
|
||||
.map(async (file) => {
|
||||
const nameRaw = file.match(/vehicle(.+)\.packed\.webp\.dvpl/)![1];
|
||||
const name = nameRaw[0].toLowerCase() + nameRaw.slice(1);
|
||||
const content = await readBase64DVPL(
|
||||
`${DATA}/${POI.moduleIcons}/${file}`,
|
||||
);
|
||||
|
||||
return {
|
||||
content,
|
||||
path: `icons/modules/${name}.webp`,
|
||||
encoding: 'base64',
|
||||
} satisfies FileChange;
|
||||
}),
|
||||
);
|
||||
|
||||
await commitAssets('module icons', changes, production);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readdir } from 'fs/promises';
|
||||
import { readBase64DVPL } from '../../src/core/blitz/readBase64DVPL';
|
||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||
import { DATA, POI } from './constants';
|
||||
|
||||
export async function scratchedFlags(production: boolean) {
|
||||
console.log('Building scratched flags...');
|
||||
|
||||
const flags = await readdir(`${DATA}/${POI.flags}`);
|
||||
const changes = await Promise.all(
|
||||
flags
|
||||
.filter(
|
||||
(flag) =>
|
||||
flag.startsWith('flag_tutor-tank_') &&
|
||||
!flag.endsWith('@2x.packed.webp.dvpl'),
|
||||
)
|
||||
.map(async (flag) => {
|
||||
const content = await readBase64DVPL(`${DATA}/${POI.flags}/${flag}`);
|
||||
const name = flag.match(/flag_tutor-tank_(.+)\.packed\.webp/)![1];
|
||||
|
||||
return {
|
||||
content,
|
||||
encoding: 'base64',
|
||||
path: `flags/scratched/${name}.webp`,
|
||||
} satisfies FileChange;
|
||||
}),
|
||||
);
|
||||
|
||||
await commitAssets('scratched flags', changes, production);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readdir } from 'fs/promises';
|
||||
import sharp from 'sharp';
|
||||
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
|
||||
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
|
||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||
import { DATA, POI } from './constants';
|
||||
|
||||
export async function shellIcons(production: boolean) {
|
||||
console.log('Building shell icons...');
|
||||
|
||||
const image = sharp(
|
||||
await readDVPLFile(
|
||||
`${DATA}/${POI.bigShellIcons}/texture0.packed.webp.dvpl`,
|
||||
),
|
||||
);
|
||||
|
||||
const changes = await Promise.all(
|
||||
(await readdir(`${DATA}/${POI.bigShellIcons}`))
|
||||
.filter((file) => file.endsWith('_l.txt.dvpl'))
|
||||
.map(async (file) => {
|
||||
const name = file.match(/(.+)_l\.txt\.dvpl/)![1];
|
||||
const sizes = (
|
||||
await readStringDVPL(`${DATA}/${POI.bigShellIcons}/${file}`)
|
||||
)
|
||||
.split('\n')[4]
|
||||
.split(' ')
|
||||
.map(Number);
|
||||
|
||||
return {
|
||||
content: (
|
||||
await image
|
||||
.clone()
|
||||
.extract({
|
||||
left: sizes[0],
|
||||
top: sizes[1],
|
||||
width: sizes[2],
|
||||
height: sizes[3],
|
||||
})
|
||||
.toBuffer()
|
||||
).toString('base64'),
|
||||
encoding: 'base64',
|
||||
path: `icons/shells/${name}.webp`,
|
||||
} satisfies FileChange;
|
||||
}),
|
||||
);
|
||||
|
||||
await commitAssets('shell icons', changes, production);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { readdir } from 'fs/promises';
|
||||
import { extractArmor } from '../../src/core/blitz/extractArmor';
|
||||
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
|
||||
import { toUniqueId } from '../../src/core/blitz/toUniqueId';
|
||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||
import { DATA, POI } from './constants';
|
||||
import { VehicleDefinitionList } from './definitions';
|
||||
|
||||
export async function tankArmors(production: boolean) {
|
||||
console.log('Building tank armors...');
|
||||
|
||||
const changes: FileChange[] = [];
|
||||
const nodeIO = new NodeIO();
|
||||
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then(
|
||||
(nations) => nations.filter((nation) => nation !== 'common'),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
nations.map(async (nation) => {
|
||||
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
|
||||
`${DATA}/${POI.vehicleDefinitions}/${nation}/list.xml.dvpl`,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(tanks.root).map(async ([tankKey, tank]) => {
|
||||
if (tankKey.includes('tutorial_bot')) return;
|
||||
|
||||
const id = toUniqueId(nation, tank.id);
|
||||
|
||||
console.log(`Building armor ${id} @ ${nation}/${tankKey}`);
|
||||
|
||||
const model = await extractArmor(DATA, `${nation}-${tankKey}`);
|
||||
|
||||
changes.push({
|
||||
path: `3d/tanks/armor/${id}.glb`,
|
||||
encoding: 'base64',
|
||||
content: Buffer.from(await nodeIO.writeBinary(model)).toString(
|
||||
'base64',
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await commitAssets('tank armor', changes, production);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { readdir } from 'fs/promises';
|
||||
import { Vector3Tuple } from 'three';
|
||||
import { NATION_IDS } from '../../src/constants/nations';
|
||||
import { readBase64DVPL } from '../../src/core/blitz/readBase64DVPL';
|
||||
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
|
||||
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
|
||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||
import { DATA, POI } from './constants';
|
||||
import { VehicleDefinitionList } from './definitions';
|
||||
|
||||
export interface TankParameters {
|
||||
resourcesPath: {
|
||||
smallIconPath: string;
|
||||
bigIconPath: string;
|
||||
blitzModelPath: string;
|
||||
};
|
||||
collision: {
|
||||
[key: string]: {
|
||||
points: Vector3Tuple;
|
||||
bbox: {
|
||||
min: Vector3Tuple;
|
||||
max: Vector3Tuple;
|
||||
};
|
||||
averageThickness?: Record<string, number>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function tankIcons(production: boolean) {
|
||||
console.log('Building tank icons...');
|
||||
|
||||
const changes: FileChange[] = [];
|
||||
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then(
|
||||
(nations) => nations.filter((nation) => nation !== 'common'),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
nations.map(async (nation) => {
|
||||
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
|
||||
`${DATA}/${POI.vehicleDefinitions}/${nation}/list.xml.dvpl`,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(tanks.root).map(async ([tankKey, tank]) => {
|
||||
if (tankKey.includes('tutorial_bot')) return;
|
||||
|
||||
const nationVehicleId = tank.id;
|
||||
const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1;
|
||||
const parameters = await readYAMLDVPL<TankParameters>(
|
||||
`${DATA}/${POI.tankParameters}/${nation}/${tankKey}.yaml.dvpl`,
|
||||
);
|
||||
const small = `${DATA}/${parameters.resourcesPath.smallIconPath
|
||||
.replace(/~res:\//, '')
|
||||
.replace(/\..+/, '')}.packed.webp.dvpl`;
|
||||
const big = `${DATA}/${parameters.resourcesPath.bigIconPath
|
||||
.replace(/~res:\//, '')
|
||||
.replace(/\..+/, '')}.packed.webp.dvpl`;
|
||||
|
||||
if (big) {
|
||||
changes.push({
|
||||
content: await readBase64DVPL(big),
|
||||
encoding: 'base64',
|
||||
path: `icons/tanks/big/${id}.webp`,
|
||||
});
|
||||
}
|
||||
if (small) {
|
||||
changes.push({
|
||||
content: await readBase64DVPL(small),
|
||||
encoding: 'base64',
|
||||
path: `icons/tanks/small/${id}.webp`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await commitAssets('tank icons', changes, production);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { readdir } from 'fs/promises';
|
||||
import { Vector3Tuple } from 'three';
|
||||
import { extractModel } from '../../src/core/blitz/extractModel';
|
||||
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
|
||||
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
|
||||
import { toUniqueId } from '../../src/core/blitz/toUniqueId';
|
||||
// import { AssetCommit } from '../../src/core/github/assetCommit';
|
||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||
import { DATA, POI } from './constants';
|
||||
import { VehicleDefinitionList } from './definitions';
|
||||
import { TankParameters } from './tankIcons';
|
||||
|
||||
interface VehicleCustomization {
|
||||
armorColor: string;
|
||||
}
|
||||
|
||||
export async function tankModels(production: boolean) {
|
||||
console.log('Building tank models...');
|
||||
|
||||
const nodeIO = new NodeIO();
|
||||
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then(
|
||||
(nations) => nations.filter((nation) => nation !== 'common'),
|
||||
);
|
||||
|
||||
for (const nationIndex in nations) {
|
||||
const nation = nations[nationIndex];
|
||||
const changes: FileChange[] = [];
|
||||
// const assetCommit = new AssetCommit(`tank models ${nation}`, production);
|
||||
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
|
||||
`${DATA}/${POI.vehicleDefinitions}/${nation}/list.xml.dvpl`,
|
||||
);
|
||||
const customization = await readXMLDVPL<{ root: VehicleCustomization }>(
|
||||
`${DATA}/${POI.vehicleDefinitions}/${nation}/customization.xml.dvpl`,
|
||||
);
|
||||
const baseColor = customization.root.armorColor
|
||||
.split(' ')
|
||||
.slice(0, 3)
|
||||
.map(Number)
|
||||
.map((channel) => channel / 255) as Vector3Tuple;
|
||||
|
||||
console.log(`Building models for ${nation}`);
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(tanks.root).map(async ([tankKey, tank]) => {
|
||||
if (tankKey.includes('tutorial_bot')) return;
|
||||
|
||||
const id = toUniqueId(nation, tank.id);
|
||||
|
||||
console.log(`Building model ${id} @ ${nation}/${tankKey}`);
|
||||
|
||||
const parameters = await readYAMLDVPL<TankParameters>(
|
||||
`${DATA}/${POI.tankParameters}/${nation}/${tankKey}.yaml.dvpl`,
|
||||
);
|
||||
const model = await extractModel(
|
||||
DATA,
|
||||
parameters.resourcesPath.blitzModelPath.replace(/\.sc2$/, ''),
|
||||
baseColor,
|
||||
);
|
||||
|
||||
// writeFile(`test.${tankKey}.glb`, await nodeIO.writeBinary(model));
|
||||
|
||||
changes.push({
|
||||
path: `3d/tanks/models/${id}.glb`,
|
||||
content: Buffer.from(await nodeIO.writeBinary(model)).toString(
|
||||
'base64',
|
||||
),
|
||||
encoding: 'base64',
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// assetCommit.push();
|
||||
await commitAssets(`tank models ${nation}`, changes, production);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { rm, writeFile } from 'fs/promises';
|
||||
import { argv, env } from 'process';
|
||||
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';
|
||||
import commitMultipleFiles from './commitMultipleFiles.js';
|
||||
|
||||
/*
|
||||
* Central North American Time (UTC-5): use 0 5 * * *.
|
||||
@@ -115,7 +114,6 @@ for (let index = 0; index < PLAYERS; index++) {
|
||||
const leaderboardJSON = JSON.stringify(leaderboard);
|
||||
|
||||
if (publish) {
|
||||
const octokit = new Octokit({ auth: env.GH_TOKEN });
|
||||
const info = await patientFetchJSON<RatingsInfo & { detail: undefined }>(
|
||||
`https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`,
|
||||
);
|
||||
@@ -126,14 +124,13 @@ if (publish) {
|
||||
|
||||
console.log(`Publishing to season ${info.current_season}...`);
|
||||
commitMultipleFiles(
|
||||
octokit,
|
||||
'tresabhi',
|
||||
'blitzkrieg-assets',
|
||||
'main',
|
||||
new Date().toString(),
|
||||
[
|
||||
{ path: infoPath, content: infoJSON },
|
||||
{ path: leaderboardPath, content: leaderboardJSON },
|
||||
{ path: infoPath, content: infoJSON, encoding: 'utf-8' },
|
||||
{ path: leaderboardPath, content: leaderboardJSON, encoding: 'utf-8' },
|
||||
],
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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'),
|
||||
);
|
||||
@@ -1,86 +0,0 @@
|
||||
import decompress from 'decompress';
|
||||
import { readFile, readdir, rm, writeFile } from 'fs/promises';
|
||||
import { load } from 'js-yaml';
|
||||
import { ElementCompact, xml2js } from 'xml-js';
|
||||
import { NATION_IDS } from '../src/constants/nations';
|
||||
|
||||
const REWRITE = false;
|
||||
const VEHICLE_DEFS = 'assets/Data/XML/item_defs/vehicles';
|
||||
const APK_FILE = 'temp/blitz.apk';
|
||||
const STRINGS = 'assets/Data/Strings';
|
||||
const LANGUAGE = 'en';
|
||||
const DIRECTORIES_OF_INTEREST = [VEHICLE_DEFS, STRINGS];
|
||||
|
||||
if (REWRITE) {
|
||||
await rm('temp/blitz', { recursive: true, force: true });
|
||||
decompress(APK_FILE, 'temp/blitz', {
|
||||
filter: (file) =>
|
||||
DIRECTORIES_OF_INTEREST.some((dir) => file.path.startsWith(dir)),
|
||||
});
|
||||
}
|
||||
|
||||
interface BlitzkriegTankopedia {
|
||||
id: number;
|
||||
nation: string;
|
||||
name: string;
|
||||
name_short?: string;
|
||||
is_premium: boolean;
|
||||
is_collector: boolean;
|
||||
tier: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const strings = await readFile(
|
||||
`temp/blitz/${STRINGS}/${LANGUAGE}.yaml`,
|
||||
'utf-8',
|
||||
).then((data) => load(data) as Record<string, string>);
|
||||
const tankopedia: Record<number, BlitzkriegTankopedia> = {};
|
||||
const nations = await readdir(`temp/blitz/${VEHICLE_DEFS}`);
|
||||
|
||||
await Promise.all(
|
||||
nations
|
||||
.filter((nation) => nation !== 'common')
|
||||
.map(async (nation) => {
|
||||
const listXML = await readFile(
|
||||
`temp/blitz/${VEHICLE_DEFS}/${nation}/list.xml`,
|
||||
'utf-8',
|
||||
);
|
||||
const list = xml2js(listXML, { compact: true }) as ElementCompact;
|
||||
|
||||
await Promise.all(
|
||||
Object.entries<ElementCompact>(list.root).map(
|
||||
async ([vehicle, vehicleData]) => {
|
||||
// const vehicleXML = await readFile(
|
||||
// `temp/blitz/${VEHICLE_DEFS}/${nation}/${vehicle}.xml`,
|
||||
// 'utf-8',
|
||||
// ).then((xml) => xml2js(xml, { compact: true }) as ElementCompact);
|
||||
const nationVehicleId = parseInt(list.root[vehicle].id._text);
|
||||
const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1;
|
||||
// TODO: use userString property instead
|
||||
const name = strings[`#${nation}_vehicles:${vehicle}`];
|
||||
const name_short = strings[`#${nation}_vehicles:${vehicle}_short`];
|
||||
const is_premium = 'gold' in vehicleData.price;
|
||||
const is_collector = vehicleData.sellPrice
|
||||
? 'gold' in vehicleData.sellPrice
|
||||
: false;
|
||||
const tier = parseInt(vehicleData.level._text);
|
||||
const tags = vehicleData.tags._text.split(' ');
|
||||
const type = tags[0];
|
||||
|
||||
tankopedia[id] = {
|
||||
id,
|
||||
name,
|
||||
name_short,
|
||||
nation,
|
||||
is_premium,
|
||||
is_collector,
|
||||
tier,
|
||||
type,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
writeFile('dist/tankopedia.json', JSON.stringify(tankopedia, null, 2));
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
export default async function commitMultipleFiles(
|
||||
octokit: Octokit,
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
message: string,
|
||||
changes: { path: string; content: string }[],
|
||||
) {
|
||||
// Get the latest commit SHA on the specified branch
|
||||
const { data: refData } = await octokit.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${branch}`,
|
||||
});
|
||||
const latestCommitSha = refData.object.sha;
|
||||
|
||||
// Get the tree SHA for the latest commit
|
||||
const { data: commitData } = await octokit.git.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
commit_sha: latestCommitSha,
|
||||
});
|
||||
const treeSha = commitData.tree.sha;
|
||||
|
||||
// Create blobs for each file
|
||||
const blobs = await Promise.all(
|
||||
changes.map(async (change) => {
|
||||
const { data: blobData } = await octokit.git.createBlob({
|
||||
owner,
|
||||
repo,
|
||||
content: change.content,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
return {
|
||||
sha: blobData.sha,
|
||||
path: change.path,
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
} as const;
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a new tree with the new blobs
|
||||
const { data: treeData } = await octokit.git.createTree({
|
||||
owner,
|
||||
repo,
|
||||
base_tree: treeSha,
|
||||
tree: blobs,
|
||||
});
|
||||
|
||||
// Create a new commit with the new tree
|
||||
const { data: newCommitData } = await octokit.git.createCommit({
|
||||
owner,
|
||||
repo,
|
||||
message,
|
||||
tree: treeData.sha,
|
||||
parents: [latestCommitSha],
|
||||
});
|
||||
|
||||
// Update the branch reference to point to the new commit
|
||||
await octokit.git.updateRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${branch}`,
|
||||
sha: newCommitData.sha,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import decompress from 'decompress';
|
||||
import { existsSync } from 'fs';
|
||||
import { mkdir, readdir, writeFile } from 'fs/promises';
|
||||
|
||||
if (!existsSync('temp')) {
|
||||
console.log('Creating temp directory...');
|
||||
await mkdir('temp');
|
||||
}
|
||||
|
||||
console.log('Downloading depot downloader...');
|
||||
await fetch(
|
||||
'https://api.github.com/repos/SteamRE/DepotDownloader/releases/latest',
|
||||
)
|
||||
.then(
|
||||
(response) =>
|
||||
response.json() as Promise<{
|
||||
assets: { browser_download_url: string; name: string }[];
|
||||
}>,
|
||||
)
|
||||
.then(({ assets }) => {
|
||||
const asset = assets.find(({ name }) => name.includes('windows-x64'));
|
||||
if (!asset) throw new Error('No asset found');
|
||||
return fetch(asset.browser_download_url);
|
||||
})
|
||||
.then((response) => response.arrayBuffer())
|
||||
.then((arrayBuffer) =>
|
||||
writeFile('temp/depotDownloader.zip', Buffer.from(arrayBuffer)),
|
||||
);
|
||||
|
||||
console.log('Unzipping...');
|
||||
await decompress('temp/depotDownloader.zip', 'temp/depotDownloader');
|
||||
|
||||
console.log('Download complete; files:', await readdir('temp/depotDownloader'));
|
||||
@@ -1,6 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tankopedia } from '../../../core/blitzstars/tankopedia';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await tankopedia);
|
||||
}
|
||||
+109
-100
@@ -3,25 +3,31 @@
|
||||
import { ContextMenu } from '@radix-ui/themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as Breakdown from '../../../components/Breakdown';
|
||||
import { TreeTypeEnum } from '../../../components/Tanks';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
|
||||
import fetchBlitz from '../../../core/blitz/fetchBlitz';
|
||||
import { tankAverages } from '../../../core/blitzstars/tankAverages';
|
||||
import {
|
||||
TankopediaEntry,
|
||||
tankopedia,
|
||||
} from '../../../core/blitzstars/tankopedia';
|
||||
TankDefinition,
|
||||
tankDefinitions,
|
||||
} from '../../../core/blitzkrieg/tankDefinitions';
|
||||
import { tankAverages } from '../../../core/blitzstars/tankAverages';
|
||||
import calculateWN8 from '../../../core/statistics/calculateWN8';
|
||||
import { deltaTankStats } from '../../../core/statistics/deltaTankStats';
|
||||
import getWN8Percentile from '../../../core/statistics/getWN8Percentile';
|
||||
import { resetSession, useSession } from '../../../stores/session';
|
||||
import mutateSession, {
|
||||
resetSession,
|
||||
useSession,
|
||||
} from '../../../stores/session';
|
||||
import { IndividualTankStats, TanksStats } from '../../../types/tanksStats';
|
||||
import { CustomColumnDisplay } from '../../tools/session/components/CustomColumn';
|
||||
import { Menu } from '../../tools/session/components/Menu';
|
||||
|
||||
const REFRESH_RATE = 1 / 5;
|
||||
|
||||
export default function SessionPage() {
|
||||
interface SessionPageProps {
|
||||
naked?: boolean;
|
||||
}
|
||||
|
||||
export default function SessionPage({ naked = true }: SessionPageProps) {
|
||||
const session = useSession();
|
||||
const since = session.isTracking ? new Date(session.time) : undefined;
|
||||
const [diff, setDiff] = useState<
|
||||
@@ -30,7 +36,7 @@ export default function SessionPage() {
|
||||
|
||||
list: {
|
||||
stats: IndividualTankStats;
|
||||
tankopedia?: TankopediaEntry;
|
||||
tankDefinitions?: TankDefinition;
|
||||
career: IndividualTankStats;
|
||||
currentWN8?: number;
|
||||
careerWN8?: number;
|
||||
@@ -72,6 +78,14 @@ export default function SessionPage() {
|
||||
0,
|
||||
) / careerBattles;
|
||||
|
||||
useEffect(() => {
|
||||
if (session.title === 'Untitled session') {
|
||||
mutateSession((draft) => {
|
||||
if (draft.isTracking) draft.title = draft.nickname;
|
||||
});
|
||||
}
|
||||
}, [session.title]);
|
||||
|
||||
useEffect(() => {
|
||||
async function recalculateDiff() {
|
||||
if (session.isTracking) {
|
||||
@@ -88,7 +102,7 @@ export default function SessionPage() {
|
||||
}),
|
||||
{},
|
||||
);
|
||||
const awaitedTankopedia = await tankopedia;
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const awaitedTankAverages = await tankAverages;
|
||||
const careerWN8 =
|
||||
careerRaw[id].reduce(
|
||||
@@ -114,12 +128,12 @@ export default function SessionPage() {
|
||||
|
||||
list: (
|
||||
Object.values(
|
||||
deltaTankStats(session.tankStats, career),
|
||||
deltaTankStats(session.tankStats, career, session.time / 1000),
|
||||
) as IndividualTankStats[]
|
||||
).map((stats) => {
|
||||
return {
|
||||
stats,
|
||||
tankopedia: awaitedTankopedia[stats.tank_id],
|
||||
tankDefinitions: awaitedTankDefinitions[stats.tank_id],
|
||||
career: career[stats.tank_id],
|
||||
currentWN8: awaitedTankAverages[stats.tank_id]
|
||||
? calculateWN8(
|
||||
@@ -207,16 +221,10 @@ export default function SessionPage() {
|
||||
since !== undefined &&
|
||||
diff !== undefined && (
|
||||
<Breakdown.Row
|
||||
naked={naked}
|
||||
color={session.color}
|
||||
minimized={!session.showCareer}
|
||||
title={`Since ${since.toLocaleString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
day: 'numeric',
|
||||
})}`}
|
||||
title={session.title}
|
||||
stats={session.customColumns.map((customColumn) => {
|
||||
const rowStat = totalRowStats[customColumn.display];
|
||||
|
||||
@@ -238,90 +246,91 @@ export default function SessionPage() {
|
||||
.sort(
|
||||
(a, b) => b.stats.last_battle_time - a.stats.last_battle_time,
|
||||
)
|
||||
.map(({ stats, tankopedia, career, careerWN8, currentWN8 }) => {
|
||||
const rowStats: Record<
|
||||
CustomColumnDisplay,
|
||||
Breakdown.RowStatItem | undefined
|
||||
> = {
|
||||
battles: {
|
||||
title: 'Battles',
|
||||
current: stats.all.battles.toLocaleString(),
|
||||
career: career.all.battles.toLocaleString(),
|
||||
},
|
||||
winrate: {
|
||||
title: 'Winrate',
|
||||
current: `${(
|
||||
100 *
|
||||
(stats.all.wins / stats.all.battles)
|
||||
).toFixed(2)}%`,
|
||||
career: `${(
|
||||
100 *
|
||||
(career.all.wins / career.all.battles)
|
||||
).toFixed(2)}%`,
|
||||
delta:
|
||||
stats.all.wins / stats.all.battles -
|
||||
career.all.wins / career.all.battles,
|
||||
},
|
||||
wn8: {
|
||||
title: 'WN8',
|
||||
current:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
: Math.round(currentWN8).toLocaleString(),
|
||||
career:
|
||||
careerWN8 === undefined
|
||||
? undefined
|
||||
: Math.round(careerWN8).toLocaleString(),
|
||||
percentile:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
: getWN8Percentile(currentWN8),
|
||||
},
|
||||
damage: {
|
||||
title: 'Damage',
|
||||
current: Math.round(
|
||||
stats.all.damage_dealt / stats.all.battles,
|
||||
).toLocaleString(),
|
||||
career: Math.round(
|
||||
career.all.damage_dealt / career.all.battles,
|
||||
).toLocaleString(),
|
||||
delta:
|
||||
stats.all.damage_dealt / stats.all.battles -
|
||||
career.all.damage_dealt / career.all.battles,
|
||||
},
|
||||
none: undefined,
|
||||
};
|
||||
.map(
|
||||
({ stats, tankDefinitions, career, careerWN8, currentWN8 }) => {
|
||||
const rowStats: Record<
|
||||
CustomColumnDisplay,
|
||||
Breakdown.RowStatItem | undefined
|
||||
> = {
|
||||
battles: {
|
||||
title: 'Battles',
|
||||
current: stats.all.battles.toLocaleString(),
|
||||
career: career.all.battles.toLocaleString(),
|
||||
},
|
||||
winrate: {
|
||||
title: 'Winrate',
|
||||
current: `${(
|
||||
100 *
|
||||
(stats.all.wins / stats.all.battles)
|
||||
).toFixed(2)}%`,
|
||||
career: `${(
|
||||
100 *
|
||||
(career.all.wins / career.all.battles)
|
||||
).toFixed(2)}%`,
|
||||
delta:
|
||||
stats.all.wins / stats.all.battles -
|
||||
career.all.wins / career.all.battles,
|
||||
},
|
||||
wn8: {
|
||||
title: 'WN8',
|
||||
current:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
: Math.round(currentWN8).toLocaleString(),
|
||||
career:
|
||||
careerWN8 === undefined
|
||||
? undefined
|
||||
: Math.round(careerWN8).toLocaleString(),
|
||||
percentile:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
: getWN8Percentile(currentWN8),
|
||||
},
|
||||
damage: {
|
||||
title: 'Damage',
|
||||
current: Math.round(
|
||||
stats.all.damage_dealt / stats.all.battles,
|
||||
).toLocaleString(),
|
||||
career: Math.round(
|
||||
career.all.damage_dealt / career.all.battles,
|
||||
).toLocaleString(),
|
||||
delta:
|
||||
stats.all.damage_dealt / stats.all.battles -
|
||||
career.all.damage_dealt / career.all.battles,
|
||||
},
|
||||
none: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<Breakdown.Row
|
||||
color={session.color}
|
||||
key={stats.tank_id}
|
||||
minimized={!session.showCareer}
|
||||
title={tankopedia?.name ?? `Unknown tank ${stats.tank_id}`}
|
||||
type="tank"
|
||||
tankType={tankopedia?.type}
|
||||
treeType={(() => {
|
||||
if (tankopedia?.is_collectible)
|
||||
return TreeTypeEnum.Collector;
|
||||
if (tankopedia?.is_premium) return TreeTypeEnum.Premium;
|
||||
})()}
|
||||
stats={session.customColumns.map((customColumn) => {
|
||||
const rowStat = rowStats[customColumn.display];
|
||||
return (
|
||||
<Breakdown.Row
|
||||
naked={naked}
|
||||
color={session.color}
|
||||
key={stats.tank_id}
|
||||
minimized={!session.showCareer}
|
||||
title={
|
||||
tankDefinitions?.name ?? `Unknown tank ${stats.tank_id}`
|
||||
}
|
||||
type="tank"
|
||||
tankType={tankDefinitions?.class}
|
||||
treeType={tankDefinitions?.treeType}
|
||||
stats={session.customColumns.map((customColumn) => {
|
||||
const rowStat = rowStats[customColumn.display];
|
||||
|
||||
if (!rowStat) return undefined;
|
||||
if (!rowStat) return undefined;
|
||||
|
||||
return {
|
||||
...rowStat,
|
||||
delta:
|
||||
customColumn.showDelta &&
|
||||
rowStat.percentile === undefined
|
||||
? rowStat.delta
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return {
|
||||
...rowStat,
|
||||
delta:
|
||||
customColumn.showDelta &&
|
||||
rowStat.percentile === undefined
|
||||
? rowStat.delta
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Breakdown.Root>
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
+117
-6
@@ -1,15 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { Flex, Theme } from '@radix-ui/themes';
|
||||
import { CaretRightIcon } from '@radix-ui/react-icons';
|
||||
import { AlertDialog, Button, Flex, Text, Theme } from '@radix-ui/themes';
|
||||
import '@radix-ui/themes/styles.css';
|
||||
import { Analytics } from '@vercel/analytics/react';
|
||||
import { config } from 'dotenv';
|
||||
import { Roboto_Flex } from 'next/font/google';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { SkyLine } from '../assets/art/SkyLine';
|
||||
import Navbar from '../components/Navbar';
|
||||
import PageWrapper from '../components/PageWrapper';
|
||||
import isDev from '../core/blitzkrieg/isDev';
|
||||
import { isLocalhost } from '../core/blitzkrieg/isLocalhost';
|
||||
import { useApp } from '../stores/app';
|
||||
import { mintTheme } from '../themes/mint';
|
||||
|
||||
config();
|
||||
|
||||
const DEV_BUILD_AGREEMENT_COOLDOWN = 8 * 24 * 60 * 60 * 1000;
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -22,6 +32,16 @@ const robotoFlex = Roboto_Flex({
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const isEmbed = pathname.split('/')[1] === 'embeds';
|
||||
const [showDevBuildAlert, setShowDevBuildAlert] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowDevBuildAlert(
|
||||
isDev() &&
|
||||
!isLocalhost() &&
|
||||
Date.now() - useApp.getState().devBuildAgreementTime >=
|
||||
DEV_BUILD_AGREEMENT_COOLDOWN,
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<html lang="en" className={robotoFlex.className}>
|
||||
@@ -36,15 +56,106 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
<body
|
||||
style={{
|
||||
margin: 0,
|
||||
background: isEmbed
|
||||
? 'transparent'
|
||||
: 'url(https://i.imgur.com/PhS06NJ.png)',
|
||||
paddingTop: isEmbed ? 0 : '3.25rem',
|
||||
backgroundColor: isEmbed ? 'transparent' : undefined,
|
||||
}}
|
||||
>
|
||||
<Theme appearance="dark" panelBackground="translucent" radius="full">
|
||||
<Analytics />
|
||||
|
||||
<Theme
|
||||
appearance="dark"
|
||||
panelBackground="translucent"
|
||||
radius="full"
|
||||
suppressHydrationWarning
|
||||
suppressContentEditableWarning
|
||||
>
|
||||
{!isEmbed && <Navbar />}
|
||||
<AlertDialog.Root open={showDevBuildAlert}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Title>Experimental version!</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This version may have a lot of issues. Report issues to{' '}
|
||||
<a href="https://discord.gg/nDt7AjGJQH" target="_blank">
|
||||
the official Discord server
|
||||
</a>
|
||||
. Also consider using{' '}
|
||||
<a href="https://blitz-krieg.vercel.app/">
|
||||
the more stable release version
|
||||
</a>
|
||||
. You will be asked again in 8 days.
|
||||
</AlertDialog.Description>
|
||||
|
||||
<Flex justify="end">
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={() => {
|
||||
setShowDevBuildAlert(false);
|
||||
useApp.setState({ devBuildAgreementTime: Date.now() });
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<Flex direction="column">{children}</Flex>
|
||||
|
||||
{!isEmbed && (
|
||||
<PageWrapper>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://ko-fi.com/tresabhi"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: mintTheme.colors.componentInteractive,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="1"
|
||||
style={{
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Text size="4" color="mint">
|
||||
Blitzkrieg. Free forever. For everyone.
|
||||
</Text>
|
||||
<Text color="mint">
|
||||
<b>Consider supporting today 🍀</b>
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Text
|
||||
color="mint"
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Flex>
|
||||
<CaretRightIcon width={32} height={32} />
|
||||
</Flex>
|
||||
</Text>
|
||||
|
||||
<SkyLine
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 'min(8vw, 15%)',
|
||||
top: '5%',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</a>
|
||||
</PageWrapper>
|
||||
)}
|
||||
</Theme>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { theme } from '../stitches.config';
|
||||
|
||||
export const tool = styleVariants({
|
||||
enabled: {
|
||||
boxShadow: `inset 0 -192px 128px -128px ${theme.colors.appBackground1}`,
|
||||
boxShadow: `inset 0 -256px 512px -128px ${theme.colors.appBackground1}`,
|
||||
|
||||
':hover': {
|
||||
boxShadow: `inset 0 -160px 128px -128px ${theme.colors.appBackground1}`,
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ export default function Page() {
|
||||
({
|
||||
node: (
|
||||
<Link
|
||||
href={`/tools/${tool.id}`}
|
||||
href={tool.href ?? `/tools/${tool.id}`}
|
||||
target={tool.href ? '_blank' : undefined}
|
||||
className={
|
||||
tool.disabled ? styles.tool.disabled : styles.tool.enabled
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { blackA } from '@radix-ui/colors';
|
||||
import { Flex, Text } from '@radix-ui/themes';
|
||||
import { PuffLoader } from 'react-spinners';
|
||||
import { theme } from '../../../stitches.config';
|
||||
|
||||
const NAV_HEIGHT = 52.65;
|
||||
|
||||
interface LoaderProps {
|
||||
naked?: boolean;
|
||||
color?: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export function Loader({ naked, color, progress }: LoaderProps) {
|
||||
const animation = (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{progress !== undefined && (
|
||||
<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} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (naked) return animation;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
style={{
|
||||
backgroundColor: blackA.blackA10,
|
||||
position: 'fixed',
|
||||
top: NAV_HEIGHT,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `calc(100% - ${NAV_HEIGHT}px)`,
|
||||
padding: theme.spaces.paddingMajor,
|
||||
}}
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
{animation}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
+55
-45
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, Suspense } from 'react';
|
||||
import { TOOLS } from '../../constants/tools';
|
||||
import { theme } from '../../stitches.config';
|
||||
import { Loader } from './components/Loader';
|
||||
|
||||
interface ToolsLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -11,59 +13,67 @@ interface ToolsLayoutProps {
|
||||
|
||||
export default function ToolsLayout({ children }: ToolsLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const toolId = pathname.split('/').at(-1);
|
||||
const pathSplit = pathname.split('/');
|
||||
const toolId = pathSplit.at(2);
|
||||
const tool = TOOLS.find((tool) => tool.id === toolId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
height: 128,
|
||||
boxShadow: `inset 0 -192px 128px -128px ${theme.colors.appBackground1}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{tool && <title>{tool.title}</title>}
|
||||
|
||||
{pathSplit.length === 3 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
backgroundImage: `url(/assets/banners/${toolId}.webp)`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: -1,
|
||||
filter: 'blur(4px)',
|
||||
transform: 'scale(calc((128 + 8) / 128))',
|
||||
}}
|
||||
/>
|
||||
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textHighContrast,
|
||||
fontSize: 32,
|
||||
height: 128,
|
||||
boxShadow: `inset 0 -192px 128px -128px ${theme.colors.appBackground1}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{tool?.title}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textLowContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{tool?.description}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
backgroundImage: `url(/assets/banners/${toolId}.webp)`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: -1,
|
||||
filter: 'blur(4px)',
|
||||
transform: 'scale(calc((128 + 8) / 128))',
|
||||
}}
|
||||
/>
|
||||
|
||||
{children}
|
||||
<Link
|
||||
href={`/tools/${toolId}`}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
fontWeight: 900,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontSize: 32,
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
{tool?.title}
|
||||
</Link>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textLowContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{tool?.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Suspense fallback={<Loader />}>{children}</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,8 @@ export function PageTurner({
|
||||
type="number"
|
||||
ref={pageInput}
|
||||
style={{ width: 64, textAlign: 'center' }}
|
||||
min={1}
|
||||
max={pages}
|
||||
onBlur={(event) => {
|
||||
onPageChange(
|
||||
Math.max(
|
||||
|
||||
@@ -20,7 +20,11 @@ import PageWrapper from '../../../components/PageWrapper';
|
||||
import { Skeleton } from '../../../components/Skeleton';
|
||||
import { LEAGUES } from '../../../constants/leagues';
|
||||
import { FIRST_ARCHIVED_RATINGS_SEASON } from '../../../constants/ratings';
|
||||
import { REGIONS, REGION_NAMES, Region } from '../../../constants/regions';
|
||||
import {
|
||||
REGIONS,
|
||||
Region,
|
||||
UNLOCALIZED_REGION_NAMES,
|
||||
} from '../../../constants/regions';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
|
||||
import fetchBlitz from '../../../core/blitz/fetchBlitz';
|
||||
import { getClanAccountInfo } from '../../../core/blitz/getClanAccountInfo';
|
||||
@@ -556,7 +560,7 @@ export default function Page() {
|
||||
<Select.Content>
|
||||
{REGIONS.map((region) => (
|
||||
<Select.Item key={region} value={region}>
|
||||
{REGION_NAMES[region]}
|
||||
{UNLOCALIZED_REGION_NAMES[region]}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { ContextMenu, DropdownMenu } from '@radix-ui/themes';
|
||||
import {
|
||||
Button,
|
||||
ContextMenu,
|
||||
Dialog,
|
||||
DropdownMenu,
|
||||
Flex,
|
||||
Heading,
|
||||
TextField,
|
||||
} from '@radix-ui/themes';
|
||||
import { createColors } from 'bepaint';
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
ACCENT_COLORS,
|
||||
AccentColor,
|
||||
GRAY_COLORS,
|
||||
GrayColor,
|
||||
PALETTES,
|
||||
} from '../../../../constants/radixColors';
|
||||
import { useSession } from '../../../../stores/session';
|
||||
import { CustomColumn } from './CustomColumn';
|
||||
@@ -15,59 +26,111 @@ interface ContextMenuProps {
|
||||
|
||||
export function Menu({ Builder, reset }: ContextMenuProps) {
|
||||
const session = useSession();
|
||||
const [showTitleEditor, setShowTitleEditor] = useState(false);
|
||||
const titleInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<Builder.Content>
|
||||
<Builder.Sub>
|
||||
<Builder.SubTrigger>Accent color</Builder.SubTrigger>
|
||||
<>
|
||||
<Dialog.Root open={showTitleEditor} onOpenChange={setShowTitleEditor}>
|
||||
<Dialog.Content>
|
||||
<Flex direction="column" gap="4">
|
||||
<Heading size="4">Edit title</Heading>
|
||||
|
||||
<Builder.SubContent>
|
||||
<Builder.RadioGroup
|
||||
value={session.color}
|
||||
onValueChange={(value) =>
|
||||
useSession.setState({ color: value as AccentColor | GrayColor })
|
||||
}
|
||||
>
|
||||
{[...ACCENT_COLORS, ...GRAY_COLORS].map((color) => (
|
||||
<Builder.RadioItem value={color}>{`${color
|
||||
.charAt(0)
|
||||
.toUpperCase()}${color.slice(1)}`}</Builder.RadioItem>
|
||||
))}
|
||||
</Builder.RadioGroup>
|
||||
</Builder.SubContent>
|
||||
</Builder.Sub>
|
||||
<Flex gap="2" style={{ width: '100%' }}>
|
||||
<TextField.Root style={{ flex: 1 }}>
|
||||
<TextField.Input
|
||||
ref={titleInput}
|
||||
defaultValue={session.title}
|
||||
placeholder="Enter title..."
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && titleInput.current) {
|
||||
setShowTitleEditor(false);
|
||||
useSession.setState({ title: titleInput.current!.value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TextField.Root>
|
||||
|
||||
<Builder.Separator />
|
||||
<Dialog.Close>
|
||||
<Button
|
||||
onClick={() =>
|
||||
useSession.setState({ title: titleInput.current!.value })
|
||||
}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<CustomColumn Builder={Builder} column={0} />
|
||||
<CustomColumn Builder={Builder} column={1} />
|
||||
<CustomColumn Builder={Builder} column={2} />
|
||||
<CustomColumn Builder={Builder} column={3} />
|
||||
<Builder.Content>
|
||||
<Builder.Sub>
|
||||
<Builder.SubTrigger>Accent color</Builder.SubTrigger>
|
||||
|
||||
<Builder.Separator />
|
||||
<Builder.SubContent>
|
||||
<Builder.RadioGroup
|
||||
value={session.color}
|
||||
onValueChange={(value) =>
|
||||
useSession.setState({ color: value as AccentColor | GrayColor })
|
||||
}
|
||||
>
|
||||
{[...ACCENT_COLORS, ...GRAY_COLORS].map((color) => (
|
||||
<Builder.RadioItem value={color} key={color}>
|
||||
<Flex gap="2" align="center">
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: createColors(PALETTES[color])
|
||||
.solidBackground,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '100%',
|
||||
}}
|
||||
/>
|
||||
{`${color.charAt(0).toUpperCase()}${color.slice(1)}`}
|
||||
</Flex>
|
||||
</Builder.RadioItem>
|
||||
))}
|
||||
</Builder.RadioGroup>
|
||||
</Builder.SubContent>
|
||||
</Builder.Sub>
|
||||
|
||||
<Builder.CheckboxItem
|
||||
checked={session.showTotal}
|
||||
onCheckedChange={(checked) =>
|
||||
useSession.setState({ showTotal: checked })
|
||||
}
|
||||
>
|
||||
Show total
|
||||
</Builder.CheckboxItem>
|
||||
<Builder.CheckboxItem
|
||||
checked={session.showCareer}
|
||||
onCheckedChange={(checked) =>
|
||||
useSession.setState({ showCareer: checked })
|
||||
}
|
||||
>
|
||||
Show career
|
||||
</Builder.CheckboxItem>
|
||||
<Builder.Separator />
|
||||
|
||||
<Builder.Separator />
|
||||
<Builder.Item onClick={() => setShowTitleEditor(true)}>
|
||||
Edit title...
|
||||
</Builder.Item>
|
||||
<CustomColumn Builder={Builder} column={0} />
|
||||
<CustomColumn Builder={Builder} column={1} />
|
||||
<CustomColumn Builder={Builder} column={2} />
|
||||
<CustomColumn Builder={Builder} column={3} />
|
||||
|
||||
<Builder.Item color="red" onClick={reset}>
|
||||
Reset
|
||||
</Builder.Item>
|
||||
</Builder.Content>
|
||||
<Builder.Separator />
|
||||
|
||||
<Builder.CheckboxItem
|
||||
checked={session.showTotal}
|
||||
onCheckedChange={(checked) =>
|
||||
useSession.setState({ showTotal: checked })
|
||||
}
|
||||
>
|
||||
Show total
|
||||
</Builder.CheckboxItem>
|
||||
<Builder.CheckboxItem
|
||||
checked={session.showCareer}
|
||||
onCheckedChange={(checked) =>
|
||||
useSession.setState({ showCareer: checked })
|
||||
}
|
||||
>
|
||||
Show career
|
||||
</Builder.CheckboxItem>
|
||||
|
||||
<Builder.Separator />
|
||||
|
||||
<Builder.Item color="red" onClick={reset}>
|
||||
Reset
|
||||
</Builder.Item>
|
||||
</Builder.Content>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { debounce } from 'lodash';
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import PageWrapper from '../../../components/PageWrapper';
|
||||
import { REGION_NAMES } from '../../../constants/regions';
|
||||
import { UNLOCALIZED_REGION_NAMES } from '../../../constants/regions';
|
||||
import searchPlayersAcrossRegions, {
|
||||
AccountListWithServer,
|
||||
} from '../../../core/blitz/searchPlayersAcrossRegions';
|
||||
@@ -107,7 +107,7 @@ export default function Page() {
|
||||
setShowSearchResults(false);
|
||||
setSession(region, id, nickname);
|
||||
}}
|
||||
shortcut={REGION_NAMES[region]}
|
||||
shortcut={UNLOCALIZED_REGION_NAMES[region]}
|
||||
>
|
||||
{nickname}
|
||||
</DropdownMenu.Item>
|
||||
@@ -248,7 +248,7 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SessionPage />
|
||||
<SessionPage naked={false} />
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { CaretRightIcon, Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
Flex,
|
||||
Heading,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@radix-ui/themes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { ModuleButton } from '../../../../../components/ModuleButtons/ModuleButton';
|
||||
import { ShellButton } from '../../../../../components/ModuleButtons/ShellButton';
|
||||
import { SmallTankIcon } from '../../../../../components/SmallTankIcon';
|
||||
import { resolveNearPenetration } from '../../../../../core/blitz/resolveNearPenetration';
|
||||
import { pushTankopediaPath } from '../../../../../core/blitzkrieg/pushTankopediaPath';
|
||||
import {
|
||||
SHELL_NAMES,
|
||||
TIER_ROMAN_NUMERALS,
|
||||
} from '../../../../../core/blitzkrieg/tankDefinitions';
|
||||
import { mutateDuel, useDuel } from '../../../../../stores/duel';
|
||||
import {
|
||||
mutateTankopediaTemporary,
|
||||
useTankopediaPersistent,
|
||||
} from '../../../../../stores/tankopedia';
|
||||
import { TankSearch } from '../../components/TankSearch';
|
||||
|
||||
interface AntagonistBarProps {
|
||||
floating?: boolean;
|
||||
}
|
||||
|
||||
export function AntagonistBar({ floating }: AntagonistBarProps) {
|
||||
const antagonist = useDuel((state) => state.antagonist!);
|
||||
const mode = useTankopediaPersistent((state) => state.mode);
|
||||
const [tab, setTab] = useState('search');
|
||||
const [antagonistSelectorOpen, setAntagonistSelectorVisible] =
|
||||
useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
if (mode !== 'armor') return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={
|
||||
floating
|
||||
? {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Flex align="center" justify="between" gap="6">
|
||||
<Flex>
|
||||
{antagonist.gun.shells.map((shell, index) => {
|
||||
return (
|
||||
<ShellButton
|
||||
selected={antagonist.shell.id === shell.id}
|
||||
shell={shell.icon}
|
||||
rowChild
|
||||
first={index === 0}
|
||||
key={shell.id}
|
||||
last={index === antagonist.gun.shells.length - 1}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist!.shell = shell;
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
<Flex align="center" gap="4">
|
||||
{!floating && <Text>Versus</Text>}
|
||||
|
||||
{floating && (
|
||||
<Flex gap="2" align="center">
|
||||
Versus {antagonist.tank.name}
|
||||
<SmallTankIcon id={antagonist.tank.id} size={16} />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{!floating && (
|
||||
<Dialog.Root
|
||||
open={antagonistSelectorOpen}
|
||||
onOpenChange={setAntagonistSelectorVisible}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="ghost">
|
||||
<Flex gap="2" align="center">
|
||||
{antagonist.tank.name}
|
||||
<SmallTankIcon id={antagonist.tank.id} size={16} />
|
||||
<CaretRightIcon />
|
||||
</Flex>
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content>
|
||||
<Tabs.Root
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<Dialog.Close>
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ position: 'absolute', right: 0, top: 8 }}
|
||||
>
|
||||
<Cross1Icon />
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
|
||||
<Flex gap="4" direction="column">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="search">Search</Tabs.Trigger>
|
||||
<Tabs.Trigger value="configure">Configure</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="search">
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="4"
|
||||
style={{ flex: 1 }}
|
||||
justify="center"
|
||||
>
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist!.tank = tank;
|
||||
draft.antagonist!.engine = tank.engines.at(-1)!;
|
||||
draft.antagonist!.track = tank.tracks.at(-1)!;
|
||||
draft.antagonist!.turret = tank.turrets.at(-1)!;
|
||||
draft.antagonist!.gun =
|
||||
draft.antagonist!.turret.guns.at(-1)!;
|
||||
draft.antagonist!.shell =
|
||||
draft.antagonist!.gun.shells[0];
|
||||
});
|
||||
setAntagonistSelectorVisible(false);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="configure">
|
||||
<Flex direction="column" gap="4">
|
||||
<Flex direction="column" gap="2" style={{ flex: 1 }}>
|
||||
<Heading size="4">
|
||||
{antagonist.tank.name} modules
|
||||
</Heading>
|
||||
|
||||
<Flex gap="2" wrap="wrap">
|
||||
<Flex>
|
||||
{antagonist.tank.turrets.map((turret, index) => (
|
||||
<Tooltip content={turret.name} key={turret.id}>
|
||||
<ModuleButton
|
||||
rowChild
|
||||
first={index === 0}
|
||||
last={
|
||||
index ===
|
||||
antagonist.tank.turrets.length - 1
|
||||
}
|
||||
key={turret.id}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist!.turret = turret;
|
||||
draft.antagonist!.gun =
|
||||
turret.guns.at(-1)!;
|
||||
draft.antagonist!.shell =
|
||||
draft.antagonist!.gun.shells[0];
|
||||
});
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
selected={
|
||||
antagonist.turret.id === turret.id
|
||||
}
|
||||
discriminator={
|
||||
TIER_ROMAN_NUMERALS[turret.tier]
|
||||
}
|
||||
module="turret"
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex>
|
||||
{antagonist.turret.guns.map((gun, index) => (
|
||||
<Tooltip content={gun.name} key={gun.id}>
|
||||
<ModuleButton
|
||||
rowChild
|
||||
first={index === 0}
|
||||
last={
|
||||
index ===
|
||||
antagonist.turret.guns.length - 1
|
||||
}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist!.gun = gun;
|
||||
draft.antagonist!.shell = gun.shells[0];
|
||||
});
|
||||
}}
|
||||
selected={antagonist.gun.id === gun.id}
|
||||
discriminator={
|
||||
TIER_ROMAN_NUMERALS[gun.tier]
|
||||
}
|
||||
module="gun"
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" style={{ flex: 1 }}>
|
||||
<Heading size="4">Properties</Heading>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
Turret: <b>{antagonist.turret.name}</b>
|
||||
</li>
|
||||
<li>
|
||||
Gun: <b>{antagonist.gun.name}</b>
|
||||
</li>
|
||||
<li>Shells:</li>
|
||||
<ul>
|
||||
{antagonist.gun.shells.map((shell) => (
|
||||
<li key={shell.id}>
|
||||
{SHELL_NAMES[shell.type]}:{' '}
|
||||
<b>
|
||||
{resolveNearPenetration(shell.penetration)}
|
||||
mm
|
||||
</b>
|
||||
, {shell.damage.armor}HP
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ul>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Tabs.Content>
|
||||
</Flex>
|
||||
</Tabs.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
[draft.protagonist, draft.antagonist] = [
|
||||
draft.antagonist,
|
||||
draft.protagonist,
|
||||
];
|
||||
pushTankopediaPath(draft.antagonist!.tank.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ShuffleIcon /> Swap
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { ReactNode } from 'react';
|
||||
import * as styles from '../../../../page.css';
|
||||
|
||||
interface ConfigurationChildWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ConfigurationChildWrapper({
|
||||
children,
|
||||
}: ConfigurationChildWrapperProps) {
|
||||
return <div className={styles.configurationChild}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Flex, Heading } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { ConsumableButton } from '../../../../../../../components/ModuleButtons/ConsumableButton';
|
||||
import { checkConsumableProvisionInclusivity } from '../../../../../../../core/blitzkrieg/checkConsumableProvisionInclusivity';
|
||||
import { consumableDefinitions } from '../../../../../../../core/blitzkrieg/consumableDefinitions';
|
||||
import { useEquipment } from '../../../../../../../hooks/useEquipment';
|
||||
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
||||
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
|
||||
|
||||
export function Consumables() {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const awaitedConsumableDefinitions = use(consumableDefinitions);
|
||||
const consumables = useDuel((state) => state.protagonist!.consumables);
|
||||
const consumablesList = Object.values(awaitedConsumableDefinitions).filter(
|
||||
(consumable) =>
|
||||
checkConsumableProvisionInclusivity(
|
||||
consumable,
|
||||
protagonist.tank,
|
||||
protagonist.gun,
|
||||
),
|
||||
);
|
||||
const cooldownBooster = useDuel(
|
||||
(state) => state.protagonist!.cooldownBooster,
|
||||
);
|
||||
const hasConsumableDeliverySystem = useEquipment(118);
|
||||
const hasHighEndConsumables = useEquipment(101);
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Heading size="4">Consumables</Heading>
|
||||
|
||||
<Flex wrap="wrap">
|
||||
{consumablesList.map((consumable, index) => {
|
||||
const selected = consumables.includes(consumable.id);
|
||||
|
||||
return (
|
||||
<ConsumableButton
|
||||
duration={
|
||||
typeof consumable.duration === 'number'
|
||||
? consumable.duration * (hasHighEndConsumables ? 0.7 : 1)
|
||||
: undefined
|
||||
}
|
||||
cooldown={
|
||||
consumable.cooldown *
|
||||
(hasConsumableDeliverySystem ? 0.85 : 1) *
|
||||
(1 - cooldownBooster * 0.1)
|
||||
}
|
||||
key={consumable.id}
|
||||
first={index === 0}
|
||||
last={index === consumablesList.length - 1}
|
||||
rowChild
|
||||
disabled={
|
||||
protagonist.tank.consumables === consumables.length && !selected
|
||||
}
|
||||
consumable={consumable.id}
|
||||
selected={selected}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
if (selected) {
|
||||
draft.protagonist!.consumables =
|
||||
draft.protagonist!.consumables.filter(
|
||||
(id) => id !== consumable.id,
|
||||
);
|
||||
} else {
|
||||
draft.protagonist!.consumables.push(consumable.id);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { CaretDownIcon, CaretUpIcon } from '@radix-ui/react-icons';
|
||||
import { Flex, Text } from '@radix-ui/themes';
|
||||
import { ReactNode } from 'react';
|
||||
import { theme } from '../../../../../../../stitches.config';
|
||||
|
||||
export interface InfoProps {
|
||||
name: ReactNode;
|
||||
children?: ReactNode;
|
||||
unit?: string;
|
||||
indent?: boolean;
|
||||
highlight?: boolean;
|
||||
delta?: number;
|
||||
decimals?: number;
|
||||
prefix?: string;
|
||||
deltaType?: 'higherIsBetter' | 'lowerIsBetter';
|
||||
}
|
||||
|
||||
export function Info({
|
||||
name,
|
||||
children,
|
||||
unit,
|
||||
indent = false,
|
||||
highlight,
|
||||
delta,
|
||||
decimals,
|
||||
prefix,
|
||||
deltaType = 'higherIsBetter',
|
||||
}: InfoProps) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
style={{ width: '100%', paddingLeft: indent ? 24 : 0 }}
|
||||
gap="4"
|
||||
>
|
||||
<Text color={highlight ? 'amber' : undefined}>
|
||||
{name}
|
||||
{unit != undefined && (
|
||||
<>
|
||||
{' '}
|
||||
<Text color="gray" size="1">
|
||||
{unit}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{children !== undefined && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: highlight
|
||||
? theme.colors.componentCallToActionInteractive_amberAlpha
|
||||
: theme.colors.componentCallToActionInteractive_alpha,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children !== undefined && (
|
||||
<Flex align="center" gap="1">
|
||||
{delta !== undefined && delta !== 0 && (
|
||||
<>
|
||||
<Text
|
||||
color={
|
||||
delta * (deltaType === 'lowerIsBetter' ? -1 : 1) > 0
|
||||
? 'green'
|
||||
: 'tomato'
|
||||
}
|
||||
>
|
||||
{decimals !== undefined
|
||||
? Math.abs(delta).toFixed(decimals)
|
||||
: Math.abs(delta)}
|
||||
</Text>
|
||||
<Text
|
||||
color={
|
||||
delta * (deltaType === 'lowerIsBetter' ? -1 : 1) > 0
|
||||
? 'green'
|
||||
: 'tomato'
|
||||
}
|
||||
>
|
||||
{delta > 0 ? <CaretUpIcon /> : <CaretDownIcon />}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Text color={highlight ? 'amber' : undefined}>
|
||||
{prefix}
|
||||
{decimals !== undefined && typeof children === 'number'
|
||||
? children.toFixed(decimals)
|
||||
: children}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useDelta } from '../../../../../../../hooks/useDelta';
|
||||
import { Info, InfoProps } from './Info';
|
||||
|
||||
interface InfoWithDeltaProps extends InfoProps {
|
||||
children: number;
|
||||
}
|
||||
|
||||
export function InfoWithDelta({ children, ...props }: InfoWithDeltaProps) {
|
||||
const delta = useDelta(children);
|
||||
|
||||
return (
|
||||
<Info {...props} delta={delta}>
|
||||
{children}
|
||||
</Info>
|
||||
);
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { Flex, Heading, Slider, TextField } from '@radix-ui/themes';
|
||||
import { clamp, debounce } from 'lodash';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { GenericTankComponentButton } from '../../../../../../../components/ModuleButtons/GenericTankComponentButton';
|
||||
import { asset } from '../../../../../../../core/blitzkrieg/asset';
|
||||
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
||||
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
|
||||
|
||||
export function Miscellaneous() {
|
||||
const camouflage = useDuel((state) => state.protagonist!.camouflage);
|
||||
const cooldownBooster = useDuel(
|
||||
(state) => state.protagonist!.cooldownBooster,
|
||||
);
|
||||
const crewMastery = useDuel((state) => state.protagonist!.crewMastery);
|
||||
const crewInput = useRef<HTMLInputElement>(null);
|
||||
const [crewMasteryDraft, setCrewMasteryDraft] = useState(crewMastery);
|
||||
const { tank } = useDuel((state) => state.protagonist!);
|
||||
|
||||
const debouncedApplyDraft = useMemo(
|
||||
() =>
|
||||
debounce((value) => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.crewMastery = value;
|
||||
});
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCrewMasteryDraft(crewMastery);
|
||||
if (!crewInput.current) return;
|
||||
crewInput.current.value = `${Math.round(crewMastery * 100)}`;
|
||||
}, [crewMastery]);
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Heading size="4">Miscellaneous</Heading>
|
||||
|
||||
<Flex gap="2" align="center">
|
||||
{!tank.fixedCamouflage && (
|
||||
<GenericTankComponentButton
|
||||
icon={asset('icons/camo.webp')}
|
||||
selected={camouflage}
|
||||
iconStyles={{
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(calc(-50% + 4px), calc(-50% + 4px))',
|
||||
}}
|
||||
first
|
||||
last
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.camouflage = !camouflage;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<GenericTankComponentButton
|
||||
icon={asset('icons/boosters/equipment.webp')}
|
||||
selected={cooldownBooster > 0}
|
||||
first
|
||||
last
|
||||
banner={
|
||||
cooldownBooster === 0
|
||||
? undefined
|
||||
: cooldownBooster === 1
|
||||
? '#afb0abc0'
|
||||
: cooldownBooster === 2
|
||||
? '#5f72cbc0'
|
||||
: '#9b3cc0c0'
|
||||
}
|
||||
iconStyles={{
|
||||
transform: 'translate(-50%, -50%) scale(0.8)',
|
||||
}}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.cooldownBooster++;
|
||||
if (draft.protagonist!.cooldownBooster === 4)
|
||||
draft.protagonist!.cooldownBooster = 0;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{tank.treeType === 'researchable' && (
|
||||
<Flex
|
||||
align="center"
|
||||
direction="column"
|
||||
gap="1"
|
||||
justify="end"
|
||||
style={{ width: 180 }}
|
||||
>
|
||||
<TextField.Root>
|
||||
<TextField.Slot>Crew</TextField.Slot>
|
||||
<TextField.Input
|
||||
ref={crewInput}
|
||||
defaultValue={Math.round(crewMastery * 100)}
|
||||
style={{ textAlign: 'right' }}
|
||||
onBlur={() => {
|
||||
const newValue = Number(crewInput.current!.value) / 100;
|
||||
|
||||
if (isNaN(newValue)) {
|
||||
crewInput.current!.value = `${Math.round(crewMastery * 100)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.crewMastery = clamp(
|
||||
newValue / 100,
|
||||
0.5,
|
||||
1,
|
||||
);
|
||||
});
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
crewInput.current?.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField.Slot>%</TextField.Slot>
|
||||
</TextField.Root>
|
||||
|
||||
<Slider
|
||||
min={50}
|
||||
max={100}
|
||||
value={[crewMasteryDraft * 100]}
|
||||
style={{ width: '100%' }}
|
||||
onValueChange={([value]) => {
|
||||
setCrewMasteryDraft(value / 100);
|
||||
debouncedApplyDraft(value / 100);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Flex, Heading } from '@radix-ui/themes';
|
||||
import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton';
|
||||
import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkrieg/tankDefinitions';
|
||||
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
||||
import { mutateTankopediaTemporary } from '../../../../../../../stores/tankopedia';
|
||||
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
|
||||
|
||||
export function Modules() {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Heading size="4">Modules</Heading>
|
||||
|
||||
<Flex gap="2" wrap="wrap">
|
||||
<Flex>
|
||||
{protagonist.tank.turrets.map((turret, index) => {
|
||||
return (
|
||||
<ModuleButton
|
||||
key={turret.id}
|
||||
selected={protagonist.turret.id === turret.id}
|
||||
module="turret"
|
||||
discriminator={TIER_ROMAN_NUMERALS[turret.tier]}
|
||||
first={index === 0}
|
||||
last={index === protagonist.tank.turrets.length - 1}
|
||||
rowChild
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.turret = turret;
|
||||
draft.protagonist!.gun = turret.guns.at(-1)!;
|
||||
draft.protagonist!.shell = turret.guns.at(-1)!.shells[0];
|
||||
});
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
<Flex>
|
||||
{protagonist.turret.guns.map((gun, index) => {
|
||||
return (
|
||||
<ModuleButton
|
||||
key={gun.id}
|
||||
module="gun"
|
||||
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
|
||||
selected={protagonist.gun.id === gun.id}
|
||||
first={index === 0}
|
||||
last={index === protagonist.turret.guns.length - 1}
|
||||
rowChild
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.gun = gun;
|
||||
draft.protagonist!.shell = gun.shells[0];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
<Flex>
|
||||
{protagonist.tank.engines.map((engine, index) => {
|
||||
return (
|
||||
<ModuleButton
|
||||
key={engine.id}
|
||||
module="engine"
|
||||
discriminator={TIER_ROMAN_NUMERALS[engine.tier]}
|
||||
selected={protagonist.engine.id === engine.id}
|
||||
first={index === 0}
|
||||
last={index === protagonist.tank.engines.length - 1}
|
||||
rowChild
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.engine = engine;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
<Flex>
|
||||
{protagonist.tank.tracks.map((track, index) => {
|
||||
return (
|
||||
<ModuleButton
|
||||
key={track.id}
|
||||
module="chassis"
|
||||
discriminator={TIER_ROMAN_NUMERALS[track.tier]}
|
||||
selected={protagonist.track.id === track.id}
|
||||
first={index === 0}
|
||||
last={index === protagonist.tank.tracks.length - 1}
|
||||
rowChild
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.track = track;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Flex, Heading } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { ProvisionButton } from '../../../../../../../components/ModuleButtons/ProvisionButton';
|
||||
import { availableProvisions } from '../../../../../../../core/blitzkrieg/availableProvisions';
|
||||
import { provisionDefinitions } from '../../../../../../../core/blitzkrieg/provisionDefinitions';
|
||||
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
||||
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
|
||||
|
||||
export function Provisions() {
|
||||
const { tank, gun } = useDuel((state) => state.protagonist!);
|
||||
const provisions = useDuel((state) => state.protagonist!.provisions);
|
||||
const awaitedProvisionDefinitions = use(provisionDefinitions);
|
||||
const provisionsList = availableProvisions(
|
||||
tank,
|
||||
gun,
|
||||
awaitedProvisionDefinitions,
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Heading size="4">Provisions</Heading>
|
||||
|
||||
<Flex wrap="wrap">
|
||||
{provisionsList.map((provision, index) => {
|
||||
const selected = provisions.includes(provision.id);
|
||||
|
||||
return (
|
||||
<ProvisionButton
|
||||
key={provision.id}
|
||||
first={index === 0}
|
||||
last={index === provisionsList.length - 1}
|
||||
rowChild
|
||||
disabled={tank.provisions === provisions.length && !selected}
|
||||
provision={provision.id}
|
||||
selected={selected}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
if (selected) {
|
||||
draft.protagonist!.provisions =
|
||||
draft.protagonist!.provisions.filter(
|
||||
(id) => id !== provision.id,
|
||||
);
|
||||
} else {
|
||||
draft.protagonist!.provisions.push(provision.id);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import { AccessibilityIcon } from '@radix-ui/react-icons';
|
||||
import { Flex, Heading, Slider, Text, TextField } from '@radix-ui/themes';
|
||||
import { debounce } from 'lodash';
|
||||
import { use, useEffect, useRef, useState } from 'react';
|
||||
import { lerp } from 'three/src/math/MathUtils';
|
||||
import { ShellButton } from '../../../../../../components/ModuleButtons/ShellButton';
|
||||
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
|
||||
import { coefficient } from '../../../../../../core/blitzkrieg/coefficient';
|
||||
import { degressiveStat } from '../../../../../../core/blitzkrieg/degressiveStat';
|
||||
import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
|
||||
import { normalizeBoundingBox } from '../../../../../../core/blitzkrieg/normalizeBoundingBox';
|
||||
import { progressiveStat } from '../../../../../../core/blitzkrieg/progressiveStat';
|
||||
import { resolveDpm } from '../../../../../../core/blitzkrieg/resolveDpm';
|
||||
import { sum } from '../../../../../../core/blitzkrieg/sum';
|
||||
import {
|
||||
CREW_MEMBER_NAMES,
|
||||
GUN_TYPE_NAMES,
|
||||
SHELL_NAMES,
|
||||
} from '../../../../../../core/blitzkrieg/tankDefinitions';
|
||||
import { unionBoundingBox } from '../../../../../../core/blitzkrieg/unionBoundingBox';
|
||||
import { useConsumable } from '../../../../../../hooks/useConsumable';
|
||||
import { useEquipment } from '../../../../../../hooks/useEquipment';
|
||||
import { useProvision } from '../../../../../../hooks/useProvision';
|
||||
import { useProvisions } from '../../../../../../hooks/useProvisions';
|
||||
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
|
||||
import { Info } from './components/Info';
|
||||
import { InfoWithDelta } from './components/InfoWithDelta';
|
||||
|
||||
export function Characteristics() {
|
||||
const awaitedModelDefinitions = use(modelDefinitions);
|
||||
const { tank, turret, gun, engine, track, shell } = useDuel(
|
||||
(state) => state.protagonist!,
|
||||
);
|
||||
const hasImprovedVentilation = useEquipment(102);
|
||||
const provisions = useProvisions();
|
||||
const crewMastery = useDuel((state) => state.protagonist!.crewMastery);
|
||||
const provisionCrewBonus =
|
||||
provisions.reduce(
|
||||
(total, provision) =>
|
||||
provision.crew ? total + provision.crew / 100 : total,
|
||||
0,
|
||||
) + (hasImprovedVentilation ? 0.05 : 0);
|
||||
const commanderMastery = crewMastery + provisionCrewBonus;
|
||||
const crewMemberMastery = commanderMastery * 1.1;
|
||||
const stockEngine = tank.engines[0];
|
||||
const stockTrack = tank.tracks[0];
|
||||
const stockTurret = tank.turrets[0];
|
||||
const stockGun = stockTurret.guns[0];
|
||||
const tankModelDefinition = awaitedModelDefinitions[tank.id];
|
||||
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
||||
const size = normalizeBoundingBox(
|
||||
unionBoundingBox(
|
||||
tankModelDefinition.boundingBox,
|
||||
turretModelDefinition.boundingBox,
|
||||
),
|
||||
);
|
||||
const weight =
|
||||
tank.weight + engine.weight + track.weight + turret.weight + gun.weight;
|
||||
const weightTons = weight / 1000;
|
||||
const stockWeight =
|
||||
tank.weight +
|
||||
stockEngine.weight +
|
||||
stockTrack.weight +
|
||||
stockTurret.weight +
|
||||
stockGun.weight;
|
||||
const [penetrationDistance, setPenetrationDistance] = useState(250);
|
||||
const camouflage = useDuel((state) => state.protagonist!.camouflage);
|
||||
const hasRammer = useEquipment(100);
|
||||
const hasCalibratedShells = useEquipment(103);
|
||||
const hasEnhancedGunLayingDrive = useEquipment(104);
|
||||
const hasSupercharger = useEquipment(107);
|
||||
const hasVerticalStabilizer = useEquipment(105);
|
||||
const hasRefinedGun = useEquipment(106);
|
||||
const hasImprovedAssembly = useEquipment(111);
|
||||
const hasImprovedOptics = useEquipment(114);
|
||||
const hasImprovedControl = useEquipment(116);
|
||||
const hasEngineAccelerator = useEquipment(117);
|
||||
const hasCamouflageNet = useEquipment(115);
|
||||
const hasImprovedVerticalStabilizer = useEquipment(122);
|
||||
const hasImprovedSuspension = useEquipment(123);
|
||||
const penetrationDistanceInput = useRef<HTMLInputElement>(null);
|
||||
const hasEnginePowerBoost = useConsumable(14);
|
||||
const hasImprovedEnginePowerBoost = useConsumable(27);
|
||||
const hasAdrenaline = useConsumable(18);
|
||||
const hasTungsten = useConsumable(45);
|
||||
const hasReticleCalibration = useConsumable(28);
|
||||
const hasShellReloadBoost = useConsumable(29);
|
||||
const hasStandardFuel = useProvision(18);
|
||||
const hasImprovedFuel = useProvision(19);
|
||||
const hasProtectiveKit = useProvision(22);
|
||||
const hasSandbagArmor = useProvision(28);
|
||||
const hasEnhancedSandbagArmor = useProvision(29);
|
||||
const hasImprovedGunPowder = useProvision(46);
|
||||
const hasGearOil = useProvision(44);
|
||||
const hasImprovedGearOil = useProvision(45);
|
||||
|
||||
const healthCoefficient = coefficient(
|
||||
[hasSandbagArmor, 0.03],
|
||||
[hasEnhancedSandbagArmor, 0.06],
|
||||
[hasImprovedAssembly, 0.06],
|
||||
);
|
||||
const reloadCoefficient =
|
||||
(coefficient([hasRammer, -0.07]) *
|
||||
coefficient([true, degressiveStat(crewMemberMastery)])) /
|
||||
coefficient([hasAdrenaline && gun.type === 'regular', 0.2]);
|
||||
const penetrationCoefficient = coefficient([
|
||||
hasCalibratedShells,
|
||||
isExplosive(shell.type) ? 0.1 : 0.05,
|
||||
]);
|
||||
const damageCoefficient = coefficient(
|
||||
// TODO: add max rolls
|
||||
[hasTungsten, 0.15],
|
||||
);
|
||||
const interClipCoefficient = coefficient([hasShellReloadBoost, -0.3]);
|
||||
const shellVelocityCoefficient = coefficient(
|
||||
[hasSupercharger, 0.3],
|
||||
[hasImprovedGunPowder, 0.3],
|
||||
);
|
||||
const penetrationLossOverDistanceCoefficient = coefficient([
|
||||
hasSupercharger,
|
||||
-0.5,
|
||||
]);
|
||||
const aimTimeCoefficient =
|
||||
coefficient(
|
||||
[hasEnhancedGunLayingDrive, -0.1],
|
||||
[hasReticleCalibration, -0.4],
|
||||
) * coefficient([true, degressiveStat(crewMemberMastery)]);
|
||||
const dispersionStillCoefficient =
|
||||
coefficient([hasRefinedGun, -0.1], [hasReticleCalibration, -0.4]) *
|
||||
coefficient([true, degressiveStat(crewMemberMastery)]);
|
||||
const dispersionMovingCoefficient = coefficient([
|
||||
hasVerticalStabilizer,
|
||||
-0.15,
|
||||
]);
|
||||
const enginePowerCoefficient = coefficient(
|
||||
[
|
||||
hasEngineAccelerator &&
|
||||
(tank.class === 'lightTank' || tank.class === 'mediumTank'),
|
||||
0.05,
|
||||
],
|
||||
[
|
||||
hasEngineAccelerator &&
|
||||
(tank.class === 'heavyTank' || tank.class === 'AT-SPG'),
|
||||
0.07,
|
||||
],
|
||||
[hasEnginePowerBoost, 0.2],
|
||||
[hasImprovedEnginePowerBoost, 0.4],
|
||||
[hasStandardFuel, 0.03],
|
||||
[hasImprovedFuel, 0.1],
|
||||
[hasGearOil, 0.03],
|
||||
[hasImprovedGearOil, 0.06],
|
||||
);
|
||||
const turretTraverseCoefficient =
|
||||
coefficient([hasStandardFuel, 0.03], [hasImprovedFuel, 0.1]) *
|
||||
coefficient([true, progressiveStat(crewMemberMastery)]);
|
||||
const hullTraverseCoefficient =
|
||||
coefficient(
|
||||
[hasImprovedControl, 0.1],
|
||||
[hasImprovedEnginePowerBoost, 0.05],
|
||||
) * coefficient([true, progressiveStat(crewMemberMastery)]);
|
||||
const resistanceCoefficient = coefficient([hasImprovedSuspension, -0.25]);
|
||||
const viewRangeCoefficient =
|
||||
coefficient([
|
||||
hasImprovedOptics,
|
||||
tank.class === 'AT-SPG' ? 0.05 : tank.class === 'heavyTank' ? 0.07 : 0.1,
|
||||
]) * coefficient([true, progressiveStat(commanderMastery)]);
|
||||
const fireChanceCoefficient = coefficient([hasProtectiveKit, -0.2]);
|
||||
|
||||
const speedForwardsSum = sum(
|
||||
[hasImprovedEnginePowerBoost, 5],
|
||||
[hasGearOil, 2],
|
||||
[hasImprovedGearOil, 4],
|
||||
);
|
||||
const speedBackwardsSum = sum(
|
||||
[hasImprovedEnginePowerBoost, 10],
|
||||
[hasGearOil, 2],
|
||||
[hasImprovedGearOil, 4],
|
||||
);
|
||||
const camouflageSumMoving = sum(
|
||||
[
|
||||
hasCamouflageNet,
|
||||
tank.class === 'heavyTank' ? 0.03 : tank.class === 'AT-SPG' ? 0.07 : 0.05,
|
||||
],
|
||||
[
|
||||
camouflage,
|
||||
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
|
||||
],
|
||||
);
|
||||
const camouflageSumStill = sum(
|
||||
[
|
||||
hasCamouflageNet,
|
||||
2 *
|
||||
(tank.class === 'heavyTank'
|
||||
? 0.03
|
||||
: tank.class === 'AT-SPG'
|
||||
? 0.07
|
||||
: 0.05),
|
||||
],
|
||||
[
|
||||
camouflage,
|
||||
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
|
||||
],
|
||||
);
|
||||
|
||||
const resolvedEnginePower = engine.power * enginePowerCoefficient;
|
||||
const dpm = resolveDpm(
|
||||
gun,
|
||||
shell,
|
||||
damageCoefficient,
|
||||
reloadCoefficient,
|
||||
interClipCoefficient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (penetrationDistanceInput.current) {
|
||||
penetrationDistanceInput.current.value = `${penetrationDistance}`;
|
||||
}
|
||||
}, [penetrationDistance]);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="8" style={{ width: '100%' }}>
|
||||
<Flex direction="column" gap="2">
|
||||
<Flex align="center" gap="4">
|
||||
<Heading size="5">Fire</Heading>
|
||||
|
||||
<Flex>
|
||||
{gun.shells.map((thisShell, index) => {
|
||||
return (
|
||||
<ShellButton
|
||||
key={thisShell.id}
|
||||
shell={thisShell.icon}
|
||||
discriminator={SHELL_NAMES[thisShell.type]}
|
||||
selected={thisShell.id === shell.id}
|
||||
first={index === 0}
|
||||
last={index === gun.shells.length - 1}
|
||||
rowChild
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.shell = thisShell;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Info name="Gun type">{GUN_TYPE_NAMES[gun.type]}</Info>
|
||||
<InfoWithDelta name="DPM" decimals={0} unit="hp / min">
|
||||
{dpm}
|
||||
</InfoWithDelta>
|
||||
{gun.type === 'autoReloader' && (
|
||||
<>
|
||||
<InfoWithDelta decimals={0} indent name="Maximum" unit="hp / min">
|
||||
{gun.reload.at(-1)! < gun.reload.at(-2)!
|
||||
? (shell.damage.armor / (gun.reload.at(-1)! + gun.interClip)) *
|
||||
60
|
||||
: (shell.damage.armor / gun.reload[0]) * 60}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={0}
|
||||
indent
|
||||
name="Effective at 60s"
|
||||
unit="hp / min"
|
||||
>
|
||||
{gun.reload.at(-1)! < gun.reload.at(-2)!
|
||||
? (shell.damage.armor / (gun.reload.at(-1)! + gun.interClip)) *
|
||||
(60 -
|
||||
(gun.reload.slice(0, -1).length - 1) * gun.interClip) +
|
||||
shell.damage.armor * gun.reload.slice(0, -1).length
|
||||
: (shell.damage.armor / (gun.reload[0] + gun.interClip)) *
|
||||
(60 - (gun.reload.slice(1).length - 1) * gun.interClip) +
|
||||
shell.damage.armor * gun.reload.slice(1).length}
|
||||
</InfoWithDelta>
|
||||
<Info
|
||||
indent
|
||||
name={
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://tresabhi.github.io/blitzkrieg/guide/dpm.html"
|
||||
>
|
||||
What's the difference?
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<Info indent name="Optimal shell index">
|
||||
{gun.reload.at(-1)! < gun.reload.at(-2)! ? gun.reload.length : 1}
|
||||
</Info>
|
||||
</>
|
||||
)}
|
||||
{gun.type === 'autoReloader' ? (
|
||||
<>
|
||||
<Info name="Reload" unit="s" />
|
||||
{gun.reload.map((reload, index) => (
|
||||
<InfoWithDelta
|
||||
key={index}
|
||||
indent
|
||||
name={`Shell ${index + 1}`}
|
||||
decimals={2}
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{reload}
|
||||
</InfoWithDelta>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<InfoWithDelta
|
||||
decimals={2}
|
||||
name="Reload"
|
||||
unit="s"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.reload * reloadCoefficient}
|
||||
</InfoWithDelta>
|
||||
)}
|
||||
{gun.type === 'autoLoader' && (
|
||||
<InfoWithDelta
|
||||
indent
|
||||
decimals={2}
|
||||
name="Inter-clip reload"
|
||||
unit="s"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.interClip * interClipCoefficient}
|
||||
</InfoWithDelta>
|
||||
)}
|
||||
<InfoWithDelta name="Caliber" decimals={0} unit="mm">
|
||||
{shell.caliber}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={0} name="Penetration" unit="mm">
|
||||
{resolveNearPenetration(shell.penetration) * penetrationCoefficient}
|
||||
</InfoWithDelta>
|
||||
{typeof shell.penetration !== 'number' && (
|
||||
<>
|
||||
<InfoWithDelta
|
||||
indent
|
||||
decimals={0}
|
||||
name={`At ${penetrationDistance}m`}
|
||||
>
|
||||
{lerp(
|
||||
shell.penetration[0],
|
||||
shell.penetration[1],
|
||||
(penetrationDistance * penetrationLossOverDistanceCoefficient) /
|
||||
500,
|
||||
) * penetrationCoefficient}
|
||||
</InfoWithDelta>
|
||||
<Flex align="center" gap="2" style={{ paddingLeft: 24 }}>
|
||||
<Text>Distance</Text>
|
||||
<Slider
|
||||
key={penetrationDistance}
|
||||
min={0}
|
||||
max={500}
|
||||
style={{ flex: 1 }}
|
||||
defaultValue={[penetrationDistance]}
|
||||
onValueChange={debounce(([value]) => {
|
||||
setPenetrationDistance(value);
|
||||
}, 500)}
|
||||
/>
|
||||
<TextField.Root style={{ width: 64 }}>
|
||||
<TextField.Input
|
||||
ref={penetrationDistanceInput}
|
||||
defaultValue={penetrationDistance}
|
||||
onBlur={() => {
|
||||
setPenetrationDistance(
|
||||
Math.min(
|
||||
parseInt(penetrationDistanceInput.current!.value),
|
||||
500,
|
||||
),
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField.Slot>m</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
<InfoWithDelta name="Damage" unit="hp" decimals={0}>
|
||||
{shell.damage.armor * damageCoefficient}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta name="Module damage" unit="hp" decimals={0}>
|
||||
{shell.damage.module * damageCoefficient}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}>
|
||||
{shell.speed * shellVelocityCoefficient}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={2}
|
||||
name="Aim time"
|
||||
unit="s"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.aimTime * aimTimeCoefficient}
|
||||
</InfoWithDelta>
|
||||
<Info name="Dispersion at 100m" />
|
||||
<InfoWithDelta
|
||||
decimals={3}
|
||||
indent
|
||||
name="Still"
|
||||
unit="m"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.dispersion.base * dispersionStillCoefficient}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
prefix="+ "
|
||||
decimals={3}
|
||||
indent
|
||||
name="Moving"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{track.dispersion.move * dispersionMovingCoefficient}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={3}
|
||||
prefix="+ "
|
||||
indent
|
||||
name="Hull traversing"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{track.dispersion.traverse * dispersionMovingCoefficient}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={3}
|
||||
prefix="+ "
|
||||
indent
|
||||
name="Turret traversing"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.dispersion.traverse * dispersionMovingCoefficient}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={3}
|
||||
prefix="+ "
|
||||
indent
|
||||
name="After shooting"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.dispersion.shot * dispersionMovingCoefficient}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={1}
|
||||
prefix="x "
|
||||
indent
|
||||
name="Gun damaged"
|
||||
unit="scalar"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.dispersion.damaged * dispersionMovingCoefficient}
|
||||
</InfoWithDelta>
|
||||
<Info name="Gun flexibility" unit="°" />
|
||||
<InfoWithDelta decimals={1} indent name="Depression">
|
||||
{gunModelDefinition.pitch.max +
|
||||
(hasImprovedVerticalStabilizer ? 3 : 0)}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="Elevation">
|
||||
{-gunModelDefinition.pitch.min +
|
||||
(hasImprovedVerticalStabilizer ? 3 : 0)}
|
||||
</InfoWithDelta>
|
||||
{gunModelDefinition.pitch.front && (
|
||||
<>
|
||||
<InfoWithDelta decimals={1} indent name="Frontal depression">
|
||||
{gunModelDefinition.pitch.front.max}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="Frontal elevation">
|
||||
{-gunModelDefinition.pitch.front.min}
|
||||
</InfoWithDelta>
|
||||
</>
|
||||
)}
|
||||
{gunModelDefinition.pitch.back && (
|
||||
<>
|
||||
<InfoWithDelta decimals={1} indent name="Rear depression">
|
||||
{gunModelDefinition.pitch.back.max}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="Rear elevation">
|
||||
{-gunModelDefinition.pitch.back.min}
|
||||
</InfoWithDelta>
|
||||
</>
|
||||
)}
|
||||
{turretModelDefinition.yaw && (
|
||||
<>
|
||||
<InfoWithDelta decimals={1} indent name="Azimuth left">
|
||||
{-turretModelDefinition.yaw.min}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="Azimuth right">
|
||||
{turretModelDefinition.yaw.max}
|
||||
</InfoWithDelta>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" gap="2">
|
||||
<Heading size="5">Maneuverability</Heading>
|
||||
<Info name="Speed" unit="km/hr" />
|
||||
<InfoWithDelta decimals={0} indent name="Forwards">
|
||||
{tank.speed.forwards + speedForwardsSum}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={0} indent name="Backwards">
|
||||
{tank.speed.backwards + speedBackwardsSum}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={0} name="Power" unit="hp">
|
||||
{resolvedEnginePower}
|
||||
</InfoWithDelta>
|
||||
<Info name="Power to weight ratio" unit="hp/tn" />
|
||||
<InfoWithDelta decimals={1} indent name="On hard terrain">
|
||||
{resolvedEnginePower /
|
||||
weightTons /
|
||||
(track.resistance.hard * resistanceCoefficient)}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="On medium terrain">
|
||||
{resolvedEnginePower /
|
||||
weightTons /
|
||||
(track.resistance.medium * resistanceCoefficient)}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="On soft terrain">
|
||||
{resolvedEnginePower /
|
||||
weightTons /
|
||||
(track.resistance.soft * resistanceCoefficient)}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={1}
|
||||
name="Weight"
|
||||
unit="tn"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{weightTons}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta name="Turret traverse speed" unit="°/s" decimals={0}>
|
||||
{turret.traverseSpeed * turretTraverseCoefficient}
|
||||
</InfoWithDelta>
|
||||
<Info name="Effective traverse speed" unit="°/s" />
|
||||
<InfoWithDelta decimals={1} indent name="On hard terrain">
|
||||
{(resolvedEnginePower / stockEngine.power) *
|
||||
track.traverseSpeed *
|
||||
hullTraverseCoefficient *
|
||||
(track.resistance.hard /
|
||||
(track.resistance.hard * resistanceCoefficient)) *
|
||||
(stockWeight / weight)}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="On medium terrain">
|
||||
{(resolvedEnginePower / stockEngine.power) *
|
||||
track.traverseSpeed *
|
||||
hullTraverseCoefficient *
|
||||
(track.resistance.hard /
|
||||
(track.resistance.medium * resistanceCoefficient)) *
|
||||
(stockWeight / weight)}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="On soft terrain">
|
||||
{(resolvedEnginePower / stockEngine.power) *
|
||||
track.traverseSpeed *
|
||||
hullTraverseCoefficient *
|
||||
(track.resistance.hard /
|
||||
(track.resistance.soft * resistanceCoefficient)) *
|
||||
(stockWeight / weight)}
|
||||
</InfoWithDelta>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" gap="2">
|
||||
<Heading size="5">Survivability</Heading>
|
||||
<InfoWithDelta name="Health" unit="hp" decimals={0}>
|
||||
{(tank.health + turret.health) * healthCoefficient}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta name="Fire chance" unit="%" deltaType="lowerIsBetter">
|
||||
{Math.round(engine.fireChance * fireChanceCoefficient * 100)}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta name="View range" unit="m" decimals={0}>
|
||||
{turret.viewRange * viewRangeCoefficient}
|
||||
</InfoWithDelta>
|
||||
<Info name="Camouflage" unit="%" />
|
||||
<InfoWithDelta indent name="Still" decimals={2}>
|
||||
{(tank.camouflage.still + camouflageSumStill) * 100}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta indent name="Moving" decimals={2}>
|
||||
{(tank.camouflage.moving + camouflageSumMoving) * 100}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta indent name="Shooting still" decimals={2}>
|
||||
{(tank.camouflage.still * gun.camouflageLoss + camouflageSumStill) *
|
||||
100}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta indent name="Shooting on move" decimals={2}>
|
||||
{(tank.camouflage.moving * gun.camouflageLoss + camouflageSumMoving) *
|
||||
100}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta indent name="Caught on fire" decimals={2}>
|
||||
{(tank.camouflage.onFire * tank.camouflage.still +
|
||||
camouflageSumStill) *
|
||||
100}
|
||||
</InfoWithDelta>
|
||||
<Info name="Size" unit="m">
|
||||
{size[0].toFixed(2)} x {size[2].toFixed(2)} x {size[1].toFixed(2)}
|
||||
</Info>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" gap="2">
|
||||
<Heading size="5">Crew training</Heading>
|
||||
|
||||
{tank.crew.map((member) => {
|
||||
const count = member.count ?? 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<InfoWithDelta
|
||||
key={`${member.type}-root`}
|
||||
name={`${CREW_MEMBER_NAMES[member.type]}${count > 1 ? ` x ${count}` : ''}`}
|
||||
unit="%"
|
||||
decimals={0}
|
||||
>
|
||||
{(member.type === 'commander'
|
||||
? commanderMastery
|
||||
: crewMemberMastery) * 100}
|
||||
</InfoWithDelta>
|
||||
{member.substitute && (
|
||||
<Info
|
||||
key={`${member.type}-substitute`}
|
||||
indent
|
||||
name={
|
||||
<>
|
||||
<Flex align="center" gap="1">
|
||||
<AccessibilityIcon />
|
||||
{member.substitute
|
||||
.map((sub, index) =>
|
||||
index === 0 ? CREW_MEMBER_NAMES[sub] : sub,
|
||||
)
|
||||
.join(', ')}
|
||||
</Flex>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import { useThree } from '@react-three/fiber';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Vector3 } from 'three';
|
||||
import { OrbitControls as OrbitControlsClass } from 'three-stdlib';
|
||||
import { applyPitchYawLimits } from '../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { modelDefinitions } from '../../../../../core/blitzkrieg/modelDefinitions';
|
||||
import { Pose, poseEvent } from '../../../../../core/blitzkrieg/pose';
|
||||
import { useAwait } from '../../../../../hooks/useAwait';
|
||||
import { useDuel } from '../../../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../../../stores/tankopedia';
|
||||
|
||||
const poseDistances: Record<Pose, number> = {
|
||||
[Pose.HullDown]: 15,
|
||||
[Pose.FaceHug]: 5,
|
||||
[Pose.Default]: -1,
|
||||
};
|
||||
|
||||
export function Controls() {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const orbitControls = useRef<OrbitControlsClass>(null);
|
||||
const awaitedModelDefinitions = useAwait(modelDefinitions);
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const antagonist = useDuel((state) => state.antagonist!);
|
||||
const protagonistModelDefinition =
|
||||
awaitedModelDefinitions[protagonist.tank.id];
|
||||
const protagonistTrackModelDefinition =
|
||||
awaitedModelDefinitions[protagonist.tank.id].tracks[protagonist.track.id];
|
||||
const antagonistModelDefinition = awaitedModelDefinitions[antagonist.tank.id];
|
||||
const protagonistTurretModelDefinition =
|
||||
protagonistModelDefinition.turrets[protagonist.turret.id];
|
||||
const antagonistTurretModelDefinition =
|
||||
antagonistModelDefinition.turrets[antagonist.turret.id];
|
||||
const protagonistGunModelDefinition =
|
||||
protagonistTurretModelDefinition.guns[protagonist.gun.id];
|
||||
const protagonistHullOrigin = new Vector3(
|
||||
protagonistTrackModelDefinition.origin[0],
|
||||
protagonistTrackModelDefinition.origin[1],
|
||||
-protagonistTrackModelDefinition.origin[2],
|
||||
);
|
||||
const protagonistTurretOrigin = new Vector3(
|
||||
protagonistModelDefinition.turretOrigin[0],
|
||||
protagonistModelDefinition.turretOrigin[1],
|
||||
-protagonistModelDefinition.turretOrigin[2],
|
||||
);
|
||||
const protagonistGunOrigin = new Vector3(
|
||||
protagonistTurretModelDefinition.gunOrigin[0],
|
||||
protagonistTurretModelDefinition.gunOrigin[1],
|
||||
-protagonistTurretModelDefinition.gunOrigin[2],
|
||||
);
|
||||
const antagonistGunHeight =
|
||||
protagonistTrackModelDefinition.origin[1] +
|
||||
antagonistModelDefinition.turretOrigin[1] +
|
||||
antagonistTurretModelDefinition.gunOrigin[1];
|
||||
// const hasSetInitialPose = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
camera.position.set(-4, 4, -16);
|
||||
orbitControls.current?.target.set(0, 1.25, 0);
|
||||
}, [camera]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeTankopedia = useTankopediaPersistent.subscribe(
|
||||
(state) => state.model.visual.controlsEnabled,
|
||||
(enabled) => {
|
||||
if (orbitControls.current) orbitControls.current.enabled = enabled;
|
||||
},
|
||||
);
|
||||
|
||||
function handlePoseEvent(event: Pose) {
|
||||
switch (event) {
|
||||
case Pose.HullDown: {
|
||||
const [pitch] = applyPitchYawLimits(
|
||||
-Infinity,
|
||||
0,
|
||||
protagonistGunModelDefinition.pitch,
|
||||
protagonistTurretModelDefinition.yaw,
|
||||
);
|
||||
|
||||
camera.position
|
||||
.set(0, 0, 0)
|
||||
.add(protagonistHullOrigin)
|
||||
.add(protagonistTurretOrigin)
|
||||
.add(protagonistGunOrigin)
|
||||
.add(
|
||||
new Vector3(
|
||||
0,
|
||||
poseDistances[event] * Math.sin(pitch),
|
||||
poseDistances[event] * -Math.cos(pitch),
|
||||
),
|
||||
);
|
||||
camera.lookAt(
|
||||
protagonistHullOrigin
|
||||
.clone()
|
||||
.add(protagonistTurretOrigin)
|
||||
.add(protagonistGunOrigin),
|
||||
);
|
||||
orbitControls.current?.target.set(0, antagonistGunHeight, 0);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Pose.FaceHug: {
|
||||
const [pitch] = applyPitchYawLimits(
|
||||
0,
|
||||
0,
|
||||
protagonistGunModelDefinition.pitch,
|
||||
protagonistTurretModelDefinition.yaw,
|
||||
);
|
||||
|
||||
camera.position
|
||||
.set(0, 0, 0)
|
||||
.add(protagonistHullOrigin)
|
||||
.add(protagonistTurretOrigin)
|
||||
.add(protagonistGunOrigin)
|
||||
.add(
|
||||
new Vector3(
|
||||
0,
|
||||
poseDistances[event] * Math.sin(pitch),
|
||||
poseDistances[event] * -Math.cos(pitch),
|
||||
),
|
||||
);
|
||||
orbitControls.current?.target
|
||||
.set(0, 0, 0)
|
||||
.add(protagonistHullOrigin)
|
||||
.add(protagonistTurretOrigin)
|
||||
.add(protagonistGunOrigin)
|
||||
.add(
|
||||
new Vector3(
|
||||
0,
|
||||
0.5 * poseDistances[event] * Math.sin(pitch),
|
||||
0.5 * poseDistances[event] * -Math.cos(pitch),
|
||||
),
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Pose.Default: {
|
||||
camera.position.set(-4, 4, -16);
|
||||
orbitControls.current?.target.set(0, 1.25, 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
poseEvent.on(handlePoseEvent);
|
||||
|
||||
return () => {
|
||||
unsubscribeTankopedia();
|
||||
poseEvent.off(handlePoseEvent);
|
||||
};
|
||||
}, [camera, protagonist.tank.id, antagonist.tank.id]);
|
||||
|
||||
return (
|
||||
<OrbitControls
|
||||
maxDistance={20}
|
||||
minDistance={5}
|
||||
ref={orbitControls}
|
||||
enabled={useTankopediaPersistent.getState().model.visual.controlsEnabled}
|
||||
rotateSpeed={0.25}
|
||||
enableDamping={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Environment } from '@react-three/drei';
|
||||
import { useModel } from '../../../../../hooks/useModel';
|
||||
import { useDuel } from '../../../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../../../stores/tankopedia';
|
||||
|
||||
export const ENVIRONMENTS = [
|
||||
'lobby',
|
||||
'apartment',
|
||||
'warehouse',
|
||||
'dawn',
|
||||
'sunset',
|
||||
] as const;
|
||||
|
||||
export function Lighting() {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const { hasPbr } = useModel(protagonist.tank.id);
|
||||
const showEnvironment = useTankopediaPersistent(
|
||||
(state) => state.model.visual.showEnvironment,
|
||||
);
|
||||
const environment = useTankopediaPersistent(
|
||||
(state) => state.model.visual.environment,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Environment preset={environment} background={showEnvironment} blur={0} />
|
||||
<directionalLight
|
||||
position={[1, 1, -1]}
|
||||
intensity={hasPbr ? 1 : 5}
|
||||
castShadow
|
||||
color={'rgb(225, 225, 255)'}
|
||||
/>
|
||||
<directionalLight
|
||||
position={[-1, 1, 1]}
|
||||
intensity={hasPbr ? 1 : 4}
|
||||
castShadow
|
||||
color={'rgb(240, 255, 240)'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { LightningBoltIcon } from '@radix-ui/react-icons';
|
||||
import { Button, Card, Flex, Tabs, Theme } from '@radix-ui/themes';
|
||||
import { PerspectiveCamera } from '@react-three/drei';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { Suspense, use, useEffect, useRef, useState } from 'react';
|
||||
import { isMobile } from 'react-device-detect';
|
||||
import { Armor } from '../../../../../../components/Armor';
|
||||
import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay';
|
||||
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
|
||||
import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTransform';
|
||||
import { Pose, poseEvent } from '../../../../../../core/blitzkrieg/pose';
|
||||
import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon';
|
||||
import { useWideFormat } from '../../../../../../hooks/useWideFormat';
|
||||
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
|
||||
import mutateTankopediaPersistent, {
|
||||
TankopediaMode,
|
||||
mutateTankopediaTemporary,
|
||||
useTankopediaPersistent,
|
||||
} from '../../../../../../stores/tankopedia';
|
||||
import { AntagonistBar } from '../AntagonistBar';
|
||||
import { Controls } from '../Control';
|
||||
import { Lighting } from '../Lighting';
|
||||
import { RotationInputs } from '../QuickInputs';
|
||||
import { SceneProps } from '../SceneProps';
|
||||
import { ModelLoader } from './components/ModelLoader';
|
||||
import { Options } from './components/Options';
|
||||
import { TankModel } from './components/TankModel';
|
||||
|
||||
export function TankSandbox() {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const awaitedModelDefinitions = use(modelDefinitions);
|
||||
const canvasWrapper = useRef<HTMLDivElement>(null);
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
const mode = useTankopediaPersistent((state) => state.mode);
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
const wideFormat = useWideFormat();
|
||||
const [loadModel, setLoadModel] = useState(wideFormat || !isMobile);
|
||||
const duel = useDuel();
|
||||
|
||||
function handlePointerDown() {
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleFullScreenChange() {
|
||||
setIsFullScreen(document.fullscreenElement !== null);
|
||||
}
|
||||
|
||||
function handlePoseEvent(pose: Pose) {
|
||||
switch (pose) {
|
||||
case Pose.HullDown: {
|
||||
mutateDuel((draft) => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
-Infinity,
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
draft.protagonist!.pitch = pitch;
|
||||
draft.protagonist!.yaw = yaw;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Pose.FaceHug: {
|
||||
mutateDuel((draft) => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
Infinity,
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
draft.protagonist!.pitch = pitch;
|
||||
draft.protagonist!.yaw = yaw;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Pose.Default:
|
||||
mutateDuel((draft) => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
0,
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
draft.protagonist!.pitch = pitch;
|
||||
draft.protagonist!.yaw = yaw;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
poseEvent.on(handlePoseEvent);
|
||||
document.addEventListener('fullscreenchange', handleFullScreenChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullScreenChange);
|
||||
poseEvent.off(handlePoseEvent);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
mutateDuel((draft) => {
|
||||
[draft.protagonist!.pitch, draft.protagonist!.yaw] = applyPitchYawLimits(
|
||||
draft.protagonist!.pitch,
|
||||
draft.protagonist!.yaw,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
);
|
||||
});
|
||||
}, [protagonist.gun, protagonist.turret]);
|
||||
|
||||
return (
|
||||
<Theme radius={isFullScreen ? 'none' : undefined}>
|
||||
<Card
|
||||
style={{
|
||||
position: 'relative',
|
||||
border: isFullScreen ? 'none' : 'unset',
|
||||
}}
|
||||
ref={canvasWrapper}
|
||||
>
|
||||
<Theme radius="full" style={{ height: '100%' }}>
|
||||
<Flex
|
||||
style={{
|
||||
height: isFullScreen ? '100%' : '75vh',
|
||||
maxHeight: isFullScreen ? 'unset' : 576,
|
||||
}}
|
||||
direction="column"
|
||||
gap="2"
|
||||
>
|
||||
<Tabs.Root
|
||||
value={mode}
|
||||
onValueChange={(mode) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.mode = mode as TankopediaMode;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="model">
|
||||
<Flex gap="2" align="center">
|
||||
Model
|
||||
</Flex>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="armor">Armor</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Options
|
||||
canvasWrapper={canvasWrapper}
|
||||
isFullScreen={isFullScreen}
|
||||
/>
|
||||
|
||||
{isFullScreen && <AntagonistBar floating />}
|
||||
|
||||
{loadModel && <RotationInputs isFullScreen={isFullScreen} />}
|
||||
</Flex>
|
||||
</Theme>
|
||||
</Card>
|
||||
</Theme>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Html, useProgress } from '@react-three/drei';
|
||||
import { theme } from '../../../../../../../stitches.config';
|
||||
import { Loader } from '../../../../../components/Loader';
|
||||
|
||||
export function ModelLoader() {
|
||||
const { progress } = useProgress();
|
||||
|
||||
return (
|
||||
<Html center position={[0, 1.5, 0]}>
|
||||
<Loader
|
||||
naked
|
||||
color={theme.colors.textHighContrast_purple}
|
||||
progress={progress / 100}
|
||||
/>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
EnterFullScreenIcon,
|
||||
ExitFullScreenIcon,
|
||||
EyeOpenIcon,
|
||||
GearIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import { Button, DropdownMenu, Flex } from '@radix-ui/themes';
|
||||
import { RefObject } from 'react';
|
||||
import { Pose, poseEvent } from '../../../../../../../core/blitzkrieg/pose';
|
||||
import mutateTankopediaPersistent, {
|
||||
useTankopediaPersistent,
|
||||
} from '../../../../../../../stores/tankopedia';
|
||||
import { ENVIRONMENTS } from '../../Lighting';
|
||||
|
||||
interface OptionsProps {
|
||||
isFullScreen: boolean;
|
||||
canvasWrapper: RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
||||
const showGrid = useTankopediaPersistent(
|
||||
(state) => state.model.visual.showGrid,
|
||||
);
|
||||
const showEnvironment = useTankopediaPersistent(
|
||||
(state) => state.model.visual.showEnvironment,
|
||||
);
|
||||
const greenPenetration = useTankopediaPersistent(
|
||||
(state) => state.model.visual.greenPenetration,
|
||||
);
|
||||
const wireframe = useTankopediaPersistent(
|
||||
(state) => state.model.visual.wireframe,
|
||||
);
|
||||
const opaque = useTankopediaPersistent((state) => state.model.visual.opaque);
|
||||
const fullScreenAvailable =
|
||||
typeof document !== 'undefined' && document.fullscreenEnabled;
|
||||
const environment = useTankopediaPersistent(
|
||||
(state) => state.model.visual.environment,
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap="4"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 18,
|
||||
right: 18,
|
||||
}}
|
||||
>
|
||||
{!isFullScreen && (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost">
|
||||
<EyeOpenIcon />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
|
||||
Hull down
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
|
||||
Face hug
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
|
||||
Default
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
|
||||
{!isFullScreen && (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost">
|
||||
<GearIcon />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Label>Armor</DropdownMenu.Label>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={greenPenetration}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.greenPenetration = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Green penetration
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={wireframe}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.wireframe = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Wireframe
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={opaque}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.opaque = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Opaque
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.Label>Environment</DropdownMenu.Label>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={showGrid}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.showGrid = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Show grid
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={showEnvironment}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.showEnvironment = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
View environment
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent>
|
||||
<DropdownMenu.RadioGroup value={environment}>
|
||||
{ENVIRONMENTS.map((environment) => (
|
||||
<DropdownMenu.RadioItem
|
||||
key={environment}
|
||||
value={environment}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.environment = environment;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{environment[0].toUpperCase() + environment.slice(1)}
|
||||
</DropdownMenu.RadioItem>
|
||||
))}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
|
||||
{fullScreenAvailable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (isFullScreen) {
|
||||
document.exitFullscreen();
|
||||
} else canvasWrapper.current?.requestFullscreen();
|
||||
}}
|
||||
>
|
||||
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,254 @@
|
||||
import { ThreeEvent, useThree } from '@react-three/fiber';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { Euler, Group, Vector3 } from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../constants/axis';
|
||||
import { applyPitchYawLimits } from '../../../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { jsxTree } from '../../../../../../../core/blitzkrieg/jsxTree';
|
||||
import { modelDefinitions } from '../../../../../../../core/blitzkrieg/modelDefinitions';
|
||||
import {
|
||||
ModelTransformEventData,
|
||||
modelTransformEvent,
|
||||
} from '../../../../../../../core/blitzkrieg/modelTransform';
|
||||
import { normalizeAngleRad } from '../../../../../../../core/math/normalizeAngleRad';
|
||||
import { useAwait } from '../../../../../../../hooks/useAwait';
|
||||
import { useModel } from '../../../../../../../hooks/useModel';
|
||||
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
||||
import mutateTankopediaPersistent, {
|
||||
mutateTankopediaTemporary,
|
||||
} from '../../../../../../../stores/tankopedia';
|
||||
|
||||
export const TankModel = memo(() => {
|
||||
const awaitedModelDefinitions = useAwait(modelDefinitions);
|
||||
const protagonist = useDuel((draft) => draft.protagonist!);
|
||||
const canvas = useThree((state) => state.gl.domElement);
|
||||
const hullContainer = useRef<Group>(null);
|
||||
const turretContainer = useRef<Group>(null);
|
||||
const gunContainer = useRef<Group>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretPosition = new Vector3();
|
||||
const turretRotation = new Euler();
|
||||
const gunPosition = new Vector3();
|
||||
const gunRotation = new Euler();
|
||||
|
||||
function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
|
||||
gunPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
.sub(turretOrigin)
|
||||
.sub(gunOrigin)
|
||||
.applyAxisAngle(I_HAT, pitch)
|
||||
.add(gunOrigin)
|
||||
.add(turretOrigin)
|
||||
.add(hullOrigin);
|
||||
gunRotation.set(pitch, 0, 0);
|
||||
gunContainer.current?.position.copy(gunPosition);
|
||||
gunContainer.current?.rotation.copy(gunRotation);
|
||||
|
||||
if (yaw === undefined) return;
|
||||
|
||||
turretPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
.sub(turretOrigin)
|
||||
.applyAxisAngle(new Vector3(0, 0, 1), yaw);
|
||||
turretRotation.set(0, 0, yaw);
|
||||
|
||||
if (tankModelDefinition.turretRotation) {
|
||||
const initialPitch = -degToRad(
|
||||
tankModelDefinition.turretRotation.pitch,
|
||||
);
|
||||
const initialYaw = -degToRad(tankModelDefinition.turretRotation.yaw);
|
||||
const initialRoll = -degToRad(tankModelDefinition.turretRotation.roll);
|
||||
|
||||
turretPosition
|
||||
.applyAxisAngle(I_HAT, initialPitch)
|
||||
.applyAxisAngle(J_HAT, initialRoll)
|
||||
.applyAxisAngle(K_HAT, initialYaw);
|
||||
turretRotation.x += initialPitch;
|
||||
turretRotation.y += initialRoll;
|
||||
turretRotation.z += initialYaw;
|
||||
}
|
||||
|
||||
turretPosition.add(turretOrigin).add(hullOrigin);
|
||||
turretContainer.current?.position.copy(turretPosition);
|
||||
turretContainer.current?.rotation.copy(turretRotation);
|
||||
}
|
||||
|
||||
handleModelTransform(protagonist);
|
||||
modelTransformEvent.on(handleModelTransform);
|
||||
|
||||
return () => {
|
||||
modelTransformEvent.off(handleModelTransform);
|
||||
};
|
||||
});
|
||||
|
||||
if (!awaitedModelDefinitions) return;
|
||||
|
||||
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
|
||||
const trackModelDefinition = tankModelDefinition.tracks[protagonist.track.id];
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
|
||||
const { gltf } = useModel(protagonist.tank.id);
|
||||
const nodes = Object.values(gltf.nodes);
|
||||
|
||||
return (
|
||||
<group ref={hullContainer} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{nodes.map((node) => {
|
||||
const isHull = node.name === 'hull';
|
||||
const isWheel = node.name.startsWith('chassis_wheel_');
|
||||
const isTrack = node.name.startsWith('chassis_track_');
|
||||
const isVisible = isHull || isWheel || isTrack;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
function onPointerDown(event: ThreeEvent<PointerEvent>) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
return jsxTree(node, {
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
onPointerDown,
|
||||
});
|
||||
})}
|
||||
|
||||
<group ref={turretContainer}>
|
||||
{nodes.map((node) => {
|
||||
const isTurret = node.name.startsWith('turret_');
|
||||
const isCurrentTurret =
|
||||
node.name ===
|
||||
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`;
|
||||
const isVisible = isCurrentTurret;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
let pitch = 0;
|
||||
let yaw = 0;
|
||||
|
||||
function onPointerDown(event: ThreeEvent<PointerEvent>) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (isTurret) {
|
||||
yaw = protagonist.yaw;
|
||||
pitch = protagonist.pitch;
|
||||
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.controlsEnabled = false;
|
||||
});
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
[pitch, yaw] = applyPitchYawLimits(
|
||||
pitch,
|
||||
yaw + event.movementX * (Math.PI / canvas.width),
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
);
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
}
|
||||
function handlePointerUp() {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.pitch = normalizeAngleRad(pitch);
|
||||
draft.protagonist!.yaw = normalizeAngleRad(yaw);
|
||||
});
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.controlsEnabled = true;
|
||||
});
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
|
||||
return jsxTree(node, {
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
onPointerDown,
|
||||
});
|
||||
})}
|
||||
|
||||
<group ref={gunContainer}>
|
||||
{nodes.map((node) => {
|
||||
const isCurrentMantlet =
|
||||
node.name ===
|
||||
`gun_${gunModelDefinition.model
|
||||
.toString()
|
||||
.padStart(2, '0')}_mask`;
|
||||
const isCurrentGun =
|
||||
node.name ===
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
|
||||
const isVisible = isCurrentGun || isCurrentMantlet;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
let pitch = 0;
|
||||
let yaw = 0;
|
||||
|
||||
function onPointerDown(event: ThreeEvent<PointerEvent>) {
|
||||
event.stopPropagation();
|
||||
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.controlsEnabled = false;
|
||||
});
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
pitch = protagonist.pitch;
|
||||
yaw = protagonist.yaw;
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
[pitch, yaw] = applyPitchYawLimits(
|
||||
pitch - event.movementY * (Math.PI / canvas.height),
|
||||
yaw + event.movementX * (Math.PI / canvas.width),
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
);
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
}
|
||||
function handlePointerUp() {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.controlsEnabled = true;
|
||||
});
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.pitch = normalizeAngleRad(pitch);
|
||||
draft.protagonist!.yaw = normalizeAngleRad(yaw);
|
||||
});
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
|
||||
return jsxTree(node, {
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
onPointerDown,
|
||||
});
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Button } from '@radix-ui/themes';
|
||||
import { ComponentProps } from 'react';
|
||||
import { asset } from '../../../../../../../core/blitzkrieg/asset';
|
||||
|
||||
interface QuickEquipmentButtonProps extends ComponentProps<typeof Button> {
|
||||
equipment: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function QuickEquipmentButton({
|
||||
equipment,
|
||||
active,
|
||||
...props
|
||||
}: QuickEquipmentButtonProps) {
|
||||
return (
|
||||
<Button variant="ghost" {...props}>
|
||||
<img
|
||||
style={{ opacity: active ? 1 : 0.5 }}
|
||||
src={asset(`icons/equipment/${equipment}.webp`)}
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Flex, TextField } from '@radix-ui/themes';
|
||||
import { use, useEffect, useRef } from 'react';
|
||||
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
|
||||
import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTransform';
|
||||
import { useEquipment } from '../../../../../../hooks/useEquipment';
|
||||
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../../../../stores/tankopedia';
|
||||
import { QuickEquipmentButton } from './components/QuickEquipmentButton';
|
||||
|
||||
interface RotationInputsProps {
|
||||
isFullScreen?: boolean;
|
||||
}
|
||||
|
||||
export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const awaitedModelDefinitions = use(modelDefinitions);
|
||||
const turretYawInput = useRef<HTMLInputElement>(null);
|
||||
const gunPitchInput = useRef<HTMLInputElement>(null);
|
||||
const mode = useTankopediaPersistent((state) => state.mode);
|
||||
const hasCalibratedShells = useEquipment(103, true);
|
||||
const hasEnhancedArmor = useEquipment(110);
|
||||
|
||||
useEffect(() => {
|
||||
turretYawInput.current!.value = `${-Math.round(
|
||||
protagonist.yaw * (180 / Math.PI),
|
||||
)}`;
|
||||
}, [protagonist.yaw]);
|
||||
useEffect(() => {
|
||||
gunPitchInput.current!.value = `${-Math.round(
|
||||
protagonist.pitch * (180 / Math.PI),
|
||||
)}`;
|
||||
}, [protagonist.pitch]);
|
||||
|
||||
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
|
||||
return (
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
style={{
|
||||
width: 'calc(100% - 16px)',
|
||||
position: 'absolute',
|
||||
left: 8,
|
||||
bottom: isFullScreen ? 80 : 8,
|
||||
}}
|
||||
gap="4"
|
||||
>
|
||||
<Flex
|
||||
gap="2"
|
||||
style={{
|
||||
width: 256,
|
||||
}}
|
||||
>
|
||||
<TextField.Root
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<TextField.Slot
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
Yaw
|
||||
</TextField.Slot>
|
||||
<TextField.Input
|
||||
defaultValue={Math.round(protagonist.yaw * (180 / Math.PI))}
|
||||
onBlur={() => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
protagonist.pitch,
|
||||
-Number(turretYawInput.current?.value) * (Math.PI / 180),
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
);
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
mutateDuel((state) => {
|
||||
state.protagonist!.pitch = pitch;
|
||||
state.protagonist!.yaw = yaw;
|
||||
});
|
||||
turretYawInput.current!.value = `${Math.round(
|
||||
yaw * (180 / Math.PI),
|
||||
)}`;
|
||||
}}
|
||||
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
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<TextField.Slot
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
Pitch
|
||||
</TextField.Slot>
|
||||
<TextField.Input
|
||||
defaultValue={-Math.round(protagonist.pitch * (180 / Math.PI))}
|
||||
onBlur={() => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
-Number(gunPitchInput.current?.value) * (Math.PI / 180),
|
||||
protagonist.yaw,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
);
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
mutateDuel((state) => {
|
||||
state.protagonist!.pitch = pitch;
|
||||
state.protagonist!.yaw = yaw;
|
||||
});
|
||||
gunPitchInput.current!.value = `${Math.round(
|
||||
pitch * (180 / Math.PI),
|
||||
)}`;
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
gunPitchInput.current?.blur();
|
||||
}
|
||||
}}
|
||||
onFocus={() => gunPitchInput.current?.focus()}
|
||||
ref={gunPitchInput}
|
||||
style={{ textAlign: 'right' }}
|
||||
/>
|
||||
<TextField.Slot
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
°
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Flex>
|
||||
|
||||
{mode === 'armor' && (
|
||||
<Flex gap="4">
|
||||
<QuickEquipmentButton
|
||||
equipment={103}
|
||||
active={hasCalibratedShells}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist!.equipment[0][0] = hasCalibratedShells ? 0 : 1;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<QuickEquipmentButton
|
||||
equipment={110}
|
||||
active={hasEnhancedArmor}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.equipment[1][1] = hasEnhancedArmor ? 0 : -1;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useFrame, useLoader } from '@react-three/fiber';
|
||||
import { clamp } from 'lodash';
|
||||
import { useRef } from 'react';
|
||||
import { MeshStandardMaterial, TextureLoader } from 'three';
|
||||
import { useTankopediaPersistent } from '../../../../../stores/tankopedia';
|
||||
|
||||
export function SceneProps() {
|
||||
const show = useTankopediaPersistent(
|
||||
(state) =>
|
||||
state.model.visual.showGrid && !state.model.visual.showEnvironment,
|
||||
);
|
||||
const texture = useLoader(TextureLoader, 'https://i.imgur.com/C28Z8nU.png');
|
||||
const material = useRef<MeshStandardMaterial>(null);
|
||||
|
||||
useFrame(({ camera }) => {
|
||||
if (!material.current) return;
|
||||
material.current.opacity = clamp(0.1 * camera.position.y, 0, 1);
|
||||
});
|
||||
|
||||
texture.anisotropy = 2;
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[10, 10]} />
|
||||
<meshStandardMaterial ref={material} map={texture} transparent />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { UpdateIcon } from '@radix-ui/react-icons';
|
||||
import { Button, Dialog, Flex, Heading } from '@radix-ui/themes';
|
||||
import { useState } from 'react';
|
||||
import { TREE_TYPE_ICONS } from '../../../../../components/Tanks';
|
||||
import { assignDuelMember } from '../../../../../core/blitzkrieg/assignDuelMember';
|
||||
import { pushTankopediaPath } from '../../../../../core/blitzkrieg/pushTankopediaPath';
|
||||
import { useDuel } from '../../../../../stores/duel';
|
||||
import { TankSearch } from '../../components/TankSearch';
|
||||
|
||||
export function Title() {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const [changeTankDialogOpen, setChangeTankDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{protagonist.tank.name}</title>
|
||||
|
||||
<Flex gap="6" justify="center" align="center" style={{ padding: 16 }}>
|
||||
<Flex gap="2" align="center">
|
||||
<Heading
|
||||
color={
|
||||
protagonist.tank.treeType === 'premium'
|
||||
? 'amber'
|
||||
: protagonist.tank.treeType === 'collector'
|
||||
? 'blue'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Flex align="center" gap="2">
|
||||
<img
|
||||
src={
|
||||
TREE_TYPE_ICONS[protagonist.tank.treeType][
|
||||
protagonist.tank.class
|
||||
]
|
||||
}
|
||||
style={{
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
}}
|
||||
/>{' '}
|
||||
{protagonist.tank.name}
|
||||
</Flex>
|
||||
</Heading>
|
||||
</Flex>
|
||||
|
||||
<Dialog.Root
|
||||
open={changeTankDialogOpen}
|
||||
onOpenChange={setChangeTankDialogOpen}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="ghost">
|
||||
Change <UpdateIcon />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content>
|
||||
<Flex gap="4" direction="column">
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="4"
|
||||
style={{ flex: 1 }}
|
||||
justify="center"
|
||||
>
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
assignDuelMember('protagonist', tank.id);
|
||||
setChangeTankDialogOpen(false);
|
||||
pushTankopediaPath(tank.id);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { Flex } from '@radix-ui/themes';
|
||||
import { useEffect } from 'react';
|
||||
import PageWrapper from '../../../../components/PageWrapper';
|
||||
import { assignDuelMember } from '../../../../core/blitzkrieg/assignDuelMember';
|
||||
import { useWideFormat } from '../../../../hooks/useWideFormat';
|
||||
import { mutateDuel, useDuel } from '../../../../stores/duel';
|
||||
import { mutateTankopediaTemporary } from '../../../../stores/tankopedia';
|
||||
import { AntagonistBar } from './components/AntagonistBar';
|
||||
import { Characteristics } from './components/Characteristics';
|
||||
import { Consumables } from './components/Characteristics/components/Consumables';
|
||||
import { Equipments } from './components/Characteristics/components/Equipments';
|
||||
import { Miscellaneous } from './components/Characteristics/components/Miscellaneous';
|
||||
import { Modules } from './components/Characteristics/components/Modules';
|
||||
import { Provisions } from './components/Characteristics/components/Provisions';
|
||||
import { TankSandbox } from './components/Model/TankSandbox';
|
||||
import { Title } from './components/Title';
|
||||
|
||||
export default function Page({ params }: { params: { id: string } }) {
|
||||
const initialId = parseInt(params.id);
|
||||
const assigned = useDuel((state) => state.assigned);
|
||||
const wideFormat = useWideFormat();
|
||||
|
||||
useEffect(() => {
|
||||
assignDuelMember('both', initialId);
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (document.activeElement instanceof HTMLInputElement) return;
|
||||
|
||||
function wipeShot() {
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
if (event.key === '1') {
|
||||
wipeShot();
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist!.shell = draft.antagonist!.gun.shells[0];
|
||||
});
|
||||
} else if (event.key === '2') {
|
||||
wipeShot();
|
||||
mutateDuel((draft) => {
|
||||
if (draft.antagonist!.gun.shells[1]) {
|
||||
draft.antagonist!.shell = draft.antagonist!.gun.shells[1];
|
||||
}
|
||||
});
|
||||
} else if (event.key === '3') {
|
||||
wipeShot();
|
||||
mutateDuel((draft) => {
|
||||
if (draft.antagonist!.gun.shells[2]) {
|
||||
draft.antagonist!.shell = draft.antagonist!.gun.shells[2];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [initialId, assigned]);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<PageWrapper color="purple" size="double">
|
||||
{assigned && (
|
||||
<>
|
||||
<Title />
|
||||
|
||||
<Flex
|
||||
style={{ width: '100%' }}
|
||||
gap="8"
|
||||
direction={wideFormat ? 'row' : 'column'}
|
||||
align="start"
|
||||
justify="center"
|
||||
>
|
||||
<Flex
|
||||
gap="4"
|
||||
direction="column"
|
||||
style={{
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
top: 64,
|
||||
position: wideFormat ? 'sticky' : undefined,
|
||||
}}
|
||||
>
|
||||
<TankSandbox />
|
||||
<AntagonistBar />
|
||||
|
||||
<Flex direction="column" gap="4">
|
||||
<Modules />
|
||||
<Provisions />
|
||||
<Equipments />
|
||||
<Miscellaneous />
|
||||
<Consumables />
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex style={{ width: wideFormat ? 320 : '100%' }}>
|
||||
<Characteristics />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { CaretLeftIcon, CaretRightIcon } from '@radix-ui/react-icons';
|
||||
import { Button, Flex, TextField } from '@radix-ui/themes';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { TankDefinition } from '../../../../core/blitzkrieg/tankDefinitions';
|
||||
import mutateTankopediaPersistent, {
|
||||
useTankopediaPersistent,
|
||||
} from '../../../../stores/tankopedia';
|
||||
|
||||
interface PageTurnerProps {
|
||||
searchedList: TankDefinition[];
|
||||
tanksPerPage: number;
|
||||
}
|
||||
|
||||
export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
|
||||
const page = useTankopediaPersistent((state) => state.filters.page);
|
||||
const pageInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageInput.current) pageInput.current.value = `${page + 1}`;
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="center" gap="2">
|
||||
<Button
|
||||
variant="soft"
|
||||
disabled={page === 0}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.filters.page = Math.max(0, page - 1);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CaretLeftIcon />
|
||||
</Button>
|
||||
<TextField.Root>
|
||||
<TextField.Slot>Page</TextField.Slot>
|
||||
<TextField.Input
|
||||
defaultValue={1}
|
||||
type="number"
|
||||
ref={pageInput}
|
||||
min={1}
|
||||
max={Math.floor(searchedList.length / tanksPerPage) + 1}
|
||||
style={{ width: 64, textAlign: 'center' }}
|
||||
onBlur={(event) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.filters.page = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Math.floor(searchedList.length / tanksPerPage),
|
||||
event.target.valueAsNumber - 1,
|
||||
),
|
||||
);
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
(event.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField.Slot>
|
||||
out of {Math.floor(searchedList.length / tanksPerPage) + 1}
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
<Button
|
||||
variant="soft"
|
||||
disabled={Math.floor(searchedList.length / tanksPerPage) === page}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.filters.page = Math.min(
|
||||
Math.floor(searchedList.length / tanksPerPage),
|
||||
page + 1,
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CaretRightIcon />
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Button, Flex, Text } from '@radix-ui/themes';
|
||||
import { SmallTankIcon } from '../../../../../../components/SmallTankIcon';
|
||||
import {
|
||||
TIER_ROMAN_NUMERALS,
|
||||
TankDefinition,
|
||||
} from '../../../../../../core/blitzkrieg/tankDefinitions';
|
||||
|
||||
interface CompactSearchResultRowProps {
|
||||
tanks: TankDefinition[];
|
||||
onSelect: (tank: TankDefinition) => void;
|
||||
}
|
||||
|
||||
export function CompactSearchResultRow({
|
||||
tanks,
|
||||
onSelect = () => {},
|
||||
}: CompactSearchResultRowProps) {
|
||||
return (
|
||||
<Flex justify="center">
|
||||
<Flex direction="column" gap="2" align="start">
|
||||
{tanks.map((tank) => (
|
||||
<Button
|
||||
key={tank.id}
|
||||
variant="ghost"
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => onSelect(tank)}
|
||||
>
|
||||
<Flex gap="2" align="start" style={{ width: '100%' }}>
|
||||
<Text align="right" color="gray" style={{ width: 24 }}>
|
||||
{TIER_ROMAN_NUMERALS[tank.tier]}
|
||||
</Text>
|
||||
|
||||
<Text color={tank.treeType === 'collector' ? 'blue' : 'amber'}>
|
||||
<Flex gap="2" align="center">
|
||||
<SmallTankIcon id={tank.id} size={16} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'block',
|
||||
color:
|
||||
tank.treeType === 'researchable' ? 'white' : undefined,
|
||||
}}
|
||||
>
|
||||
{tank.name}
|
||||
</div>
|
||||
</Flex>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const splitFilters = style({
|
||||
'@media': {
|
||||
'screen and (min-width: 513px)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const oneLineFilters = style({
|
||||
'@media': {
|
||||
'screen and (max-width: 512px)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
'use client';
|
||||
|
||||
import { CaretDownIcon } from '@radix-ui/react-icons';
|
||||
import { Button, DropdownMenu, Flex } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { Flag } from '../../../../../../../components/Flag';
|
||||
import {
|
||||
TANK_CLASSES,
|
||||
TREE_TYPES,
|
||||
TREE_TYPE_ICONS,
|
||||
TREE_TYPE_IMAGES,
|
||||
} from '../../../../../../../components/Tanks';
|
||||
import {
|
||||
NATIONS,
|
||||
TIERS,
|
||||
TIER_ROMAN_NUMERALS,
|
||||
} from '../../../../../../../core/blitzkrieg/tankDefinitions';
|
||||
import mutateTankopediaPersistent, {
|
||||
TankopediaTestTankDisplay,
|
||||
useTankopediaPersistent,
|
||||
} from '../../../../../../../stores/tankopedia';
|
||||
import * as styles from './index.css';
|
||||
|
||||
export function Options() {
|
||||
const nations = use(NATIONS);
|
||||
const filters = useTankopediaPersistent((state) => state.filters);
|
||||
|
||||
return (
|
||||
<Flex gap="2" wrap="wrap" justify="center">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="soft" color="gray">
|
||||
Test tanks
|
||||
<CaretDownIcon />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.RadioGroup
|
||||
value={filters.test}
|
||||
onValueChange={(value) =>
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.filters.test = value as TankopediaTestTankDisplay;
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.RadioItem value="include">
|
||||
Include
|
||||
</DropdownMenu.RadioItem>
|
||||
<DropdownMenu.RadioItem value="exclude">
|
||||
Exclude
|
||||
</DropdownMenu.RadioItem>
|
||||
<DropdownMenu.RadioItem value="only">Only</DropdownMenu.RadioItem>
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<Flex>
|
||||
{TREE_TYPES.map((type, index) => (
|
||||
<Button
|
||||
key={type}
|
||||
color={filters.treeTypes.includes(type) ? undefined : 'gray'}
|
||||
variant={filters.treeTypes.includes(type) ? 'surface' : 'soft'}
|
||||
style={{
|
||||
flex: 1,
|
||||
margin: -0.5,
|
||||
borderTopLeftRadius: index === 0 ? undefined : 0,
|
||||
borderBottomLeftRadius: index === 0 ? undefined : 0,
|
||||
borderTopRightRadius:
|
||||
index === TREE_TYPES.length - 1 ? undefined : 0,
|
||||
borderBottomRightRadius:
|
||||
index === TREE_TYPES.length - 1 ? undefined : 0,
|
||||
}}
|
||||
onClick={() =>
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
if (draft.filters.treeTypes.includes(type)) {
|
||||
draft.filters.treeTypes = draft.filters.treeTypes.filter(
|
||||
(preexistingType) => preexistingType !== type,
|
||||
);
|
||||
} else {
|
||||
draft.filters.treeTypes.push(type);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={TREE_TYPE_IMAGES[type]}
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex className={styles.oneLineFilters}>
|
||||
{nations.map((nation, index) => (
|
||||
<Button
|
||||
key={nation}
|
||||
color={filters.nations.includes(nation) ? undefined : 'gray'}
|
||||
variant={filters.nations.includes(nation) ? 'surface' : 'soft'}
|
||||
style={{
|
||||
flex: 1,
|
||||
margin: -0.5,
|
||||
borderTopLeftRadius: index === 0 ? undefined : 0,
|
||||
borderBottomLeftRadius: index === 0 ? undefined : 0,
|
||||
borderTopRightRadius:
|
||||
index === nations.length - 1 ? undefined : 0,
|
||||
borderBottomRightRadius:
|
||||
index === nations.length - 1 ? undefined : 0,
|
||||
}}
|
||||
onClick={() =>
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
if (draft.filters.nations.includes(nation)) {
|
||||
draft.filters.nations = draft.filters.nations.filter(
|
||||
(preexistingType) => preexistingType !== nation,
|
||||
);
|
||||
} else {
|
||||
draft.filters.nations.push(nation);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Flag nation={nation} />
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex>
|
||||
{[...TANK_CLASSES].map((type, index) => (
|
||||
<Button
|
||||
key={type}
|
||||
color={filters.types.includes(type) ? undefined : 'gray'}
|
||||
variant={filters.types.includes(type) ? 'surface' : 'soft'}
|
||||
style={{
|
||||
flex: 1,
|
||||
margin: -0.5,
|
||||
borderTopLeftRadius: index === 0 ? undefined : 0,
|
||||
borderBottomLeftRadius: index === 0 ? undefined : 0,
|
||||
borderTopRightRadius:
|
||||
index === TANK_CLASSES.length - 1 ? undefined : 0,
|
||||
borderBottomRightRadius:
|
||||
index === TANK_CLASSES.length - 1 ? undefined : 0,
|
||||
}}
|
||||
onClick={() =>
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
if (draft.filters.types.includes(type)) {
|
||||
draft.filters.types = draft.filters.types.filter(
|
||||
(preexistingType) => preexistingType !== type,
|
||||
);
|
||||
} else {
|
||||
draft.filters.types.push(type);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={TREE_TYPE_ICONS.researchable[type]}
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex className={styles.oneLineFilters}>
|
||||
{[...TIERS].map((tier, index) => (
|
||||
<Button
|
||||
key={tier}
|
||||
color={filters.tiers.includes(tier) ? undefined : 'gray'}
|
||||
variant={filters.tiers.includes(tier) ? 'surface' : 'soft'}
|
||||
style={{
|
||||
flex: 1,
|
||||
margin: -0.5,
|
||||
borderTopLeftRadius: index === 0 ? undefined : 0,
|
||||
borderBottomLeftRadius: index === 0 ? undefined : 0,
|
||||
borderTopRightRadius: index === TIERS.length - 1 ? undefined : 0,
|
||||
borderBottomRightRadius:
|
||||
index === TIERS.length - 1 ? undefined : 0,
|
||||
}}
|
||||
onClick={() =>
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
if (draft.filters.tiers.includes(tier)) {
|
||||
draft.filters.tiers = draft.filters.tiers.filter(
|
||||
(preexistingTier) => preexistingTier !== tier,
|
||||
);
|
||||
} else {
|
||||
draft.filters.tiers.push(tier);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{TIER_ROMAN_NUMERALS[tier]}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>{' '}
|
||||
<Flex
|
||||
direction="column"
|
||||
className={styles.splitFilters}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Flex>
|
||||
{[...TIERS].slice(0, 5).map((tier, index) => (
|
||||
<Button
|
||||
key={tier}
|
||||
color={filters.tiers.includes(tier) ? undefined : 'gray'}
|
||||
variant={filters.tiers.includes(tier) ? 'surface' : 'soft'}
|
||||
style={{
|
||||
flex: 1,
|
||||
margin: -0.5,
|
||||
borderTopLeftRadius: index === 0 ? 16 : 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderTopRightRadius:
|
||||
index === TIERS.length - Math.round(TIERS.length / 2) - 1
|
||||
? 16
|
||||
: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
}}
|
||||
onClick={() =>
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
if (draft.filters.tiers.includes(tier)) {
|
||||
draft.filters.tiers = draft.filters.tiers.filter(
|
||||
(preexistingTier) => preexistingTier !== tier,
|
||||
);
|
||||
} else {
|
||||
draft.filters.tiers.push(tier);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{TIER_ROMAN_NUMERALS[tier]}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex>
|
||||
{[...TIERS].slice(5).map((tier, index) => (
|
||||
<Button
|
||||
key={tier}
|
||||
color={filters.tiers.includes(tier) ? undefined : 'gray'}
|
||||
variant={filters.tiers.includes(tier) ? 'surface' : 'soft'}
|
||||
style={{
|
||||
flex: 1,
|
||||
margin: -0.5,
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: index === 0 ? 16 : 0,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius:
|
||||
index === TIERS.length - Math.round(TIERS.length / 2) - 1
|
||||
? 16
|
||||
: 0,
|
||||
}}
|
||||
onClick={() =>
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
if (draft.filters.tiers.includes(tier)) {
|
||||
draft.filters.tiers = draft.filters.tiers.filter(
|
||||
(preexistingTier) => preexistingTier !== tier,
|
||||
);
|
||||
} else {
|
||||
draft.filters.tiers.push(tier);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{TIER_ROMAN_NUMERALS[tier]}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex
|
||||
direction="column"
|
||||
className={styles.splitFilters}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Flex>
|
||||
{nations.slice(0, 5).map((nation, index) => (
|
||||
<Button
|
||||
key={nation}
|
||||
color={filters.nations.includes(nation) ? undefined : 'gray'}
|
||||
variant={filters.nations.includes(nation) ? 'surface' : 'soft'}
|
||||
style={{
|
||||
flex: 1,
|
||||
margin: -0.5,
|
||||
borderTopLeftRadius: index === 0 ? 16 : 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderTopRightRadius:
|
||||
index === nations.length - Math.round(nations.length / 2)
|
||||
? 16
|
||||
: 0,
|
||||
borderBottomRightRadius:
|
||||
index === nations.length - 1 ? undefined : 0,
|
||||
}}
|
||||
onClick={() =>
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
if (draft.filters.nations.includes(nation)) {
|
||||
draft.filters.nations = draft.filters.nations.filter(
|
||||
(preexistingType) => preexistingType !== nation,
|
||||
);
|
||||
} else {
|
||||
draft.filters.nations.push(nation);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Flag nation={nation} />
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
<Flex>
|
||||
{nations.slice(5).map((nation, index) => (
|
||||
<Button
|
||||
key={nation}
|
||||
color={filters.nations.includes(nation) ? undefined : 'gray'}
|
||||
variant={filters.nations.includes(nation) ? 'surface' : 'soft'}
|
||||
style={{
|
||||
flex: 1,
|
||||
margin: -0.5,
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: index === 0 ? 16 : 0,
|
||||
borderTopRightRadius:
|
||||
index === nations.length - 1 ? undefined : 0,
|
||||
borderBottomRightRadius:
|
||||
index === nations.length - Math.round(nations.length / 2) - 1
|
||||
? 16
|
||||
: 0,
|
||||
}}
|
||||
onClick={() =>
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
if (draft.filters.nations.includes(nation)) {
|
||||
draft.filters.nations = draft.filters.nations.filter(
|
||||
(preexistingType) => preexistingType !== nation,
|
||||
);
|
||||
} else {
|
||||
draft.filters.nations.push(nation);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Flag nation={nation} />
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
import { slateDark } from '@radix-ui/colors';
|
||||
import {
|
||||
CaretDownIcon,
|
||||
MagnifyingGlassIcon,
|
||||
TrashIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DropdownMenu,
|
||||
Flex,
|
||||
Inset,
|
||||
Text,
|
||||
TextField,
|
||||
} from '@radix-ui/themes';
|
||||
import { go } from 'fuzzysort';
|
||||
import { use, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TANK_CLASSES, TreeType } from '../../../../../components/Tanks';
|
||||
import { resolveNearPenetration } from '../../../../../core/blitz/resolveNearPenetration';
|
||||
import { asset } from '../../../../../core/blitzkrieg/asset';
|
||||
import { modelDefinitions } from '../../../../../core/blitzkrieg/modelDefinitions';
|
||||
import { normalizeBoundingBox } from '../../../../../core/blitzkrieg/normalizeBoundingBox';
|
||||
import { resolveDpm } from '../../../../../core/blitzkrieg/resolveDpm';
|
||||
import { resolveReload } from '../../../../../core/blitzkrieg/resolveReload';
|
||||
import {
|
||||
NATIONS,
|
||||
TANK_ICONS,
|
||||
TANK_ICONS_COLLECTOR,
|
||||
TANK_ICONS_PREMIUM,
|
||||
TIER_ROMAN_NUMERALS,
|
||||
TankDefinition,
|
||||
tanksDefinitionsArray,
|
||||
} from '../../../../../core/blitzkrieg/tankDefinitions';
|
||||
import { tankIcon } from '../../../../../core/blitzkrieg/tankIcon';
|
||||
import { unionBoundingBox } from '../../../../../core/blitzkrieg/unionBoundingBox';
|
||||
import { theme } from '../../../../../stitches.config';
|
||||
import mutateTankopediaPersistent, {
|
||||
SORT_NAMES,
|
||||
TankopediaSortDirection,
|
||||
useTankopediaPersistent,
|
||||
} from '../../../../../stores/tankopedia';
|
||||
import * as styles from '../../page.css';
|
||||
import { PageTurner } from '../PageTurner';
|
||||
import { CompactSearchResultRow } from './components/CompactSearchResultRow';
|
||||
import { Options } from './components/Options';
|
||||
|
||||
interface TankSearchProps {
|
||||
compact?: boolean;
|
||||
onSelect?: (tank: TankDefinition) => void;
|
||||
}
|
||||
|
||||
const treeTypeOrder: TreeType[] = ['researchable', 'premium', 'collector'];
|
||||
|
||||
export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
||||
const tanksPerPage = compact ? 16 : 96;
|
||||
const nations = use(NATIONS);
|
||||
const filters = useTankopediaPersistent((state) => state.filters);
|
||||
const sort = useTankopediaPersistent((state) => state.sort);
|
||||
const awaitedTanks = use(tanksDefinitionsArray);
|
||||
const awaitedModelDefinitions = use(modelDefinitions);
|
||||
const defaultSortedTanks = useMemo(
|
||||
() =>
|
||||
awaitedTanks
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(treeTypeOrder.indexOf(a.treeType) -
|
||||
treeTypeOrder.indexOf(b.treeType)) *
|
||||
(sort.direction === 'descending' ? -1 : 1),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(TANK_CLASSES.indexOf(a.class) - TANK_CLASSES.indexOf(b.class)) *
|
||||
(sort.direction === 'descending' ? -1 : 1),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(nations.indexOf(a.nation) - nations.indexOf(b.nation)) *
|
||||
(sort.direction === 'descending' ? -1 : 1),
|
||||
),
|
||||
[],
|
||||
);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const searchableTanks = useMemo(() => {
|
||||
const filtered = defaultSortedTanks.filter(
|
||||
(tank) =>
|
||||
(filters.tiers.length === 0
|
||||
? true
|
||||
: filters.tiers.includes(tank.tier)) &&
|
||||
(filters.types.length === 0
|
||||
? true
|
||||
: filters.types.includes(tank.class)) &&
|
||||
(filters.treeTypes.length === 0
|
||||
? true
|
||||
: filters.treeTypes.includes(tank.treeType)) &&
|
||||
(filters.nations.length === 0
|
||||
? true
|
||||
: filters.nations.includes(tank.nation)) &&
|
||||
(filters.test === 'include'
|
||||
? true
|
||||
: filters.test === 'exclude'
|
||||
? !tank.testing
|
||||
: tank.testing),
|
||||
);
|
||||
let sorted: TankDefinition[] = [];
|
||||
|
||||
switch (sort.by) {
|
||||
case 'meta.tier':
|
||||
sorted = filtered.sort((a, b) => a.tier - b.tier);
|
||||
break;
|
||||
|
||||
case 'meta.name':
|
||||
sorted = filtered.sort((a, b) => a.name?.localeCompare(b.name));
|
||||
break;
|
||||
|
||||
case 'survivability.health': {
|
||||
sorted = filtered.sort((a, b) => {
|
||||
const aHealth = a.health + a.turrets.at(-1)!.health;
|
||||
const bHealth = b.health + b.turrets.at(-1)!.health;
|
||||
|
||||
return aHealth - bHealth;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'survivability.viewRange':
|
||||
sorted = filtered.sort(
|
||||
(a, b) => a.turrets.at(-1)!.viewRange - b.turrets.at(-1)!.viewRange,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'survivability.camouflageStill':
|
||||
sorted = filtered.sort(
|
||||
(a, b) => a.camouflage.still - b.camouflage.still,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'survivability.camouflageMoving':
|
||||
sorted = filtered.sort(
|
||||
(a, b) => a.camouflage.moving - b.camouflage.moving,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'survivability.camouflageShooting':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
a.turrets.at(-1)!.guns.at(-1)!.camouflageLoss -
|
||||
b.turrets.at(-1)!.guns.at(-1)!.camouflageLoss,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'survivability.size':
|
||||
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 aVolume = aSize[0] * aSize[1] * aSize[2];
|
||||
const bVolume = bSize[0] * bSize[1] * bSize[2];
|
||||
|
||||
return aVolume - bVolume;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'fire.dpm':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
resolveDpm(
|
||||
a.turrets.at(-1)!.guns.at(-1)!,
|
||||
a.turrets.at(-1)!.guns.at(-1)!.shells[0],
|
||||
) -
|
||||
resolveDpm(
|
||||
b.turrets.at(-1)!.guns.at(-1)!,
|
||||
b.turrets.at(-1)!.guns.at(-1)!.shells[0],
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'fire.reload':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
resolveReload(a.turrets.at(-1)!.guns.at(-1)!) -
|
||||
resolveReload(b.turrets.at(-1)!.guns.at(-1)!),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'fire.caliber':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
a.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber -
|
||||
b.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'fire.standardPenetration':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
resolveNearPenetration(
|
||||
a.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration,
|
||||
) -
|
||||
resolveNearPenetration(
|
||||
b.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'fire.premiumPenetration':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
resolveNearPenetration(
|
||||
(
|
||||
a.turrets.at(-1)!.guns.at(-1)!.shells[1] ??
|
||||
a.turrets.at(-1)!.guns.at(-1)!.shells[0]
|
||||
).penetration,
|
||||
) -
|
||||
resolveNearPenetration(
|
||||
(
|
||||
b.turrets.at(-1)!.guns.at(-1)!.shells[1] ??
|
||||
b.turrets.at(-1)!.guns.at(-1)!.shells[0]
|
||||
).penetration,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'fire.damage':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
a.turrets.at(-1)!.guns.at(-1)!.shells[0].damage.armor -
|
||||
b.turrets.at(-1)!.guns.at(-1)!.shells[0].damage.armor,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'fire.shellVelocity':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
a.turrets.at(-1)!.guns.at(-1)!.shells[0].speed -
|
||||
b.turrets.at(-1)!.guns.at(-1)!.shells[0].speed,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'fire.aimTime':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
a.turrets.at(-1)!.guns.at(-1)!.aimTime -
|
||||
b.turrets.at(-1)!.guns.at(-1)!.aimTime,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'fire.dispersionStill':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
a.turrets.at(-1)!.guns.at(-1)!.dispersion.base -
|
||||
b.turrets.at(-1)!.guns.at(-1)!.dispersion.base,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'fire.dispersionMoving':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
a.tracks.at(-1)!.dispersion.move - b.tracks.at(-1)!.dispersion.move,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'fire.gunDepression':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
awaitedModelDefinitions[a.id].turrets[a.turrets.at(-1)!.id].guns[
|
||||
a.turrets.at(-1)!.guns.at(-1)!.id
|
||||
].pitch.max -
|
||||
awaitedModelDefinitions[b.id].turrets[b.turrets.at(-1)!.id].guns[
|
||||
b.turrets.at(-1)!.guns.at(-1)!.id
|
||||
].pitch.max,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'fire.gunElevation':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
// reversed because of negative values
|
||||
awaitedModelDefinitions[b.id].turrets[b.turrets.at(-1)!.id].guns[
|
||||
b.turrets.at(-1)!.guns.at(-1)!.id
|
||||
].pitch.min -
|
||||
awaitedModelDefinitions[a.id].turrets[a.turrets.at(-1)!.id].guns[
|
||||
a.turrets.at(-1)!.guns.at(-1)!.id
|
||||
].pitch.min,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'maneuverability.forwardsSpeed':
|
||||
sorted = filtered.sort((a, b) => a.speed.forwards - b.speed.forwards);
|
||||
break;
|
||||
|
||||
case 'maneuverability.backwardsSpeed':
|
||||
sorted = filtered.sort((a, b) => a.speed.backwards - b.speed.backwards);
|
||||
break;
|
||||
|
||||
case 'maneuverability.power':
|
||||
sorted = filtered.sort(
|
||||
(a, b) => a.engines.at(-1)!.power - b.engines.at(-1)!.power,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'maneuverability.powerToWeight':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
a.engines.at(-1)!.power /
|
||||
(a.weight +
|
||||
a.engines.at(-1)!.weight +
|
||||
a.tracks.at(-1)!.weight +
|
||||
a.turrets.at(-1)!.weight +
|
||||
a.turrets.at(-1)!.guns.at(-1)!.weight) -
|
||||
b.engines.at(-1)!.power /
|
||||
(b.weight +
|
||||
b.engines.at(-1)!.weight +
|
||||
b.tracks.at(-1)!.weight +
|
||||
b.turrets.at(-1)!.weight +
|
||||
b.turrets.at(-1)!.guns.at(-1)!.weight),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'maneuverability.weight':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
a.weight +
|
||||
a.engines.at(-1)!.weight +
|
||||
a.tracks.at(-1)!.weight +
|
||||
a.turrets.at(-1)!.weight +
|
||||
a.turrets.at(-1)!.guns.at(-1)!.weight -
|
||||
(b.weight +
|
||||
b.engines.at(-1)!.weight +
|
||||
b.tracks.at(-1)!.weight +
|
||||
b.turrets.at(-1)!.weight +
|
||||
b.turrets.at(-1)!.guns.at(-1)!.weight),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'maneuverability.traverseSpeed':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
a.tracks.at(-1)!.traverseSpeed - b.tracks.at(-1)!.traverseSpeed,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return sort.direction === 'ascending' ? sorted : sorted.reverse();
|
||||
}, [
|
||||
sort,
|
||||
...Object.entries(filters)
|
||||
.filter(([name]) => name !== 'page')
|
||||
.map(([, value]) => value),
|
||||
]);
|
||||
const [searchResults, setSearchedList] = useState(searchableTanks);
|
||||
const page = useTankopediaPersistent((state) => state.filters.page);
|
||||
const wideFormat = typeof window !== 'undefined' && window.innerWidth > 512;
|
||||
const chunkSize = wideFormat ? tanksPerPage / 2 : Infinity;
|
||||
const searchResultsPageSlice = searchResults.slice(
|
||||
page * tanksPerPage,
|
||||
(page + 1) * tanksPerPage,
|
||||
);
|
||||
const firstChunk = searchResultsPageSlice.slice(0, chunkSize);
|
||||
const secondChunk = searchResultsPageSlice.slice(chunkSize);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchedList(searchableTanks);
|
||||
}, [searchableTanks]);
|
||||
useEffect(() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.filters.page = Math.min(
|
||||
Math.max(0, page),
|
||||
Math.ceil(searchResults.length / tanksPerPage) - 1,
|
||||
);
|
||||
});
|
||||
}, [searchResults]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex direction="column" gap="4">
|
||||
<Flex gap="2">
|
||||
<TextField.Root style={{ flex: 1 }}>
|
||||
<TextField.Slot>
|
||||
<MagnifyingGlassIcon height="16" width="16" />
|
||||
</TextField.Slot>
|
||||
|
||||
<TextField.Input
|
||||
ref={input}
|
||||
placeholder="Search tanks..."
|
||||
onChange={(event) => {
|
||||
const results = go(event.target.value, searchableTanks, {
|
||||
keys: [
|
||||
'name',
|
||||
'nameFull',
|
||||
'id',
|
||||
] satisfies (keyof TankDefinition)[],
|
||||
all: true,
|
||||
}).map(({ obj }) => obj);
|
||||
|
||||
setSearchedList(results);
|
||||
}}
|
||||
/>
|
||||
</TextField.Root>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="soft" color="gray">
|
||||
Sort
|
||||
<CaretDownIcon />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Label>By</DropdownMenu.Label>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger>Meta</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'meta.tier'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'meta.tier';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Tier
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'meta.name'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'meta.name';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Name
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger>Fire</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'fire.dpm'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'fire.dpm';
|
||||
});
|
||||
}}
|
||||
>
|
||||
DPM
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'fire.reload'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'fire.reload';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reload
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'fire.caliber'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'fire.caliber';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Caliber
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'fire.standardPenetration'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'fire.standardPenetration';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Standard penetration
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'fire.premiumPenetration'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'fire.premiumPenetration';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Premium penetration
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'fire.damage'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'fire.damage';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Damage
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'fire.shellVelocity'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'fire.shellVelocity';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Shell velocity
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'fire.aimTime'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'fire.aimTime';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Aim time
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'fire.dispersionStill'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'fire.dispersionStill';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Dispersion still
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'fire.dispersionMoving'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'fire.dispersionMoving';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Dispersion moving
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'fire.gunDepression'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'fire.gunDepression';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Gun depression
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'fire.gunElevation'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'fire.gunElevation';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Gun elevation
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger>
|
||||
Maneuverability
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'maneuverability.forwardsSpeed'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'maneuverability.forwardsSpeed';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Forwards speed
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'maneuverability.backwardsSpeed'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'maneuverability.backwardsSpeed';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Backwards speed
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'maneuverability.power'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'maneuverability.power';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Power
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'maneuverability.powerToWeight'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'maneuverability.powerToWeight';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Power to weight
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'maneuverability.weight'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'maneuverability.weight';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Weight
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'maneuverability.traverseSpeed'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'maneuverability.traverseSpeed';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Traverse speed
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger>Survivability</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'survivability.health'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'survivability.health';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Health
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'survivability.viewRange'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'survivability.viewRange';
|
||||
});
|
||||
}}
|
||||
>
|
||||
View range
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'survivability.camouflageStill'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'survivability.camouflageStill';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Camouflage
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'survivability.camouflageMoving'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'survivability.camouflageMoving';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Camouflage moving
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'survivability.camouflageShooting'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'survivability.camouflageShooting';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Camouflage shooting
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'survivability.size'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'survivability.size';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Size
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Label>Direction</DropdownMenu.Label>
|
||||
<DropdownMenu.RadioGroup
|
||||
value={sort.direction}
|
||||
onValueChange={(value) =>
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.direction = value as TankopediaSortDirection;
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.RadioItem value="ascending">
|
||||
Ascending
|
||||
</DropdownMenu.RadioItem>
|
||||
<DropdownMenu.RadioItem value="descending">
|
||||
Descending
|
||||
</DropdownMenu.RadioItem>
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<Button
|
||||
color="red"
|
||||
variant="soft"
|
||||
onClick={() =>
|
||||
useTankopediaPersistent.setState({
|
||||
filters: {
|
||||
nations: [],
|
||||
tiers: [],
|
||||
treeTypes: [],
|
||||
types: [],
|
||||
test: 'include',
|
||||
page: 0,
|
||||
},
|
||||
sort: {
|
||||
by: 'meta.tier',
|
||||
direction: 'descending',
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Options />
|
||||
</Flex>
|
||||
|
||||
<Flex justify="center">
|
||||
<Text color="gray">
|
||||
Sorting: {SORT_NAMES[sort.by]}, {sort.direction}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{!compact && (
|
||||
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
|
||||
)}
|
||||
<Flex wrap="wrap" gap="2" justify={compact ? 'between' : 'center'}>
|
||||
{!compact &&
|
||||
searchResultsPageSlice.map((tank) => (
|
||||
<Card
|
||||
key={tank.id}
|
||||
onClick={() => onSelect(tank)}
|
||||
className={styles.listing}
|
||||
style={{
|
||||
flex: 1,
|
||||
cursor: 'pointer',
|
||||
height: 64,
|
||||
}}
|
||||
>
|
||||
<Inset
|
||||
key={tank.id}
|
||||
style={{
|
||||
minWidth: 256,
|
||||
minHeight: 64,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className={styles.flag}
|
||||
src={asset(`flags/scratched/${tank.nation}.webp`)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
position: 'absolute',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={styles.listingShadow}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
transition: `box-shadow ${theme.durations.regular}`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: 256,
|
||||
height: 128,
|
||||
}}
|
||||
className={styles.listingImage}
|
||||
>
|
||||
<img
|
||||
src={tankIcon(tank.id)}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'left center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Flex
|
||||
align="center"
|
||||
justify="between"
|
||||
className={styles.listingLabel}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
padding: '0 12px',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Flex align="center" justify="center" gap="1">
|
||||
<img
|
||||
src={
|
||||
(tank.treeType === 'collector'
|
||||
? TANK_ICONS_COLLECTOR
|
||||
: tank.treeType === 'premium'
|
||||
? TANK_ICONS_PREMIUM
|
||||
: TANK_ICONS)[tank.class]
|
||||
}
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
/>
|
||||
<Text
|
||||
size="4"
|
||||
color={
|
||||
tank.treeType === 'collector'
|
||||
? 'blue'
|
||||
: tank.treeType === 'premium'
|
||||
? 'amber'
|
||||
: undefined
|
||||
}
|
||||
weight="medium"
|
||||
style={{
|
||||
color:
|
||||
tank.treeType === 'researchable'
|
||||
? slateDark.slate12
|
||||
: undefined,
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 160,
|
||||
display: 'block',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{tank.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Text size="4" color="gray" highContrast={false}>
|
||||
{TIER_ROMAN_NUMERALS[tank.tier]}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Inset>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{compact && (
|
||||
<CompactSearchResultRow tanks={firstChunk} onSelect={onSelect} />
|
||||
)}
|
||||
|
||||
{compact && secondChunk.length > 0 && (
|
||||
<CompactSearchResultRow tanks={secondChunk} onSelect={onSelect} />
|
||||
)}
|
||||
</Flex>
|
||||
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { theme } from '../../../stitches.config';
|
||||
|
||||
export const listing = style({});
|
||||
|
||||
export const flag = style({
|
||||
transitionDuration: theme.durations.regular,
|
||||
left: '0',
|
||||
top: '0',
|
||||
scale: 1.5,
|
||||
opacity: 0.75,
|
||||
|
||||
[`.${listing}:hover &, .${listing}:active &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
|
||||
// [`.${listing}:hover &, .${listing}:active &`]: {
|
||||
// transform: 'translateX(-25%)',
|
||||
// filter: 'blur(3rem)',
|
||||
// },
|
||||
});
|
||||
|
||||
export const listingShadow = style({
|
||||
boxShadow: `inset 64px -80px 64px -64px ${theme.colors.appBackground1}`,
|
||||
|
||||
[`.${listing}:hover &, .${listing}:active &`]: {
|
||||
boxShadow: `inset 0 0 0 -64px ${theme.colors.appBackground1}`,
|
||||
},
|
||||
});
|
||||
|
||||
export const listingLabel = style({
|
||||
transition: `transform ${theme.durations.regular}`,
|
||||
transform: 'translateY(0)',
|
||||
|
||||
// [`.${listing}:hover &, .${listing}:active &`]: {
|
||||
// transform: 'translateY(200%)',
|
||||
// },
|
||||
});
|
||||
|
||||
export const listingImage = style({
|
||||
transition: `transform ${theme.durations.regular}`,
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '-25%',
|
||||
|
||||
// [`.${listing}:hover &, .${listing}:active &`]: {
|
||||
// transform: 'translate(-25%, 10%)',
|
||||
// },
|
||||
});
|
||||
|
||||
export const alignTopRight = style({
|
||||
'@media': {
|
||||
'screen and (min-width: 400px)': {
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: 16,
|
||||
},
|
||||
|
||||
'screen and (max-width: 399px)': {
|
||||
// left: 16,
|
||||
// bottom: 16,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const characteristics = style({
|
||||
display: 'flex',
|
||||
gap: 32,
|
||||
|
||||
'@media': {
|
||||
'screen and (max-width: 800px)': {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const configurationChild = style({
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const configuration = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
flex: 1,
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import PageWrapper from '../../../components/PageWrapper';
|
||||
import { TankSearch } from './components/TankSearch';
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<PageWrapper size="double" color="purple">
|
||||
<TankSearch
|
||||
onSelect={(tank) => {
|
||||
router.push(`./tankopedia/${tank.id}`);
|
||||
}}
|
||||
/>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export function SkyLine(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
width="294"
|
||||
height="99"
|
||||
viewBox="0 0 294 99"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M80.2779 23.3701C80.2779 23.3701 73.8679 13.2501 67.5679 18.3701C67.5679 18.3701 65.5679 9.69007 59.1579 12.9301C52.7479 16.1701 55.4779 21.4901 55.4779 21.4901C55.4779 21.4901 47.2779 20.6101 46.4279 24.8101C46.4279 24.8101 39.2879 24.8101 38.5579 30.8101H81.9579L80.2779 23.3701Z"
|
||||
fill="#082424"
|
||||
/>
|
||||
<path
|
||||
d="M89.8678 90.33C92.4159 88.2998 95.5978 87.2321 98.8547 87.3144C102.112 87.3967 105.236 88.6237 107.678 90.78C107.448 90.56 110.008 88.53 110.258 88.38C111.377 87.6722 112.624 87.1899 113.928 86.96C116.408 86.52 119.358 87.04 121.088 89.02C121.116 88.7441 121.226 88.483 121.405 88.2708C121.583 88.0585 121.821 87.9049 122.088 87.83C122.359 87.7706 122.641 87.7932 122.9 87.8946C123.158 87.9961 123.38 88.172 123.538 88.4C122.718 87.14 125.278 85.35 126.068 84.91C127.406 84.2635 128.837 83.8283 130.308 83.62C133.736 82.9624 137.235 82.7473 140.718 82.98C147.715 83.4087 154.471 85.6975 160.288 89.61C160.918 90.05 161.538 90.5 162.138 90.97C165.328 87.55 168.738 85.66 173.558 86.17C174.583 86.2315 175.58 86.5254 176.474 87.0296C177.368 87.5338 178.135 88.235 178.718 89.08C178.968 88.4817 179.403 87.9788 179.959 87.6443C180.514 87.3098 181.162 87.1612 181.808 87.22C182.452 87.2962 183.074 87.5053 183.633 87.8341C184.193 88.1628 184.678 88.6041 185.058 89.13C183.768 87.46 189.648 84.44 190.618 84.26C193.668 83.71 196.618 85.71 199.008 87.43C201.189 85.0982 204.063 83.5308 207.204 82.9599C210.345 82.389 213.586 82.8448 216.448 84.26C218.627 82.7527 221.302 82.1396 223.92 82.5471C226.538 82.9546 228.9 84.3517 230.518 86.45C231.368 85.7343 232.356 85.2011 233.421 84.8836C234.485 84.566 235.604 84.4709 236.707 84.6041C237.81 84.7372 238.874 85.0959 239.832 85.6578C240.791 86.2196 241.623 86.9726 242.278 87.87C242.088 87.58 245.968 86.11 246.428 86.14C247.21 86.2189 247.955 86.5104 248.583 86.9829C249.21 87.4553 249.696 88.0907 249.988 88.82C250.566 88.33 251.291 88.0483 252.048 88.02C252.494 88.0752 252.901 88.3007 253.184 88.6492C253.468 88.9978 253.605 89.4423 253.568 89.89C254.07 89.4425 254.67 89.1189 255.319 88.9453C255.969 88.7716 256.65 88.7527 257.308 88.89C260.708 89.63 261.658 95.18 261.648 98.09H68.3578C68.0578 98.07 79.9878 83.84 89.8678 90.33Z"
|
||||
fill="#082424"
|
||||
/>
|
||||
<path d="M249.299 63.98H187.048V70.17H249.299V63.98Z" fill="#082424" />
|
||||
<path
|
||||
d="M246.628 70.1699H189.708V97.5099H246.628V70.1699Z"
|
||||
fill="#082424"
|
||||
/>
|
||||
<path d="M283.819 47.53H254.069V52.35H283.819V47.53Z" fill="#082424" />
|
||||
<path
|
||||
d="M281.669 52.3401H256.219V98.0801H281.669V52.3401Z"
|
||||
fill="#082424"
|
||||
/>
|
||||
<path
|
||||
d="M164.078 73.6901V68.8501H142.238V73.6901H122.458V79.7901H125.808V98.0901H180.498V79.7901H183.848V73.6901H164.078Z"
|
||||
fill="#082424"
|
||||
/>
|
||||
<path
|
||||
d="M73.1879 59.22V52H55.9079V59.22H47.9079V52H30.6279V59.22H22.8879V98.09H81.9579V59.22H73.1879Z"
|
||||
fill="#082424"
|
||||
/>
|
||||
<path
|
||||
d="M115.578 31.3701V25.3601H111.968V19.3401H109.638V13.3301H95.9577V19.3401H93.6277V25.3601H90.0277V31.3701H86.4177V98.0901H119.178V31.3701H115.578Z"
|
||||
fill="#082424"
|
||||
/>
|
||||
<path
|
||||
d="M102.798 14.33C102.533 14.33 102.279 14.2247 102.091 14.0371C101.903 13.8496 101.798 13.5952 101.798 13.33V1.91003C101.798 1.64482 101.903 1.39046 102.091 1.20293C102.279 1.01539 102.533 0.910034 102.798 0.910034C103.063 0.910034 103.318 1.01539 103.505 1.20293C103.693 1.39046 103.798 1.64482 103.798 1.91003V13.33C103.798 13.5952 103.693 13.8496 103.505 14.0371C103.318 14.2247 103.063 14.33 102.798 14.33Z"
|
||||
fill="#082424"
|
||||
/>
|
||||
<path
|
||||
d="M179.959 48.8401C179.959 48.8401 180.779 41.3601 186.509 40.9201C190.559 40.6101 191.729 43.6201 191.729 43.6201C191.598 42.1914 191.866 40.7543 192.503 39.469C193.14 38.1836 194.122 37.1005 195.339 36.3401C199.609 33.5901 203.919 34.4601 205.339 38.0901C207.263 37.0072 209.491 36.5916 211.677 36.9082C213.862 37.2248 215.881 38.2556 217.419 39.8401C223.119 45.4601 223.069 48.8401 223.069 48.8401H179.959Z"
|
||||
fill="#082424"
|
||||
/>
|
||||
<path
|
||||
d="M26.6637 84.6301V73.6901H4.82372V84.6301H0.499809V90.7301L0.499512 98.09H43.0838L43.0837 90.7301H46.4337V84.6301H26.6637Z"
|
||||
fill="#082424"
|
||||
/>
|
||||
<path
|
||||
d="M293.627 73.6901H236.707V98.09H293.627V73.6901Z"
|
||||
fill="#082424"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+54
-54
@@ -1,69 +1,69 @@
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
SlashCommandSubcommandsOnlyBuilder,
|
||||
} from 'discord.js';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { COMMANDS_RAW, CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
const RAW_PATH = `https://raw.githubusercontent.com/tresabhi/blitzkrieg/main/docs/`;
|
||||
const DOCS = {
|
||||
permissions: 'guide/permissions',
|
||||
const subcommands = [
|
||||
'embeds',
|
||||
'introduction',
|
||||
'invite',
|
||||
'timezones',
|
||||
'creators',
|
||||
] as const;
|
||||
type Subcommand = (typeof subcommands)[number];
|
||||
|
||||
const DOCS: Record<Subcommand, string> = {
|
||||
embeds: 'guide/embeds',
|
||||
introduction: 'guide/introduction',
|
||||
invite: 'guide/invite',
|
||||
timezones: 'guide/timezones',
|
||||
creators: 'guide/creators',
|
||||
};
|
||||
const DOC_DESCRIPTIONS: Record<keyof typeof DOCS, string> = {
|
||||
permissions: 'The permissions needed for the bot to function',
|
||||
embeds: 'How embeds work',
|
||||
introduction: 'About the bot',
|
||||
invite: 'How to invite the bot',
|
||||
timezones: 'How Blitzkrieg infers and uses your timezone',
|
||||
creators: 'Who created the bot',
|
||||
};
|
||||
|
||||
function addDocsSubcommands(option: SlashCommandSubcommandsOnlyBuilder) {
|
||||
Object.entries(DOC_DESCRIPTIONS).forEach(([key, value]) => {
|
||||
option.addSubcommand((sub) => sub.setName(key).setDescription(value));
|
||||
});
|
||||
export const aboutCommand = new Promise<CommandRegistry>(async (resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
return option;
|
||||
}
|
||||
command: createLocalizedCommand('about', [
|
||||
{ subcommand: 'commands' },
|
||||
...subcommands.map((subcommand) => ({ subcommand })),
|
||||
]),
|
||||
|
||||
export const aboutCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
async handler(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const { t } = translator(interaction.locale);
|
||||
|
||||
command: addDocsSubcommands(
|
||||
new SlashCommandBuilder()
|
||||
.setName('about')
|
||||
.setDescription('All the info you need about the bot')
|
||||
.addSubcommand((option) =>
|
||||
option
|
||||
.setName('commands')
|
||||
.setDescription('List of all commands and their descriptions'),
|
||||
),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === 'commands') {
|
||||
return `# Commands\n\nBlitzkrieg offers the following commands:\n\n${(
|
||||
await Promise.all(COMMANDS_RAW)
|
||||
)
|
||||
.filter((registry) => registry.inPublic && registry.inProduction)
|
||||
.sort((a, b) => (a.command.name < b.command.name ? -1 : 1))
|
||||
.map(
|
||||
(registry) =>
|
||||
`- \`/${registry.command.name}\`: ${registry.command.description}`,
|
||||
if (subcommand === 'commands') {
|
||||
return t`bot.commands.about.subcommands.commands.body${(
|
||||
await Promise.all(COMMANDS_RAW)
|
||||
)
|
||||
.join('\n')}`;
|
||||
}
|
||||
.filter((registry) => registry.inPublic && registry.inProduction)
|
||||
.sort((a, b) =>
|
||||
(a.command.name_localizations?.[interaction.locale] ??
|
||||
a.command.name) <
|
||||
(b.command.name_localizations?.[interaction.locale] ??
|
||||
b.command.name)
|
||||
? -1
|
||||
: 1,
|
||||
)
|
||||
.map(
|
||||
(registry) =>
|
||||
`- \`/${
|
||||
registry.command.name_localizations?.[interaction.locale] ??
|
||||
registry.command.name
|
||||
}\`: ${
|
||||
registry.command.description_localizations?.[
|
||||
interaction.locale
|
||||
] ?? registry.command.description
|
||||
}`,
|
||||
)
|
||||
.join('\n')}`;
|
||||
}
|
||||
|
||||
const url = `${RAW_PATH}${DOCS[subcommand as keyof typeof DOCS]}.md`;
|
||||
const content = fetch(url).then((response) => response.text());
|
||||
const url = `https://raw.githubusercontent.com/tresabhi/blitzkrieg/main/docs/${DOCS[subcommand as Subcommand]}.md`;
|
||||
const content = fetch(url).then((response) => response.text());
|
||||
|
||||
return content;
|
||||
},
|
||||
};
|
||||
return content;
|
||||
},
|
||||
});
|
||||
});
|
||||
+114
-102
@@ -1,25 +1,27 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { Locale } from 'discord.js';
|
||||
import { chunk } from 'lodash';
|
||||
import * as Breakdown from '../components/Breakdown';
|
||||
import NoData, { NoDataType } from '../components/NoData';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import NoData from '../components/NoData';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { AllStats, getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import getTankStats from '../core/blitz/getTankStats';
|
||||
import getTreeType from '../core/blitz/getTreeType';
|
||||
import resolveTankName from '../core/blitz/resolveTankName';
|
||||
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
|
||||
import {
|
||||
TankDefinition,
|
||||
tankDefinitions,
|
||||
} from '../core/blitzkrieg/tankDefinitions';
|
||||
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
||||
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
|
||||
import { tankAverages } from '../core/blitzstars/tankAverages';
|
||||
import { tankopedia } from '../core/blitzstars/tankopedia';
|
||||
import addFilterOptions from '../core/discord/addFilterOptions';
|
||||
import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteTanks from '../core/discord/autocompleteTanks';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import buttonPrimary from '../core/discord/buttonPrimary';
|
||||
import { buttonRefresh } from '../core/discord/buttonRefresh';
|
||||
import commandToURL from '../core/discord/commandToURL';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
|
||||
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
|
||||
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
|
||||
@@ -31,27 +33,30 @@ import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
|
||||
import resolvePlayerFromCommand, {
|
||||
ResolvedPlayer,
|
||||
} from '../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import calculateWN8 from '../core/statistics/calculateWN8';
|
||||
import { StatFilters, filterStats } from '../core/statistics/filterStats';
|
||||
import getWN8Percentile from '../core/statistics/getWN8Percentile';
|
||||
import { CommandRegistryRaw } from '../events/interactionCreate';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
const ROWS_PER_PAGE = 8;
|
||||
const MAX_PAGES = 10;
|
||||
const MAX_PAGES = 9;
|
||||
|
||||
async function render(
|
||||
export async function renderBreakdown(
|
||||
{ region, id }: ResolvedPlayer,
|
||||
{ start, end, name }: ResolvedPeriod,
|
||||
filters: StatFilters,
|
||||
locale: Locale,
|
||||
) {
|
||||
const awaitedTankopedia = await tankopedia;
|
||||
const { t, translate } = translator(locale);
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const awaitedTankAverages = await tankAverages;
|
||||
const statsInPeriod = await getStatsInPeriod(region, id, start, end);
|
||||
const statsInPeriod = await getStatsInPeriod(region, id, start, end, locale);
|
||||
const { filteredOrder } = await filterStats(statsInPeriod, filters);
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clanData = await getClanAccountInfo(region, id, ['clan']);
|
||||
const tankStats = await getTankStats(region, id);
|
||||
const filterDescriptions = await filtersToDescription(filters);
|
||||
const tankStats = await getTankStats(region, id, locale);
|
||||
const filterDescriptions = await filtersToDescription(filters, locale);
|
||||
const orderedCurrentStats: AllStats[] = [];
|
||||
const orderedCareerStats: AllStats[] = [];
|
||||
const orderedCurrentWN8: (number | undefined)[] = [];
|
||||
@@ -143,21 +148,21 @@ async function render(
|
||||
<Breakdown.Row
|
||||
key={id}
|
||||
type="summary"
|
||||
title="Total"
|
||||
title={t`bot.commands.breakdown.body.total`}
|
||||
stats={[
|
||||
{
|
||||
title: 'Battles',
|
||||
title: t`bot.commands.breakdown.body.battles`,
|
||||
current: currentBattles,
|
||||
career: careerBattles,
|
||||
},
|
||||
{
|
||||
title: 'Winrate',
|
||||
title: t`bot.commands.breakdown.body.winrate`,
|
||||
current: `${(100 * currentWinrate).toFixed(2)}%`,
|
||||
career: `${(100 * careerWinrate).toFixed(2)}%`,
|
||||
delta: currentWinrate - careerWinrate,
|
||||
},
|
||||
{
|
||||
title: 'WN8',
|
||||
title: t`bot.commands.breakdown.body.wn8`,
|
||||
current:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
@@ -172,7 +177,7 @@ async function render(
|
||||
: getWN8Percentile(currentWN8),
|
||||
},
|
||||
{
|
||||
title: 'Damage',
|
||||
title: t`bot.commands.breakdown.body.damage`,
|
||||
current: Math.round(currentDamage).toLocaleString(),
|
||||
career: Math.round(careerDamage).toLocaleString(),
|
||||
delta: currentDamage - careerDamage,
|
||||
@@ -181,68 +186,71 @@ async function render(
|
||||
/>,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
filteredOrder.map(async (id, index) => {
|
||||
const tankopediaEntry = awaitedTankopedia[id];
|
||||
const current = orderedCurrentStats[index];
|
||||
const career = orderedCareerStats[index];
|
||||
const currentWN8 = orderedCurrentWN8[index];
|
||||
const careerWN8 = orderedCareerWN8[index];
|
||||
filteredOrder.forEach((id, index) => {
|
||||
const tankDefinition = awaitedTankDefinitions[id] as
|
||||
| TankDefinition
|
||||
| undefined;
|
||||
const current = orderedCurrentStats[index];
|
||||
const career = orderedCareerStats[index];
|
||||
const currentWN8 = orderedCurrentWN8[index];
|
||||
const careerWN8 = orderedCareerWN8[index];
|
||||
|
||||
children.push(
|
||||
<Breakdown.Row
|
||||
key={id}
|
||||
type="tank"
|
||||
tankType={tankopediaEntry?.type}
|
||||
treeType={tankopediaEntry ? await getTreeType(id) : undefined}
|
||||
title={await resolveTankName(id)}
|
||||
stats={[
|
||||
{
|
||||
title: 'Battles',
|
||||
current: current.battles.toLocaleString(),
|
||||
career: career.battles.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: 'Winrate',
|
||||
current: `${(100 * (current.wins / current.battles)).toFixed(
|
||||
2,
|
||||
)}%`,
|
||||
career: `${(100 * (career.wins / career.battles)).toFixed(2)}%`,
|
||||
delta:
|
||||
current.wins / current.battles - career.wins / career.battles,
|
||||
},
|
||||
{
|
||||
title: 'WN8',
|
||||
current:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
: Math.round(currentWN8).toLocaleString(),
|
||||
career:
|
||||
careerWN8 === undefined
|
||||
? undefined
|
||||
: Math.round(careerWN8).toLocaleString(),
|
||||
percentile:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
: getWN8Percentile(currentWN8),
|
||||
},
|
||||
{
|
||||
title: 'Damage',
|
||||
current: Math.round(
|
||||
current.damage_dealt / current.battles,
|
||||
).toLocaleString(),
|
||||
career: Math.round(
|
||||
career.damage_dealt / career.battles,
|
||||
).toLocaleString(),
|
||||
delta:
|
||||
current.damage_dealt / current.battles -
|
||||
career.damage_dealt / career.battles,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
}),
|
||||
);
|
||||
children.push(
|
||||
<Breakdown.Row
|
||||
key={id}
|
||||
type="tank"
|
||||
tankType={tankDefinition?.class}
|
||||
treeType={tankDefinition?.treeType}
|
||||
title={
|
||||
tankDefinition?.name ??
|
||||
translate('bot.commands.breakdown.body.unknown_tank', [`${id}`])
|
||||
}
|
||||
stats={[
|
||||
{
|
||||
title: t`bot.commands.breakdown.body.battles`,
|
||||
current: current.battles.toLocaleString(),
|
||||
career: career.battles.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: t`bot.commands.breakdown.body.winrate`,
|
||||
current: `${(100 * (current.wins / current.battles)).toFixed(
|
||||
2,
|
||||
)}%`,
|
||||
career: `${(100 * (career.wins / career.battles)).toFixed(2)}%`,
|
||||
delta:
|
||||
current.wins / current.battles - career.wins / career.battles,
|
||||
},
|
||||
{
|
||||
title: t`bot.commands.breakdown.body.wn8`,
|
||||
current:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
: Math.round(currentWN8).toLocaleString(),
|
||||
career:
|
||||
careerWN8 === undefined
|
||||
? undefined
|
||||
: Math.round(careerWN8).toLocaleString(),
|
||||
percentile:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
: getWN8Percentile(currentWN8),
|
||||
},
|
||||
{
|
||||
title: t`bot.commands.breakdown.body.damage`,
|
||||
current: Math.round(
|
||||
current.damage_dealt / current.battles,
|
||||
).toLocaleString(),
|
||||
career: Math.round(
|
||||
career.damage_dealt / career.battles,
|
||||
).toLocaleString(),
|
||||
delta:
|
||||
current.damage_dealt / current.battles -
|
||||
career.damage_dealt / career.battles,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const pages = chunk(
|
||||
@@ -253,10 +261,10 @@ async function render(
|
||||
if (filteredOrder.length > 0) {
|
||||
return pages
|
||||
.map((page, index) => (
|
||||
<Wrapper>
|
||||
<CommandWrapper>
|
||||
{index === 0 && (
|
||||
<TitleBar
|
||||
name={accountInfo.nickname}
|
||||
title={accountInfo.nickname}
|
||||
image={
|
||||
clanData?.clan
|
||||
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData?.clan?.emblem_set_id}.png`
|
||||
@@ -267,14 +275,14 @@ async function render(
|
||||
)}
|
||||
|
||||
<Breakdown.Root>{page}</Breakdown.Root>
|
||||
</Wrapper>
|
||||
</CommandWrapper>
|
||||
))
|
||||
.reverse();
|
||||
} else {
|
||||
return [
|
||||
<Wrapper>
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
name={accountInfo.nickname}
|
||||
title={accountInfo.nickname}
|
||||
image={
|
||||
clanData?.clan
|
||||
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData?.clan?.emblem_set_id}.png`
|
||||
@@ -283,28 +291,22 @@ async function render(
|
||||
description={`${name} • ${filterDescriptions}`}
|
||||
/>
|
||||
|
||||
{filteredOrder.length === 0 && (
|
||||
<NoData type={NoDataType.BattlesInPeriod} />
|
||||
)}
|
||||
</Wrapper>,
|
||||
<NoData type="battles_in_period" locale={locale} />
|
||||
</CommandWrapper>,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export const breakdownCommand = new Promise<CommandRegistryRaw>(
|
||||
export const breakdownCommand = new Promise<CommandRegistry>(
|
||||
async (resolve) => {
|
||||
const command = await addFilterOptions(
|
||||
new SlashCommandBuilder()
|
||||
.setName('breakdown')
|
||||
.setDescription("A period's breakdown by tanks played"),
|
||||
(option) => option.addStringOption(addUsernameChoices),
|
||||
);
|
||||
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command,
|
||||
command: await addPeriodicFilterOptions(
|
||||
createLocalizedCommand('breakdown'),
|
||||
(option) => option.addStringOption(addUsernameChoices),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const player = await resolvePlayerFromCommand(interaction);
|
||||
@@ -317,8 +319,13 @@ export const breakdownCommand = new Promise<CommandRegistryRaw>(
|
||||
});
|
||||
|
||||
return [
|
||||
...(await render(player, period, filters)),
|
||||
buttonPrimary(path, 'Refresh'),
|
||||
...(await renderBreakdown(
|
||||
player,
|
||||
period,
|
||||
filters,
|
||||
interaction.locale,
|
||||
)),
|
||||
buttonRefresh(interaction, path),
|
||||
await getBlitzStarsLinkButton(player.region, player.id),
|
||||
];
|
||||
},
|
||||
@@ -333,7 +340,12 @@ export const breakdownCommand = new Promise<CommandRegistryRaw>(
|
||||
const period = resolvePeriodFromButton(player.region, interaction);
|
||||
const filters = getFiltersFromButton(interaction);
|
||||
|
||||
return await render(player, period, filters);
|
||||
return await renderBreakdown(
|
||||
player,
|
||||
period,
|
||||
filters,
|
||||
interaction.locale,
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
+32
-28
@@ -1,36 +1,40 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import packageJSON from '../../package.json' assert { type: 'json' };
|
||||
import { client } from '../core/discord/client';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
const executionStart = new Date().getTime();
|
||||
|
||||
export const debugCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
export const debugCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('debug')
|
||||
.setDescription('Debug information about the bot'),
|
||||
command: createLocalizedCommand('debug'),
|
||||
|
||||
async handler(interaction) {
|
||||
const currentTime = new Date().getTime();
|
||||
const uptime = currentTime - executionStart;
|
||||
const list = [
|
||||
['Version', packageJSON.version],
|
||||
['Shard', client.shard?.ids[0] ?? 'default'],
|
||||
[
|
||||
'Uptime',
|
||||
`${Math.floor((uptime / 1000 / 60 / 60) % 24)}h ${Math.floor(
|
||||
(uptime / 1000 / 60) % 60,
|
||||
)}m ${Math.floor((uptime / 1000) % 60)}s ${Math.floor(
|
||||
uptime % 1000,
|
||||
)}ms`,
|
||||
],
|
||||
]
|
||||
.map(([key, value]) => `- ${key}: ${value}`)
|
||||
.join('\n');
|
||||
|
||||
return `# Debug\n\n${list}`;
|
||||
},
|
||||
};
|
||||
async handler(interaction) {
|
||||
const { t } = translator(interaction.locale);
|
||||
const currentTime = new Date().getTime();
|
||||
const uptime = currentTime - executionStart;
|
||||
const list = [
|
||||
[t`bot.commands.debug.body.version`, packageJSON.version],
|
||||
[
|
||||
t`bot.commands.debug.body.shard`,
|
||||
client.shard?.ids[0] ?? t`bot.commands.debug.body.shard.default`,
|
||||
],
|
||||
[
|
||||
t`bot.commands.debug.body.uptime`,
|
||||
`${Math.floor((uptime / 1000 / 60 / 60) % 24)}h ${Math.floor(
|
||||
(uptime / 1000 / 60) % 60,
|
||||
)}m ${Math.floor((uptime / 1000) % 60)}s ${Math.floor(
|
||||
uptime % 1000,
|
||||
)}ms`,
|
||||
],
|
||||
];
|
||||
return t`bot.commands.debug.body.title${list
|
||||
.map(([key, value]) => `- ${key}: ${value}`)
|
||||
.join('\n')}`;
|
||||
},
|
||||
});
|
||||
});
|
||||
+185
-178
@@ -1,198 +1,205 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import getPeriodNow from '../core/blitzkrieg/getPeriodNow';
|
||||
import getTimeDaysAgo from '../core/blitzkrieg/getTimeDaysAgo';
|
||||
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
|
||||
import { tankAverages } from '../core/blitzstars/tankAverages';
|
||||
import { tankopedia } from '../core/blitzstars/tankopedia';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import embedNegative from '../core/discord/embedNegative';
|
||||
import embedPositive from '../core/discord/embedPositive';
|
||||
import markdownTable, { TableInput } from '../core/discord/markdownTable';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import calculateWN8 from '../core/statistics/calculateWN8';
|
||||
import sumStats from '../core/statistics/sumStats';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { PossiblyPromise } from '../types/possiblyPromise';
|
||||
// _____ _____ _ _ _ _ _____ ____ __ __ ______ ____ _____ _ __
|
||||
// |_ _| / ____| | | | /\ | | | | / ____/ __ \| \/ | ____| | _ \ /\ / ____| |/ /
|
||||
// | | | (___ | |__| | / \ | | | | | | | | | | \ / | |__ | |_) | / \ | | | ' /
|
||||
// | | \___ \| __ | / /\ \ | | | | | | | | | | |\/| | __| | _ < / /\ \| | | <
|
||||
// _| |_ ____) | | | |/ ____ \| |____| |____ | |___| |__| | | | | |____ | |_) / ____ \ |____| . \
|
||||
// |_____| |_____/|_| |_/_/ \_\______|______| \_____\____/|_| |_|______| |____/_/ \_\_____|_|\_\
|
||||
|
||||
export type SkilledClan = 'SKLLD' | 'SMRI';
|
||||
// import { SlashCommandBuilder } from 'discord.js';
|
||||
// import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
// import getPeriodNow from '../core/blitzkrieg/getPeriodNow';
|
||||
// import getTimeDaysAgo from '../core/blitzkrieg/getTimeDaysAgo';
|
||||
// import { tankDefinitions } from '../core/blitzkrieg/tankDefinitions';
|
||||
// import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
|
||||
// import { tankAverages } from '../core/blitzstars/tankAverages';
|
||||
// import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
// import embedNegative from '../core/discord/embedNegative';
|
||||
// import embedPositive from '../core/discord/embedPositive';
|
||||
// import markdownTable, { TableInput } from '../core/discord/markdownTable';
|
||||
// import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
// import calculateWN8 from '../core/statistics/calculateWN8';
|
||||
// import sumStats from '../core/statistics/sumStats';
|
||||
// import { CommandRegistry } from '../events/interactionCreate';
|
||||
// import { PossiblyPromise } from '../types/possiblyPromise';
|
||||
|
||||
export const SKILLED_CLANS: Record<SkilledClan, string> = {
|
||||
SKLLD: 'Skilled',
|
||||
SMRI: 'Samurai',
|
||||
};
|
||||
// export type SkilledClan = 'SKLLD' | 'SMRI';
|
||||
|
||||
export const eligibleCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: false,
|
||||
// export const SKILLED_CLANS: Record<SkilledClan, string> = {
|
||||
// SKLLD: 'Skilled',
|
||||
// SMRI: 'Samurai',
|
||||
// };
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('eligible')
|
||||
.setDescription('Checks eligibility for any Skilled clan')
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('clan')
|
||||
.setDescription('The clan to check')
|
||||
.setChoices(
|
||||
{
|
||||
name: SKILLED_CLANS.SKLLD,
|
||||
value: 'SKLLD' satisfies SkilledClan,
|
||||
},
|
||||
{ name: SKILLED_CLANS.SMRI, value: 'SMRI' satisfies SkilledClan },
|
||||
)
|
||||
.setRequired(true),
|
||||
)
|
||||
.addStringOption(addUsernameChoices),
|
||||
// export const eligibleCommand: CommandRegistry = {
|
||||
// inProduction: true,
|
||||
// inPublic: false,
|
||||
|
||||
async handler(interaction) {
|
||||
const clan = interaction.options.getString('clan') as SkilledClan;
|
||||
const { id, region: region } = await resolvePlayerFromCommand(interaction);
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const problems: TableInput = [];
|
||||
const title = `${accountInfo.nickname}'s eligibility for ${SKILLED_CLANS[clan]}`;
|
||||
let isEligible = false;
|
||||
let body = '';
|
||||
// command: new SlashCommandBuilder()
|
||||
// .setName('eligible')
|
||||
// .setDescription('Checks eligibility for any Skilled clan')
|
||||
// .addStringOption((option) =>
|
||||
// option
|
||||
// .setName('clan')
|
||||
// .setDescription('The clan to check')
|
||||
// .setChoices(
|
||||
// {
|
||||
// name: SKILLED_CLANS.SKLLD,
|
||||
// value: 'SKLLD' satisfies SkilledClan,
|
||||
// },
|
||||
// { name: SKILLED_CLANS.SMRI, value: 'SMRI' satisfies SkilledClan },
|
||||
// )
|
||||
// .setRequired(true),
|
||||
// )
|
||||
// .addStringOption(addUsernameChoices),
|
||||
|
||||
if (clan === 'SKLLD') {
|
||||
const { diff: diffed } = await getStatsInPeriod(
|
||||
region,
|
||||
id,
|
||||
getTimeDaysAgo(region, 30),
|
||||
getPeriodNow(),
|
||||
);
|
||||
const entries = Object.entries(diffed);
|
||||
const stats = sumStats(entries.map(([, stats]) => stats));
|
||||
const averageTier =
|
||||
(await entries.reduce<PossiblyPromise<number>>(
|
||||
async (accumulator, [tankIdString, stats]) => {
|
||||
const tankId = parseInt(tankIdString);
|
||||
const tankopediaEntry = (await tankopedia)[tankId];
|
||||
// async handler(interaction) {
|
||||
// const clan = interaction.options.getString('clan') as SkilledClan;
|
||||
// const { id, region: region } = await resolvePlayerFromCommand(interaction);
|
||||
// const accountInfo = await getAccountInfo(region, id);
|
||||
// const problems: TableInput = [];
|
||||
// const title = `${accountInfo.nickname}'s eligibility for ${SKILLED_CLANS[clan]}`;
|
||||
// let isEligible = false;
|
||||
// let body = '';
|
||||
|
||||
if (!tankopediaEntry) return accumulator;
|
||||
// if (clan === 'SKLLD') {
|
||||
// const { diff: diffed } = await getStatsInPeriod(
|
||||
// region,
|
||||
// id,
|
||||
// getTimeDaysAgo(region, 30),
|
||||
// getPeriodNow(),
|
||||
// );
|
||||
// const entries = Object.entries(diffed);
|
||||
// const stats = sumStats(entries.map(([, stats]) => stats));
|
||||
// const averageTier =
|
||||
// (await entries.reduce<PossiblyPromise<number>>(
|
||||
// async (accumulator, [tankIdString, stats]) => {
|
||||
// const tankId = parseInt(tankIdString);
|
||||
// const tankDefinition = (await tankDefinitions)[tankId];
|
||||
|
||||
const tankTier = tankopediaEntry.tier;
|
||||
// if (!tankDefinition) return accumulator;
|
||||
|
||||
return (await accumulator) + tankTier * stats.battles;
|
||||
},
|
||||
0,
|
||||
)) /
|
||||
(await entries.reduce<PossiblyPromise<number>>(
|
||||
async (accumulator, [tankIdString, stats]) => {
|
||||
const tankId = parseInt(tankIdString);
|
||||
const tankopediaEntry = (await tankopedia)[tankId];
|
||||
// const tankTier = tankDefinition.tier;
|
||||
|
||||
if (!tankopediaEntry) return accumulator;
|
||||
// return (await accumulator) + tankTier * stats.battles;
|
||||
// },
|
||||
// 0,
|
||||
// )) /
|
||||
// (await entries.reduce<PossiblyPromise<number>>(
|
||||
// async (accumulator, [tankIdString, stats]) => {
|
||||
// const tankId = parseInt(tankIdString);
|
||||
// const tankDefinition = (await tankDefinitions)[tankId];
|
||||
|
||||
return stats.battles;
|
||||
},
|
||||
0,
|
||||
));
|
||||
const WN8 =
|
||||
(await entries.reduce<PossiblyPromise<number>>(
|
||||
async (accumulator, [tankIdString, stats]) => {
|
||||
const tankId = parseInt(tankIdString);
|
||||
// if (!tankDefinition) return accumulator;
|
||||
|
||||
// edge case where new tanks don't have averages
|
||||
if ((await tankAverages)[tankId]) {
|
||||
const tankWN8 = calculateWN8(
|
||||
(await tankAverages)[tankId].all,
|
||||
stats,
|
||||
);
|
||||
// return stats.battles;
|
||||
// },
|
||||
// 0,
|
||||
// ));
|
||||
// const WN8 =
|
||||
// (await entries.reduce<PossiblyPromise<number>>(
|
||||
// async (accumulator, [tankIdString, stats]) => {
|
||||
// const tankId = parseInt(tankIdString);
|
||||
|
||||
if (isNaN(tankWN8)) return accumulator;
|
||||
return (await accumulator) + tankWN8 * stats.battles;
|
||||
} else return accumulator;
|
||||
},
|
||||
0,
|
||||
)) / stats.battles;
|
||||
const tier10Damage =
|
||||
(await entries.reduce<PossiblyPromise<number>>(
|
||||
async (accumulator, [tankIdString, stats]) => {
|
||||
const tankId = parseInt(tankIdString);
|
||||
// // edge case where new tanks don't have averages
|
||||
// if ((await tankAverages)[tankId]) {
|
||||
// const tankWN8 = calculateWN8(
|
||||
// (await tankAverages)[tankId].all,
|
||||
// stats,
|
||||
// );
|
||||
|
||||
if ((await tankopedia)[tankId]?.tier === 10) {
|
||||
const tankStats = diffed[tankId];
|
||||
return (await accumulator) + tankStats.damage_dealt;
|
||||
} else return accumulator;
|
||||
},
|
||||
0,
|
||||
)) /
|
||||
(await entries.reduce<PossiblyPromise<number>>(
|
||||
async (accumulator, [tankIdString, stats]) => {
|
||||
const tankId = parseInt(tankIdString);
|
||||
// if (isNaN(tankWN8)) return accumulator;
|
||||
// return (await accumulator) + tankWN8 * stats.battles;
|
||||
// } else return accumulator;
|
||||
// },
|
||||
// 0,
|
||||
// )) / stats.battles;
|
||||
// const tier10Damage =
|
||||
// (await entries.reduce<PossiblyPromise<number>>(
|
||||
// async (accumulator, [tankIdString, stats]) => {
|
||||
// const tankId = parseInt(tankIdString);
|
||||
|
||||
if ((await tankopedia)[tankId]?.tier === 10) {
|
||||
const tankStats = diffed[tankId];
|
||||
return (await accumulator) + tankStats.battles;
|
||||
} else return accumulator;
|
||||
},
|
||||
0,
|
||||
));
|
||||
// if ((await tankDefinitions)[tankId]?.tier === 10) {
|
||||
// const tankStats = diffed[tankId];
|
||||
// return (await accumulator) + tankStats.damage_dealt;
|
||||
// } else return accumulator;
|
||||
// },
|
||||
// 0,
|
||||
// )) /
|
||||
// (await entries.reduce<PossiblyPromise<number>>(
|
||||
// async (accumulator, [tankIdString, stats]) => {
|
||||
// const tankId = parseInt(tankIdString);
|
||||
|
||||
if (stats.battles < 126) {
|
||||
problems.push(['Battles', `${stats.battles} (${stats.battles - 126})`]);
|
||||
}
|
||||
if (averageTier < 8) {
|
||||
problems.push([
|
||||
'Average tier',
|
||||
`${averageTier.toFixed(2)} (${(averageTier - 8).toFixed(2)})`,
|
||||
]);
|
||||
}
|
||||
if (stats.wins / stats.battles < 0.6) {
|
||||
problems.push([
|
||||
'Winrate',
|
||||
`${(100 * (stats.wins / stats.battles)).toFixed(2)}% (${(
|
||||
100 *
|
||||
(stats.wins / stats.battles - 0.6)
|
||||
).toFixed(2)}%)`,
|
||||
]);
|
||||
}
|
||||
if (WN8 < 2450) {
|
||||
problems.push([
|
||||
'WN8',
|
||||
`${WN8.toFixed(0)} (${(WN8 - 2450).toFixed(0)})`,
|
||||
]);
|
||||
}
|
||||
if (tier10Damage < 1250) {
|
||||
problems.push([
|
||||
'Tier 10 damage',
|
||||
`${tier10Damage.toFixed(0)} (${tier10Damage - 1250})`,
|
||||
]);
|
||||
}
|
||||
// if ((await tankDefinitions)[tankId]?.tier === 10) {
|
||||
// const tankStats = diffed[tankId];
|
||||
// return (await accumulator) + tankStats.battles;
|
||||
// } else return accumulator;
|
||||
// },
|
||||
// 0,
|
||||
// ));
|
||||
|
||||
isEligible = problems.length === 0;
|
||||
body = isEligible
|
||||
? 'Player is fully eligible.'
|
||||
: `Player is not eligible. Shortcomings in 30-day statistics:\n\n${markdownTable(
|
||||
problems,
|
||||
)}`;
|
||||
} else if (clan === 'SMRI') {
|
||||
const stats = accountInfo.statistics.all;
|
||||
// if (stats.battles < 126) {
|
||||
// problems.push(['Battles', `${stats.battles} (${stats.battles - 126})`]);
|
||||
// }
|
||||
// if (averageTier < 8) {
|
||||
// problems.push([
|
||||
// 'Average tier',
|
||||
// `${averageTier.toFixed(2)} (${(averageTier - 8).toFixed(2)})`,
|
||||
// ]);
|
||||
// }
|
||||
// if (stats.wins / stats.battles < 0.6) {
|
||||
// problems.push([
|
||||
// 'Winrate',
|
||||
// `${(100 * (stats.wins / stats.battles)).toFixed(2)}% (${(
|
||||
// 100 *
|
||||
// (stats.wins / stats.battles - 0.6)
|
||||
// ).toFixed(2)}%)`,
|
||||
// ]);
|
||||
// }
|
||||
// if (WN8 < 2450) {
|
||||
// problems.push([
|
||||
// 'WN8',
|
||||
// `${WN8.toFixed(0)} (${(WN8 - 2450).toFixed(0)})`,
|
||||
// ]);
|
||||
// }
|
||||
// if (tier10Damage < 1250) {
|
||||
// problems.push([
|
||||
// 'Tier 10 damage',
|
||||
// `${tier10Damage.toFixed(0)} (${tier10Damage - 1250})`,
|
||||
// ]);
|
||||
// }
|
||||
|
||||
if (stats.battles < 1500) {
|
||||
problems.push([
|
||||
'Battles',
|
||||
`${stats.battles} (${stats.battles - 1500})`,
|
||||
]);
|
||||
}
|
||||
if (stats.wins / stats.battles <= 0.53) {
|
||||
problems.push([
|
||||
'Winrate',
|
||||
`${(100 * (stats.wins / stats.battles)).toFixed(2)}% (${(
|
||||
100 *
|
||||
(stats.wins / stats.battles - 0.53)
|
||||
).toFixed(2)}%)`,
|
||||
]);
|
||||
}
|
||||
// isEligible = problems.length === 0;
|
||||
// body = isEligible
|
||||
// ? 'Player is fully eligible.'
|
||||
// : `Player is not eligible. Shortcomings in 30-day statistics:\n\n${markdownTable(
|
||||
// problems,
|
||||
// )}`;
|
||||
// } else if (clan === 'SMRI') {
|
||||
// const stats = accountInfo.statistics.all;
|
||||
|
||||
isEligible = problems.length === 0;
|
||||
body = isEligible
|
||||
? 'Player is fully eligible.'
|
||||
: `Player is not eligible. Shortcomings in career statistics:\n\n${markdownTable(
|
||||
problems,
|
||||
)}`;
|
||||
}
|
||||
// if (stats.battles < 1500) {
|
||||
// problems.push([
|
||||
// 'Battles',
|
||||
// `${stats.battles} (${stats.battles - 1500})`,
|
||||
// ]);
|
||||
// }
|
||||
// if (stats.wins / stats.battles <= 0.53) {
|
||||
// problems.push([
|
||||
// 'Winrate',
|
||||
// `${(100 * (stats.wins / stats.battles)).toFixed(2)}% (${(
|
||||
// 100 *
|
||||
// (stats.wins / stats.battles - 0.53)
|
||||
// ).toFixed(2)}%)`,
|
||||
// ]);
|
||||
// }
|
||||
|
||||
return (isEligible ? embedPositive : embedNegative)(title, body);
|
||||
},
|
||||
};
|
||||
// isEligible = problems.length === 0;
|
||||
// body = isEligible
|
||||
// ? 'Player is fully eligible.'
|
||||
// : `Player is not eligible. Shortcomings in career statistics:\n\n${markdownTable(
|
||||
// problems,
|
||||
// )}`;
|
||||
// }
|
||||
|
||||
// return (isEligible ? embedPositive : embedNegative)(title, body);
|
||||
// },
|
||||
// };
|
||||
+48
-52
@@ -1,11 +1,8 @@
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
SlashCommandSubcommandGroupBuilder,
|
||||
} from 'discord.js';
|
||||
import { Locale, SlashCommandSubcommandGroupBuilder } from 'discord.js';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import * as Graph from '../components/Graph';
|
||||
import NoData, { NoDataType } from '../components/NoData';
|
||||
import NoData from '../components/NoData';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import resolveTankId from '../core/blitz/resolveTankId';
|
||||
@@ -13,13 +10,14 @@ import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
|
||||
import getTankHistories from '../core/blitzkrieg/getTankHistories';
|
||||
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
||||
import getPlayerHistories from '../core/blitzstars/getPlayerHistories';
|
||||
import addPeriodSubCommands from '../core/discord/addPeriodSubCommands';
|
||||
import { addPeriodSubCommands } from '../core/discord/addPeriodSubCommands';
|
||||
import addTankChoices from '../core/discord/addTankChoices';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteTanks from '../core/discord/autocompleteTanks';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import buttonPrimary from '../core/discord/buttonPrimary';
|
||||
import { buttonRefresh } from '../core/discord/buttonRefresh';
|
||||
import commandToURL from '../core/discord/commandToURL';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
|
||||
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
|
||||
import resolvePeriodFromCommand, {
|
||||
@@ -29,7 +27,7 @@ import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
|
||||
import resolvePlayerFromCommand, {
|
||||
ResolvedPlayer,
|
||||
} from '../core/discord/resolvePlayerFromCommand';
|
||||
import { CommandRegistryRaw } from '../events/interactionCreate';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
type EvolutionStatType = 'player' | 'tank';
|
||||
|
||||
@@ -38,6 +36,7 @@ async function render(
|
||||
{ start, end, name }: ResolvedPeriod,
|
||||
type: 'player' | 'tank',
|
||||
tankId: number | null,
|
||||
locale: Locale,
|
||||
) {
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||
@@ -49,7 +48,7 @@ async function render(
|
||||
includeLatestHistories: true,
|
||||
includePreviousHistories: true,
|
||||
})
|
||||
: getTankHistories(region, id, {
|
||||
: getTankHistories(region, id, locale, {
|
||||
tankId: tankId!,
|
||||
start,
|
||||
end,
|
||||
@@ -74,8 +73,8 @@ async function render(
|
||||
const minX = Math.min(...xs);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<TitleBar name={accountInfo.nickname} image={logo} description={name} />
|
||||
<CommandWrapper>
|
||||
<TitleBar title={accountInfo.nickname} image={logo} description={name} />
|
||||
|
||||
{plot.length > 0 && (
|
||||
<Graph.Root
|
||||
@@ -90,8 +89,8 @@ async function render(
|
||||
max: maxX,
|
||||
precision: 0,
|
||||
}}
|
||||
xMinLabel={new Date(minTime * 1000).toDateString()}
|
||||
xMaxLabel={new Date(maxTime * 1000).toDateString()}
|
||||
xMinLabel={new Date(minTime * 1000).toLocaleDateString(locale)}
|
||||
xMaxLabel={new Date(maxTime * 1000).toLocaleDateString(locale)}
|
||||
>
|
||||
{Graph.Line({
|
||||
color: Graph.LineColor.Red,
|
||||
@@ -102,47 +101,37 @@ async function render(
|
||||
</Graph.Root>
|
||||
)}
|
||||
|
||||
{plot.length === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
|
||||
</Wrapper>
|
||||
{plot.length === 0 && <NoData type="battles_in_period" locale={locale} />}
|
||||
</CommandWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const evolutionCommand = new Promise<CommandRegistryRaw>(
|
||||
export const evolutionCommand = new Promise<CommandRegistry>(
|
||||
async (resolve) => {
|
||||
let playerGroup: SlashCommandSubcommandGroupBuilder;
|
||||
let tankGroup: SlashCommandSubcommandGroupBuilder;
|
||||
const command = new SlashCommandBuilder()
|
||||
.setName('evolution')
|
||||
.setDescription('Evolution of statistics')
|
||||
.addSubcommandGroup((option) => {
|
||||
playerGroup = option
|
||||
.setName('player')
|
||||
.setDescription('Player statistics');
|
||||
|
||||
return playerGroup;
|
||||
})
|
||||
.addSubcommandGroup((option) => {
|
||||
tankGroup = option.setName('tank').setDescription('Tank statistics');
|
||||
|
||||
return tankGroup;
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
addPeriodSubCommands(playerGroup!, (option) =>
|
||||
option.addStringOption(addUsernameChoices),
|
||||
),
|
||||
addPeriodSubCommands(tankGroup!, (option) =>
|
||||
option
|
||||
.addStringOption(addTankChoices)
|
||||
.addStringOption(addUsernameChoices),
|
||||
),
|
||||
]);
|
||||
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command,
|
||||
command: createLocalizedCommand('evolution', [
|
||||
{
|
||||
group: 'player',
|
||||
modify(option: SlashCommandSubcommandGroupBuilder) {
|
||||
addPeriodSubCommands(option, (option) =>
|
||||
option.addStringOption(addUsernameChoices),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'tank',
|
||||
modify(option: SlashCommandSubcommandGroupBuilder) {
|
||||
addPeriodSubCommands(option, (option) =>
|
||||
option
|
||||
.addStringOption(addTankChoices)
|
||||
.addStringOption(addUsernameChoices),
|
||||
);
|
||||
},
|
||||
},
|
||||
]),
|
||||
|
||||
async handler(interaction) {
|
||||
const commandGroup =
|
||||
@@ -151,18 +140,24 @@ export const evolutionCommand = new Promise<CommandRegistryRaw>(
|
||||
const period = resolvePeriodFromCommand(player.region, interaction);
|
||||
const tankIdRaw = interaction.options.getString('tank')!;
|
||||
const tankId =
|
||||
commandGroup === 'tank' ? await resolveTankId(tankIdRaw) : null;
|
||||
commandGroup === 'tank'
|
||||
? await resolveTankId(tankIdRaw, interaction.locale)
|
||||
: null;
|
||||
const path = commandToURL(interaction, {
|
||||
...player,
|
||||
...getCustomPeriodParams(interaction),
|
||||
tankId,
|
||||
});
|
||||
|
||||
getBlitzStarsLinkButton;
|
||||
|
||||
return [
|
||||
await render(player, period, commandGroup, tankId),
|
||||
buttonPrimary(path, 'Refresh'),
|
||||
await render(
|
||||
player,
|
||||
period,
|
||||
commandGroup,
|
||||
tankId,
|
||||
interaction.locale,
|
||||
),
|
||||
buttonRefresh(interaction, path),
|
||||
await getBlitzStarsLinkButton(
|
||||
player.region,
|
||||
player.id,
|
||||
@@ -188,6 +183,7 @@ export const evolutionCommand = new Promise<CommandRegistryRaw>(
|
||||
period,
|
||||
commandGroup,
|
||||
parseInt(url.searchParams.get('tankId') ?? '0') || null,
|
||||
interaction.locale,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
+33
-23
@@ -1,21 +1,22 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { Locale } from 'discord.js';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import GenericAllStats from '../components/GenericAllStats';
|
||||
import NoData, { NoDataType } from '../components/NoData';
|
||||
import NoData from '../components/NoData';
|
||||
import TierWeights from '../components/TierWeights';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
|
||||
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
|
||||
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
||||
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
|
||||
import addFilterOptions from '../core/discord/addFilterOptions';
|
||||
import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteTanks from '../core/discord/autocompleteTanks';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { buttonRefresh } from '../core/discord/buttonRefresh';
|
||||
import commandToURL from '../core/discord/commandToURL';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
|
||||
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
|
||||
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
|
||||
@@ -29,55 +30,64 @@ import resolvePlayerFromCommand, {
|
||||
} from '../core/discord/resolvePlayerFromCommand';
|
||||
import { StatFilters, filterStats } from '../core/statistics/filterStats';
|
||||
import { getTierWeights } from '../core/statistics/getTierWeights';
|
||||
import { CommandRegistryRaw } from '../events/interactionCreate';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
async function render(
|
||||
{ region, id }: ResolvedPlayer,
|
||||
{ start, end, name }: ResolvedPeriod,
|
||||
filters: StatFilters,
|
||||
locale: Locale,
|
||||
) {
|
||||
const { nickname } = await getAccountInfo(region, id);
|
||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined;
|
||||
const diffedTankStats = await getStatsInPeriod(region, id, start, end);
|
||||
const diffedTankStats = await getStatsInPeriod(
|
||||
region,
|
||||
id,
|
||||
start,
|
||||
end,
|
||||
locale,
|
||||
);
|
||||
const { stats, supplementary, filteredOrder } = await filterStats(
|
||||
diffedTankStats,
|
||||
filters,
|
||||
);
|
||||
const filterDescriptions = await filtersToDescription(filters);
|
||||
const filterDescriptions = await filtersToDescription(filters, locale);
|
||||
const tierWeights = await getTierWeights(diffedTankStats.diff, filteredOrder);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
name={nickname}
|
||||
title={nickname}
|
||||
image={clanImage}
|
||||
description={`${name} • ${filterDescriptions}`}
|
||||
/>
|
||||
|
||||
{stats.battles === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
|
||||
{stats.battles === 0 && (
|
||||
<NoData type="battles_in_period" locale={locale} />
|
||||
)}
|
||||
{stats.battles > 0 && <TierWeights weights={tierWeights!} />}
|
||||
{stats.battles > 0 && (
|
||||
<GenericAllStats stats={stats} supplementaryStats={supplementary} />
|
||||
<GenericAllStats
|
||||
stats={stats}
|
||||
supplementaryStats={supplementary}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</Wrapper>
|
||||
</CommandWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const fullStatsCommand = new Promise<CommandRegistryRaw>(
|
||||
export const fullStatsCommand = new Promise<CommandRegistry>(
|
||||
async (resolve) => {
|
||||
const command = await addFilterOptions(
|
||||
new SlashCommandBuilder()
|
||||
.setName('full-stats')
|
||||
.setDescription('Full in-game statistics'),
|
||||
(option) => option.addStringOption(addUsernameChoices),
|
||||
);
|
||||
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command,
|
||||
command: await addPeriodicFilterOptions(
|
||||
createLocalizedCommand('full-stats'),
|
||||
(option) => option.addStringOption(addUsernameChoices),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const player = await resolvePlayerFromCommand(interaction);
|
||||
@@ -90,7 +100,7 @@ export const fullStatsCommand = new Promise<CommandRegistryRaw>(
|
||||
});
|
||||
|
||||
return Promise.all([
|
||||
render(player, period, filters),
|
||||
render(player, period, filters, interaction.locale),
|
||||
buttonRefresh(interaction, path),
|
||||
getBlitzStarsLinkButton(player.region, player.id),
|
||||
]);
|
||||
@@ -106,7 +116,7 @@ export const fullStatsCommand = new Promise<CommandRegistryRaw>(
|
||||
const period = resolvePeriodFromButton(player.region, interaction);
|
||||
const filters = getFiltersFromButton(interaction);
|
||||
|
||||
return await render(player, period, filters);
|
||||
return await render(player, period, filters, interaction.locale);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
+75
-53
@@ -1,69 +1,91 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { Locale } from 'discord.js';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import GenericStats from '../components/GenericStats';
|
||||
import NoData, { NoDataType } from '../components/NoData';
|
||||
import NoData from '../components/NoData';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanInfo } from '../core/blitz/getClanInfo';
|
||||
import addClanChoices from '../core/discord/addClanChoices';
|
||||
import autocompleteClan from '../core/discord/autocompleteClan';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { localizationObject } from '../core/discord/localizationObject';
|
||||
import resolveClanFromCommand from '../core/discord/resolveClanFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
const DEFAULT_THRESHOLD = 7;
|
||||
|
||||
export const inactiveCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
export const inactiveCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('inactive')
|
||||
.setDescription('Lists all inactive players')
|
||||
.addStringOption(addClanChoices)
|
||||
.addNumberOption((option) =>
|
||||
option
|
||||
.setName('threshold')
|
||||
.setDescription(
|
||||
`The number of days inactive (default: ${DEFAULT_THRESHOLD})`,
|
||||
command: createLocalizedCommand('inactive')
|
||||
.addStringOption(addClanChoices)
|
||||
.addNumberOption((option) => {
|
||||
const { translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName('threshold')
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.commands.inactive.options.threshold'),
|
||||
)
|
||||
.setDescription(
|
||||
translate('bot.commands.inactive.options.threshold.description'),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.inactive.options.threshold.description',
|
||||
),
|
||||
)
|
||||
.setMinValue(0);
|
||||
}),
|
||||
|
||||
async handler(interaction) {
|
||||
const { translate } = translator(interaction.locale);
|
||||
const { region, id } = await resolveClanFromCommand(interaction);
|
||||
const threshold =
|
||||
interaction.options.getNumber('threshold')! ?? DEFAULT_THRESHOLD;
|
||||
const time = new Date().getTime() / 1000;
|
||||
const clanInfo = await getClanInfo(region, id);
|
||||
const accountInfo = await getAccountInfo(region, clanInfo.members_ids);
|
||||
const inactive = accountInfo
|
||||
.map(
|
||||
(account) =>
|
||||
[
|
||||
account.nickname,
|
||||
(time - account.last_battle_time) / 60 / 60 / 24,
|
||||
] as [string, number],
|
||||
)
|
||||
.setMinValue(0),
|
||||
),
|
||||
.filter(([, inactiveDays]) => inactiveDays >= threshold)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(
|
||||
([name, days]) =>
|
||||
[
|
||||
name,
|
||||
translate('bot.commands.inactive.body.listing', [
|
||||
days.toFixed(0),
|
||||
]),
|
||||
] as [string, string],
|
||||
);
|
||||
const hasInactiveMembers = inactive.length > 0;
|
||||
|
||||
async handler(interaction) {
|
||||
const { region, id } = await resolveClanFromCommand(interaction);
|
||||
const threshold =
|
||||
interaction.options.getNumber('threshold')! ?? DEFAULT_THRESHOLD;
|
||||
const time = new Date().getTime() / 1000;
|
||||
const clanInfo = await getClanInfo(region, id);
|
||||
const accountInfo = await getAccountInfo(region, clanInfo.members_ids);
|
||||
const inactive = accountInfo
|
||||
.map(
|
||||
(account) =>
|
||||
[
|
||||
account.nickname,
|
||||
(time - account.last_battle_time) / 60 / 60 / 24,
|
||||
] as [string, number],
|
||||
)
|
||||
.filter(([, inactiveDays]) => inactiveDays >= threshold)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(
|
||||
([name, days]) => [name, `${days.toFixed(0)} days`] as [string, string],
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={clanInfo.name}
|
||||
image={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanInfo.emblem_set_id}.png`}
|
||||
description={`${translate('bot.commands.inactive.body.subtitle', [`${threshold}`])} • ${new Date().toLocaleDateString(interaction.locale)}`}
|
||||
/>
|
||||
|
||||
{!hasInactiveMembers && (
|
||||
<NoData type="players_in_period" locale={interaction.locale} />
|
||||
)}
|
||||
{hasInactiveMembers && <GenericStats stats={inactive} />}
|
||||
</CommandWrapper>
|
||||
);
|
||||
const hasInactiveMembers = inactive.length > 0;
|
||||
},
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<TitleBar
|
||||
name={clanInfo.name}
|
||||
image={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanInfo.emblem_set_id}.png`}
|
||||
description={`Inactive for ${threshold}+ Days • ${new Date().toDateString()}`}
|
||||
/>
|
||||
|
||||
{!hasInactiveMembers && <NoData type={NoDataType.PlayersInPeriod} />}
|
||||
{hasInactiveMembers && <GenericStats stats={inactive} />}
|
||||
</Wrapper>
|
||||
);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteClan,
|
||||
};
|
||||
autocomplete: autocompleteClan,
|
||||
});
|
||||
});
|
||||
+45
-48
@@ -1,53 +1,51 @@
|
||||
import { teal } from '@radix-ui/colors';
|
||||
import {
|
||||
AttachmentBuilder,
|
||||
GuildMemberRoleManager,
|
||||
SlashCommandBuilder,
|
||||
} from 'discord.js';
|
||||
import { GuildMemberRoleManager } from 'discord.js';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import discord from '../../discord.json' assert { type: 'json' };
|
||||
import { Glow } from '../components/AllStatsOverview/components/WN8Display/components/Glow';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { REGION_NAMES_SHORT, Region } from '../constants/regions';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitz';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import embedInfo from '../core/discord/embedInfo';
|
||||
import embedNegative from '../core/discord/embedNegative';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
const serverAndIdPattern = /(com|eu|asia)\/[0-9]+/;
|
||||
export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
export const verifyCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
command: createLocalizedCommand('link').addStringOption((option) =>
|
||||
addUsernameChoices(option).setRequired(true),
|
||||
),
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('link')
|
||||
.setDescription('Links your Blitz and Discord account')
|
||||
.addStringOption((option) => addUsernameChoices(option).setRequired(true)),
|
||||
|
||||
async handler(interaction) {
|
||||
const username = interaction.options.getString('username', true);
|
||||
|
||||
if (serverAndIdPattern.test(username)) {
|
||||
const [region, idString] = username.split('/');
|
||||
const id = parseInt(idString);
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const { id, region } = await resolvePlayerFromCommand(interaction);
|
||||
const discordId = parseInt(interaction.user.id);
|
||||
const accountInfo = await getAccountInfo(region as Region, id);
|
||||
const clanAccountInfo = await getClanAccountInfo(region as Region, id, [
|
||||
'clan',
|
||||
]);
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
|
||||
|
||||
await linkBlitzAndDiscord(discordId, region as Region, id);
|
||||
await linkBlitzAndDiscord(discordId, region, id);
|
||||
|
||||
if (interaction.guildId === discord.sklld_guild_id) {
|
||||
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
|
||||
return embedNegative(
|
||||
`${markdownEscape(interaction.user.username)} failed to verify`,
|
||||
"I don't have the permission to change your manage roles.",
|
||||
translate(translate('bot.commands.link.body.no_role_permissions'), [
|
||||
markdownEscape(interaction.user.username),
|
||||
]),
|
||||
translate(
|
||||
translate(
|
||||
'bot.commands.link.body.no_role_permissions.description',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,8 +57,8 @@ export const verifyCommand: CommandRegistry = {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper fat>
|
||||
return [
|
||||
<CommandWrapper fat>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -147,7 +145,9 @@ export const verifyCommand: CommandRegistry = {
|
||||
height: 16,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 16 }}>Accounts linked</span>
|
||||
<span
|
||||
style={{ fontSize: 16 }}
|
||||
>{t`bot.commands.link.body.accounts_linked`}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -196,9 +196,9 @@ export const verifyCommand: CommandRegistry = {
|
||||
}}
|
||||
>
|
||||
{clanAccountInfo?.clan
|
||||
? `[${clanAccountInfo?.clan?.tag}] - `
|
||||
? `[${clanAccountInfo?.clan?.tag}] • `
|
||||
: ''}
|
||||
{REGION_NAMES_SHORT[region as Region]}
|
||||
{translate(`common.regions.normal.${region}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,18 +206,15 @@ export const verifyCommand: CommandRegistry = {
|
||||
|
||||
<Glow color={teal.teal9} rotation={-90} />
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
} else {
|
||||
return [
|
||||
embedNegative(
|
||||
'Please select from the options',
|
||||
'Follow the procedure like the video above. The search results may take a while to show up.',
|
||||
),
|
||||
new AttachmentBuilder('https://i.imgur.com/2p6GFgC.gif'),
|
||||
];
|
||||
}
|
||||
},
|
||||
</CommandWrapper>,
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
};
|
||||
embedInfo(
|
||||
t`bot.commands.link.embed.title`,
|
||||
t`bot.commands.link.embed.description`,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
});
|
||||
+108
-104
@@ -1,129 +1,133 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import NoData, { NoDataType } from '../components/NoData';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import NoData from '../components/NoData';
|
||||
import * as Tanks from '../components/Tanks';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import getTankStats from '../core/blitz/getTankStats';
|
||||
import getTreeType from '../core/blitz/getTreeType';
|
||||
import resolveTankName from '../core/blitz/resolveTankName';
|
||||
import { tankopediaInfo } from '../core/blitz/tankopediaInfo';
|
||||
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
|
||||
import {
|
||||
TIER_ROMAN_NUMERALS,
|
||||
TankopediaEntry,
|
||||
TankDefinition,
|
||||
Tier,
|
||||
tankopedia,
|
||||
} from '../core/blitzstars/tankopedia';
|
||||
tankDefinitions,
|
||||
} from '../core/blitzkrieg/tankDefinitions';
|
||||
import { tankIcon } from '../core/blitzkrieg/tankIcon';
|
||||
import addTierChoices from '../core/discord/addTierChoices';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
export const ownedTanksCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
export const ownedTanksCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('owned-tanks')
|
||||
.setDescription("Shows a player's owned tanks")
|
||||
.addStringOption(addTierChoices)
|
||||
.addStringOption(addUsernameChoices),
|
||||
command: createLocalizedCommand('owned-tanks')
|
||||
.addStringOption(addTierChoices)
|
||||
.addStringOption(addUsernameChoices),
|
||||
|
||||
async handler(interaction) {
|
||||
const tier = Number(interaction.options.getString('tier'));
|
||||
const account = await resolvePlayerFromCommand(interaction);
|
||||
const { id, region: server } = account;
|
||||
const accountInfo = await getAccountInfo(server, id);
|
||||
const tankStats = await getTankStats(server, id);
|
||||
const filteredTanks = (
|
||||
await Promise.all(
|
||||
tankStats.map(async (tankData) => ({
|
||||
tankopedia: (await tankopedia)[tankData.tank_id]!,
|
||||
tank_id: tankData.tank_id,
|
||||
})),
|
||||
)
|
||||
).filter((tank) => tank.tankopedia?.tier === tier);
|
||||
const clanAccountInfo = await getClanAccountInfo(server, id, ['clan']);
|
||||
const groupedTanks: Record<string, TankopediaEntry[]> = {};
|
||||
const nations: string[] = [];
|
||||
async handler(interaction) {
|
||||
const { translate } = translator(interaction.locale);
|
||||
const tier = Number(interaction.options.getString('tier'));
|
||||
const account = await resolvePlayerFromCommand(interaction);
|
||||
const { id, region: server } = account;
|
||||
const accountInfo = await getAccountInfo(server, id);
|
||||
const tankStats = await getTankStats(server, id, interaction.locale);
|
||||
const filteredTanks = (
|
||||
await Promise.all(
|
||||
tankStats.map(async (tankData) => ({
|
||||
tankDefinitions: (await tankDefinitions)[tankData.tank_id]!,
|
||||
id: tankData.tank_id,
|
||||
})),
|
||||
)
|
||||
).filter((tank) => tank.tankDefinitions?.tier === tier);
|
||||
const clanAccountInfo = await getClanAccountInfo(server, id, ['clan']);
|
||||
const groupedTanks: Record<string, TankDefinition[]> = {};
|
||||
const nations: string[] = [];
|
||||
|
||||
filteredTanks.forEach((tank) => {
|
||||
if (groupedTanks[tank.tankopedia.nation] === undefined) {
|
||||
groupedTanks[tank.tankopedia.nation] = [tank.tankopedia];
|
||||
nations.push(tank.tankopedia.nation);
|
||||
} else {
|
||||
groupedTanks[tank.tankopedia.nation].push(tank.tankopedia);
|
||||
}
|
||||
});
|
||||
filteredTanks.forEach((tank) => {
|
||||
if (groupedTanks[tank.tankDefinitions.nation] === undefined) {
|
||||
groupedTanks[tank.tankDefinitions.nation] = [tank.tankDefinitions];
|
||||
nations.push(tank.tankDefinitions.nation);
|
||||
} else {
|
||||
groupedTanks[tank.tankDefinitions.nation].push(tank.tankDefinitions);
|
||||
}
|
||||
});
|
||||
|
||||
nations.sort();
|
||||
nations.sort();
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<TitleBar
|
||||
name={accountInfo.nickname}
|
||||
image={
|
||||
clanAccountInfo?.clan
|
||||
? emblemIdToURL(clanAccountInfo.clan.emblem_set_id)
|
||||
: undefined
|
||||
}
|
||||
description={`Tier ${TIER_ROMAN_NUMERALS[tier as Tier]} tanks`}
|
||||
/>
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={accountInfo.nickname}
|
||||
image={
|
||||
clanAccountInfo?.clan
|
||||
? emblemIdToURL(clanAccountInfo.clan.emblem_set_id)
|
||||
: undefined
|
||||
}
|
||||
description={translate('bot.commands.owned_tanks.body.subtitle', [
|
||||
TIER_ROMAN_NUMERALS[tier as Tier],
|
||||
])}
|
||||
/>
|
||||
|
||||
{filteredTanks.length === 0 && <NoData type={NoDataType.TanksFound} />}
|
||||
{filteredTanks.length === 0 && (
|
||||
<NoData type="tanks_found" locale={interaction.locale} />
|
||||
)}
|
||||
|
||||
{filteredTanks.length > 0 &&
|
||||
(await Promise.all(
|
||||
nations.map(async (nation) => {
|
||||
const tanks = groupedTanks[nation];
|
||||
const leftColumnSize = Math.ceil(tanks.length / 2);
|
||||
const leftColumn = tanks.slice(0, leftColumnSize);
|
||||
const rightColumn = tanks.slice(leftColumnSize);
|
||||
{filteredTanks.length > 0 &&
|
||||
(await Promise.all(
|
||||
nations.map(async (nation) => {
|
||||
const tanks = groupedTanks[nation];
|
||||
const leftColumnSize = Math.ceil(tanks.length / 2);
|
||||
const leftColumn = tanks.slice(0, leftColumnSize);
|
||||
const rightColumn = tanks.slice(leftColumnSize);
|
||||
|
||||
return (
|
||||
<Tanks.Root>
|
||||
<Tanks.Title>
|
||||
{(await tankopediaInfo).vehicle_nations[nation]}
|
||||
</Tanks.Title>
|
||||
return (
|
||||
<Tanks.Root key={nation}>
|
||||
<Tanks.Title>
|
||||
{translate(`common.nations.${nation}`)}
|
||||
</Tanks.Title>
|
||||
|
||||
<Tanks.Row>
|
||||
<Tanks.Column>
|
||||
{await Promise.all(
|
||||
leftColumn.map(async (tank) => (
|
||||
<Tanks.Item
|
||||
key={tank.tank_id}
|
||||
name={await resolveTankName(tank.tank_id)}
|
||||
tankType={tank.type}
|
||||
image={tank.images?.normal}
|
||||
treeType={await getTreeType(tank.tank_id)}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</Tanks.Column>
|
||||
<Tanks.Column>
|
||||
{await Promise.all(
|
||||
rightColumn.map(async (tank) => (
|
||||
<Tanks.Item
|
||||
key={tank.tank_id}
|
||||
name={await resolveTankName(tank.tank_id)}
|
||||
tankType={tank.type}
|
||||
image={tank.images?.normal}
|
||||
treeType={await getTreeType(tank.tank_id)}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</Tanks.Column>
|
||||
</Tanks.Row>
|
||||
</Tanks.Root>
|
||||
);
|
||||
}),
|
||||
))}
|
||||
</Wrapper>
|
||||
);
|
||||
},
|
||||
<Tanks.Row>
|
||||
<Tanks.Column>
|
||||
{await Promise.all(
|
||||
leftColumn.map(async (tank) => (
|
||||
<Tanks.Item
|
||||
key={tank.id}
|
||||
name={tank.name}
|
||||
tankType={tank.class}
|
||||
image={tankIcon(tank.id)}
|
||||
treeType={tank.treeType}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</Tanks.Column>
|
||||
<Tanks.Column>
|
||||
{await Promise.all(
|
||||
rightColumn.map(async (tank) => (
|
||||
<Tanks.Item
|
||||
key={tank.id}
|
||||
name={tank.name}
|
||||
tankType={tank.class}
|
||||
image={tankIcon(tank.id)}
|
||||
treeType={tank.treeType}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</Tanks.Column>
|
||||
</Tanks.Row>
|
||||
</Tanks.Root>
|
||||
);
|
||||
}),
|
||||
))}
|
||||
</CommandWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
};
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
export const permissionsCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command: createLocalizedCommand('permissions'),
|
||||
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
// friday the 13th permissions easter egg
|
||||
const isFridayThe13th =
|
||||
new Date().getDay() === 5 && new Date().getDate() === 13;
|
||||
const permissions = [
|
||||
[
|
||||
interaction.appPermissions?.has('ViewChannel'),
|
||||
t`bot.commands.permissions.body.view_channels`,
|
||||
],
|
||||
[
|
||||
interaction.appPermissions?.has('ReadMessageHistory'),
|
||||
t`bot.commands.permissions.body.read_message_history`,
|
||||
],
|
||||
[
|
||||
interaction.appPermissions?.has('AttachFiles'),
|
||||
t`bot.commands.permissions.body.attach_files`,
|
||||
],
|
||||
];
|
||||
const body = permissions
|
||||
.map(([hasPermission, description]) => {
|
||||
if (hasPermission) {
|
||||
return `${isFridayThe13th ? '😇' : '✅'} ${description}`;
|
||||
} else {
|
||||
return `${isFridayThe13th ? '💀' : '❌'} ${description}`;
|
||||
}
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return translate('bot.commands.permissions.body.title', [body]);
|
||||
},
|
||||
});
|
||||
});
|
||||
+29
-26
@@ -1,32 +1,35 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
export const pingCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
handlesInteraction: true,
|
||||
export const pingCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
handlesInteraction: true,
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('ping')
|
||||
.setDescription('Check if the bot is alive')
|
||||
.addSubcommand((option) =>
|
||||
option.setName('blitzkrieg').setDescription('Ping Blitzkrieg'),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
option.setName('blitz').setDescription('Ping Blitz'),
|
||||
),
|
||||
command: createLocalizedCommand('ping', [
|
||||
{ subcommand: 'blitzkrieg' },
|
||||
{ subcommand: 'wotb' },
|
||||
]),
|
||||
|
||||
async handler(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const executionStart = Date.now();
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const executionStart = Date.now();
|
||||
|
||||
if (subcommand === 'blitzkrieg') {
|
||||
await interaction.editReply('Pong 🏓');
|
||||
} else {
|
||||
await fetch('https://api.wotblitz.com/');
|
||||
}
|
||||
if (subcommand === 'blitzkrieg') {
|
||||
await interaction.editReply(t`bot.commands.ping.body.pong`);
|
||||
} else {
|
||||
await fetch('https://api.wotblitz.com/');
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - executionStart;
|
||||
interaction.editReply(`Pong 🏓 - ${executionTime}ms`);
|
||||
},
|
||||
};
|
||||
const executionTime = Date.now() - executionStart;
|
||||
interaction.editReply(
|
||||
translate('bot.commands.ping.body.pong_with_time', [
|
||||
`${Math.round(executionTime)}`,
|
||||
]),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user