Compare commits

..
500 Commits
Author SHA1 Message Date
TrèsAbhi bfd23db3ff fix: no crons 2024-02-25 20:18:00 -06:00
TrèsAbhi 9c63177f03 fix: all tank icons 2024-02-25 16:09:57 -06:00
TrèsAbhi 24b4e32f50 Merge pull request #79 from tresabhi/1.16.0 2024-02-25 16:01:59 -06:00
TrèsAbhi 2e7bde8861 feat: hide errors 2024-02-25 14:34:06 -06:00
TrèsAbhi 56c626e8a1 fix: remove id lock 2024-02-25 11:57:27 -06:00
TrèsAbhi c12eb9f68a feat: better skyline 2024-02-24 19:34:52 -06:00
TrèsAbhi 8bc94e7c10 feat: donate button 2024-02-24 19:34:02 -06:00
TrèsAbhi e8e6161911 feat: default equipment rammer 2024-02-24 18:49:14 -06:00
TrèsAbhi d4f6b1a003 fix: devkrieg 2024-02-23 18:19:23 -06:00
TrèsAbhi 6cafbc1ffe feat: docs 2024-02-23 18:18:56 -06:00
TrèsAbhi b3336289e8 feat: anylitics 2024-02-23 18:17:17 -06:00
TrèsAbhi 3b92b43cb3 feat: nightly assets 2024-02-23 18:15:46 -06:00
TrèsAbhi 2f3dca8f4c feat: changelogs 2024-02-23 18:07:11 -06:00
TrèsAbhi 0c76f76a5b fix: action branches 2024-02-23 17:07:31 -06:00
TrèsAbhi d37e0390fb fix: ratings command username feild 2024-02-23 17:00:21 -06:00
TrèsAbhi a185dbe644 fix: undefined style 2024-02-23 16:51:04 -06:00
TrèsAbhi 5b9fb1ea00 fix: remove google domain verification file 2024-02-23 16:44:10 -06:00
TrèsAbhi 5106b7c97d fix: version 2024-02-23 16:43:48 -06:00
TrèsAbhi d88cb02d94 fix: a drew redundancies 2024-02-23 16:36:11 -06:00
TrèsAbhi 7fd128ac49 fix: re-enable tankopdia 2024-02-23 16:06:50 -06:00
TrèsAbhi a637dca083 Merge branch 'main' into dev 2024-02-23 16:05:46 -06:00
TrèsAbhi 23a33c7722 Merge branch 'main' into dev 2024-02-23 16:05:29 -06:00
TrèsAbhi 918a0d73b9 fix: ignore yarn 2024-02-23 15:57:50 -06:00
TrèsAbhi ca9f991bcb fix: rename to pushTankopediaPath 2024-02-23 15:54:51 -06:00
TrèsAbhi 2e9388d3c7 fix: no propogation on hull 2024-02-23 15:52:09 -06:00
TrèsAbhi 383196bdd3 feat: clean url handling 2024-02-22 22:07:09 -06:00
TrèsAbhi e69e66f5f7 feat: 8 day dev cooldown 2024-02-22 21:58:03 -06:00
TrèsAbhi b9c5c3c931 feat: better fullscreen 2024-02-22 19:04:44 -06:00
TrèsAbhi edb9104d77 fix: correct yaw mutation call 2024-02-22 18:44:32 -06:00
TrèsAbhi e0feb45f48 fix: no cam pos reset on tank swap 2024-02-22 18:43:10 -06:00
TrèsAbhi bc211822aa fix: armor mesh rotation dysync 2024-02-22 18:36:21 -06:00
TrèsAbhi e33ae0f81b feat: more accurate angle calculation in fragment shader 2024-02-22 18:14:59 -06:00
TrèsAbhi 8467fb98d6 fix: no page memo 2024-02-22 18:11:06 -06:00
TrèsAbhi 62cb2c7217 fix: ricochet penetration as a sphere 2024-02-22 17:57:11 -06:00
TrèsAbhi f2331261cf fix: penetration loss text on ricochet 2024-02-22 17:52:37 -06:00
TrèsAbhi 26ae7bcbdf fix: remove shot after module change 2024-02-22 17:49:05 -06:00
TrèsAbhi 291b93b7d7 fix: use antagonist calibrated shells 2024-02-22 17:47:12 -06:00
TrèsAbhi 73fccbb2bb fix: external modules not contributong to spaced armor detph buffer 2024-02-22 17:28:53 -06:00
TrèsAbhi 7e92f8ac50 feat: united duel data 2024-02-22 16:41:42 -06:00
TrèsAbhi da767213cb fix: don't update armor highlighting on protagonist shell switch 2024-02-22 13:07:13 -06:00
TrèsAbhi d81eddba44 fix: no suspense on shell switching 2024-02-22 12:55:38 -06:00
TrèsAbhi 83599539b5 fix: canvas blocking tabs 2024-02-22 12:43:50 -06:00
TrèsAbhi c145aa9499 feat: few preset provisions and equipments 2024-02-22 12:37:17 -06:00
TrèsAbhi ffeaba6a18 fix: shrink canvas 2024-02-22 12:23:56 -06:00
TrèsAbhi c68df74943 fix: use new model format 2024-02-22 12:20:29 -06:00
TrèsAbhi 1c3e98678f feat: fixed camo 2024-02-22 10:56:45 -06:00
TrèsAbhi b1a1e07f7d fix: something 2024-02-21 13:32:51 -06:00
TrèsAbhi 46f8c72bef feat: only run on main and dev 2024-02-21 13:01:16 -06:00
TrèsAbhi a4f2902365 fix: use ecoding 2024-02-21 12:51:39 -06:00
TrèsAbhi bcda936585 fix: undef handling 2024-02-21 10:12:19 -06:00
TrèsAbhi c16a5f1701 feat: conditional prodiction dep 2024-02-21 09:54:58 -06:00
TrèsAbhi 569f803ab2 Update index.ts 2024-02-21 08:35:01 -06:00
TrèsAbhi 7bd797cb3a Update tankModels.ts 2024-02-21 08:07:27 -06:00
TrèsAbhi 1e875540fc Update tankModels.ts 2024-02-21 07:45:48 -06:00
TrèsAbhi bcde6907c8 feat: improved tools 2024-02-20 22:36:18 -06:00
TrèsAbhi a19c772bba fix: use betakrieg 2024-02-20 22:23:11 -06:00
TrèsAbhi 24ca684db3 feat: experimental warning 2024-02-20 22:10:59 -06:00
TrèsAbhi 6fd8b83d39 fix: proper undefined handling 2024-02-20 21:52:34 -06:00
TrèsAbhi 3ecc87431a feat: concurrency 2024-02-20 21:27:04 -06:00
TrèsAbhi 082e41bd76 fix: gh_token 2024-02-20 13:23:30 -06:00
TrèsAbhi 6f0bfa6b22 feat: print error on blob fail 2024-02-20 12:55:51 -06:00
TrèsAbhi bcb8669898 fix: discord token 2024-02-20 11:52:49 -06:00
TrèsAbhi 4bdb4f47fa feat: fixed sharp 2024-02-20 11:38:50 -06:00
TrèsAbhi a51cbc51de feat: proper lod0 handling 2024-02-20 11:21:01 -06:00
TrèsAbhi 060d00ccc4 fix: ignore engines 2024-02-20 10:14:02 -06:00
TrèsAbhi 1ddc51f6c7 fix: ps syntax 2024-02-20 10:01:35 -06:00
TrèsAbhi b222cbe41c feat: call build script 2024-02-20 09:51:11 -06:00
TrèsAbhi 53b4c0db69 feat: snitch2 2024-02-19 21:52:00 -06:00
TrèsAbhi ebaef28f06 feat: snitch 2024-02-19 21:36:06 -06:00
TrèsAbhi 0948b52957 feat: use cache 2024-02-19 21:30:04 -06:00
TrèsAbhi bc51c59fe1 feat: cache 2024-02-19 21:26:56 -06:00
TrèsAbhi 17a3d45e92 fix: = 2024-02-19 21:19:05 -06:00
TrèsAbhi 2fd8416887 feat: login 2024-02-19 21:14:34 -06:00
TrèsAbhi 46f11af717 feat: windows 2024-02-19 21:10:13 -06:00
TrèsAbhi c0670e9025 feat: run 2024-02-19 21:02:46 -06:00
TrèsAbhi bc9cc4ba2a fix: --add 2024-02-19 21:01:17 -06:00
TrèsAbhi 38ee4c6f12 fix: relative dir 2024-02-19 20:59:14 -06:00
TrèsAbhi 72aa528db2 fix: chmod 2024-02-19 20:58:34 -06:00
TrèsAbhi 5d63b8c239 feat: run 2024-02-19 20:44:44 -06:00
TrèsAbhi f78c1ea75f feat: linux dist 2024-02-19 20:42:30 -06:00
TrèsAbhi 38989689f5 fix: await 2024-02-19 20:37:34 -06:00
TrèsAbhi 1e5094f3ff feat: lock file 2024-02-19 20:36:12 -06:00
TrèsAbhi 067178e94d feat: depot 2024-02-19 20:32:12 -06:00
TrèsAbhi 58002958bf feat: move ammo types to fire 2024-02-19 19:05:49 -06:00
TrèsAbhi ca030688cb feat: things? 2024-02-19 18:45:43 -06:00
TrèsAbhi b883f5b9e2 Update package.json 2024-02-19 08:19:19 -06:00
TrèsAbhi 85f815c538 Delete yarn.lock 2024-02-19 08:09:39 -06:00
TrèsAbhi b2ddd5f286 Update package.json 2024-02-19 08:05:44 -06:00
TrèsAbhi 11bbcc7993 feat: async commit 2024-02-18 19:11:41 -06:00
TrèsAbhi 70f7b862e3 feat: wireframe armor viewer 2024-02-18 16:56:52 -06:00
TrèsAbhi 544eb92a6b feat: improved ventlation 2024-02-18 16:33:29 -06:00
TrèsAbhi 7a123b68db fix: disable crew on non-tech trees 2024-02-18 16:26:26 -06:00
TrèsAbhi b89925e86b feat: removed night 2024-02-18 16:23:10 -06:00
TrèsAbhi fc1f3cdb55 fix: remove pbr badge 2024-02-18 16:19:25 -06:00
TrèsAbhi 19717aeb79 feat: better environment ui 2024-02-18 16:18:56 -06:00
TrèsAbhi 60a115b7d8 feat: more accurate models 2024-02-18 16:17:00 -06:00
TrèsAbhi bc85b354a0 feat: new lighting 2024-02-17 10:52:21 -06:00
TrèsAbhi 1b611fa7c4 feat: removed banner 2024-02-17 10:43:34 -06:00
TrèsAbhi 34ba3ffdde feat: better cropped icons 2024-02-17 10:36:00 -06:00
TrèsAbhi af4d2dbfd0 feat: reactive wide format 2024-02-17 10:23:43 -06:00
TrèsAbhi 0b12f65949 feat: synx quick equipment 2024-02-17 10:20:43 -06:00
TrèsAbhi b8beb6e8be fix: draug moment 2024-02-17 10:05:56 -06:00
TrèsAbhi 09ba8663e6 feat: stat parity 2024-02-17 09:57:52 -06:00
TrèsAbhi b7558c33da feat: rest of provisions 2024-02-16 23:12:53 -06:00
TrèsAbhi 9174f3c9d0 fix: use non-compounding percentages 2024-02-16 23:08:20 -06:00
TrèsAbhi 2c8c04387b feat: crew basics 2024-02-16 21:07:48 -06:00
TrèsAbhi b1e5502b04 fix: clear on fire 2024-02-16 10:25:51 -06:00
TrèsAbhi ddf2c2b2ac fix: dpm using adrelaine and tungesten 2024-02-16 10:25:15 -06:00
TrèsAbhi 2a70602f7e feat: crew 2024-02-16 10:20:20 -06:00
TrèsAbhi c128d8ab0c fix: division by 0 2024-02-16 10:00:09 -06:00
TrèsAbhi 0e57c37296 fix: use gun list for dispersion fallback 2024-02-16 09:48:57 -06:00
TrèsAbhi e0c0ec6fc3 fix: 0 decimals 2024-02-16 09:48:27 -06:00
TrèsAbhi 31bb92c089 feat: camouflage bonus 2024-02-13 20:45:43 -06:00
TrèsAbhi 417385f231 feat: consumables 2024-02-13 19:46:34 -06:00
TrèsAbhi 62bfab0960 feat: smaller icons 2024-02-13 09:42:30 -06:00
TrèsAbhi 809f807da8 feat: consumable timings 2024-02-13 09:40:00 -06:00
TrèsAbhi 8f098d435a fix: penetration deltas 2024-02-12 19:27:50 -06:00
TrèsAbhi 574b1eec92 feat: code split module buttons 2024-02-12 19:24:34 -06:00
TrèsAbhi 7bd8629706 Add files via upload 2024-02-12 09:52:37 -06:00
TrèsAbhi 9d7c60e92d feat: consumables 2024-02-11 14:55:21 -06:00
TrèsAbhi 12cf05cb50 fix: engine accel now affects all engine powers 2024-02-11 14:28:27 -06:00
TrèsAbhi acd7162aa1 fix: ton unit name 2024-02-11 14:25:17 -06:00
TrèsAbhi efbb8376ef fix: caliberated shells now affects stats once again 2024-02-11 14:24:26 -06:00
TrèsAbhi bc4fa60f67 fix: camo net now affects all stats 2024-02-11 14:21:51 -06:00
TrèsAbhi f13b0f2255 feat: center aligned icons 2024-02-11 14:17:41 -06:00
TrèsAbhi 4ea8615fb5 feat: track directions 2024-02-10 00:08:42 -06:00
TrèsAbhi 7877eaa89b feat: named sorting 2024-02-10 00:04:43 -06:00
TrèsAbhi a8a50ac948 feat: move fire to first 2024-02-09 23:44:38 -06:00
TrèsAbhi 4206286c21 feat: remove max count 2024-02-09 23:43:30 -06:00
TrèsAbhi eec9a4c9e6 feat: smaller compact search 2024-02-09 23:37:22 -06:00
TrèsAbhi 469ffa145a feat: brighter flags 2024-02-09 23:36:04 -06:00
TrèsAbhi e1fdab42c0 fix: default listing 2024-02-09 23:35:24 -06:00
TrèsAbhi 39238a6491 fix: shortform dpm 2024-02-09 23:32:52 -06:00
TrèsAbhi fdc662fedf feat: change order 2024-02-09 23:30:40 -06:00
TrèsAbhi 9d805f63d5 fix: use dynamic shells 2024-02-09 23:28:36 -06:00
TrèsAbhi 68273ea295 feat: simplified shell stats 2024-02-09 23:27:47 -06:00
TrèsAbhi def5e4fa4f feat: render tank iamge above shadow 2024-02-09 19:48:33 -06:00
TrèsAbhi f3b1240990 feat: duller flags 2024-02-09 19:47:26 -06:00
TrèsAbhi 8c25037cb8 fix: default order 2024-02-09 19:43:02 -06:00
TrèsAbhi 31936d6e6c feat: equipments 2024-02-09 19:29:54 -06:00
TrèsAbhi cc622ef189 feat: sticky stats 2024-02-09 18:51:28 -06:00
TrèsAbhi 6ef0448d9f feat: deltas 2024-02-09 18:47:53 -06:00
TrèsAbhi 01dc3137d8 feat: begenning or equipment 2024-02-09 18:25:23 -06:00
TrèsAbhi 543e963d1d feat: titles 2024-02-09 18:09:36 -06:00
TrèsAbhi c12a66eeab fix: remove yarn downgrade file 2024-02-09 16:39:01 -06:00
TrèsAbhi a9f2ba776a fix: filter reset 2024-02-09 16:04:09 -06:00
TrèsAbhi e7525006b5 feat: sort options 2024-02-09 16:01:06 -06:00
TrèsAbhi ded71bf4f9 fix: downgrade to yarn 1 2024-02-09 11:05:37 -06:00
TrèsAbhi 1b9cf8b604 fix: remove shot when swithcing shells 2024-02-08 11:26:12 -06:00
TrèsAbhi 01e7353ef4 feat: rest of the stats 2024-02-08 11:18:43 -06:00
TrèsAbhi 73294af296 fix: track click 2024-02-08 11:11:20 -06:00
TrèsAbhi c3dc844167 feat: track tiers 2024-02-08 11:09:33 -06:00
TrèsAbhi 1eaa276b76 feat: tracks 2024-02-08 11:08:22 -06:00
TrèsAbhi 2b37e08f19 feat: more data 2024-02-08 10:58:03 -06:00
TrèsAbhi de52b525fb feat: speed 2024-02-07 21:31:04 -06:00
TrèsAbhi 8c39b6e3df feat: dispersion stats 2024-02-07 21:18:07 -06:00
TrèsAbhi 75c6bf2b68 fix: remove unnesesary use effect 2024-02-07 20:39:11 -06:00
TrèsAbhi 0308a10690 fix: merge 2024-02-07 15:16:48 -06:00
TrèsAbhi eab8bac68c fix: order 2024-02-07 14:56:42 -06:00
TrèsAbhi 19783ccbd6 Update page.tsx 2024-02-07 14:22:30 -06:00
TrèsAbhi 10799e52ce feat: remove logging vars 2024-02-07 13:45:51 -06:00
TrèsAbhi 92bb4af5ec fix: test 2024-02-07 13:40:14 -06:00
TrèsAbhi 846c2a4962 fix: something 2024-02-07 13:32:23 -06:00
TrèsAbhi 84300389f9 feat: future proof 2024-02-07 13:12:44 -06:00
TrèsAbhi cb2846a256 feat: switch to array buffer 2024-02-07 10:34:13 -06:00
TrèsAbhi 9cfa2357b0 feat: rename to cdon 2024-02-07 10:02:00 -06:00
TrèsAbhi eaa67ba1fe fix: use and 2024-02-06 18:29:12 -06:00
TrèsAbhi 901f1bfe42 feat: ricochet repenetration 2024-02-06 17:57:20 -06:00
TrèsAbhi 8bb5032ee4 feat: environment options 2024-02-06 15:35:48 -06:00
TrèsAbhi bf59d127f9 feat: move some filters 2024-02-06 15:18:18 -06:00
TrèsAbhi 4fb0aa3936 feat: simpler filter ui 2024-02-06 15:10:54 -06:00
TrèsAbhi ed8a1c2f57 fix: no fallback on state change 2024-02-06 15:01:03 -06:00
TrèsAbhi 059358cff4 feat: put other in end 2024-02-06 13:34:23 -06:00
TrèsAbhi 939acad30c feat: compact search 2024-02-06 13:28:05 -06:00
TrèsAbhi 4d09b3deb1 feat: sort order 2024-02-06 13:18:39 -06:00
TrèsAbhi a5c4dad3d4 feat: remove tank card hover animation 2024-02-06 13:10:28 -06:00
TrèsAbhi 2cb5e4e173 fix: gun type names 2024-02-06 13:07:01 -06:00
TrèsAbhi 708bcab2f3 fix: apply fast string comrpession 2024-02-05 18:33:17 -06:00
TrèsAbhi 2de077a3ab feat: utf8 2024-02-05 18:06:18 -06:00
TrèsAbhi 63084d1855 feat: deploy new compression 2024-02-05 17:30:30 -06:00
TrèsAbhi 1575b803c0 feat: more compression 2024-02-05 13:33:23 -06:00
TrèsAbhi afe041f969 feat: speed definitions 2024-02-05 12:57:03 -06:00
TrèsAbhi 0260fc6fbc yup 2024-02-04 21:59:45 -06:00
TrèsAbhi 73bd76bcdb huhuh 2024-02-04 21:47:11 -06:00
TrèsAbhi 2960f85671 asdads 2024-02-04 21:45:57 -06:00
TrèsAbhi 34c742555d fix: docs 2024-02-04 21:41:58 -06:00
TrèsAbhi 3b1066fade feat: more compression ig 2024-02-04 21:40:40 -06:00
TrèsAbhi 6753cedd33 feat: complete bkon 2024-02-04 21:10:44 -06:00
TrèsAbhi 2951ccdad5 feat: write stream 2024-02-04 18:48:39 -06:00
TrèsAbhi 93e405c370 feat: read stream 2024-02-04 17:55:48 -06:00
TrèsAbhi 016fe1c3d3 fix: move dev names 2024-02-04 11:58:49 -06:00
TrèsAbhi c816f7a923 feat: centered shot display text 2024-02-04 11:23:39 -06:00
TrèsAbhi 43b6453fcb feat: bkon read 2024-02-04 11:06:26 -06:00
TrèsAbhi 559fb30da9 feat: game filter parity 2024-02-03 16:44:10 -06:00
TrèsAbhi 273ab97d3f fix: ugh 2024-02-03 16:33:33 -06:00
TrèsAbhi fcac511a28 feat: gun rotation speed 2024-02-03 16:28:33 -06:00
TrèsAbhi b811be4284 feat: more values batch 1 2024-02-03 15:54:21 -06:00
TrèsAbhi 1a19e56523 feat: pbr badge 2024-02-03 15:13:19 -06:00
TrèsAbhi e5a235ba41 feat: more credits 2024-02-03 14:54:26 -06:00
TrèsAbhi 1448584f2f feat: no bold discirminoatr 2024-02-03 14:52:07 -06:00
TrèsAbhi 4433855047 feat: dpm docs 2024-02-03 14:50:35 -06:00
TrèsAbhi 02f63ee5f0 fix: full width stats 2024-02-03 13:49:29 -06:00
TrèsAbhi 36f0299e51 fix: heat ricochet 2024-02-03 13:30:38 -06:00
TrèsAbhi 170056cc55 fix: no user select for on canvas elements 2024-02-03 12:47:37 -06:00
TrèsAbhi a212d2142c fix: compare remaining penetration with 0 2024-02-03 12:22:52 -06:00
TrèsAbhi d89575b25d feat: he based gaps 2024-02-03 12:16:33 -06:00
TrèsAbhi 8f9306a3db fix: on click use equipment 2024-02-03 11:51:19 -06:00
TrèsAbhi 5f9e631011 fix: scale by PI/2 2024-02-02 23:29:10 -06:00
TrèsAbhi b290c88c1d fix: use two calibers rule on spaced armor 2024-02-02 23:21:12 -06:00
TrèsAbhi 2e888525c6 feat: cleaner 1 layer penetration 2024-02-02 23:10:43 -06:00
TrèsAbhi d54c08dc4e feat: no duplicate armor peices 2024-02-02 23:07:58 -06:00
TrèsAbhi 821076ae59 fix: spaced and external swapped 2024-02-02 23:01:26 -06:00
TrèsAbhi 13e11e4e09 feat: remove shot on tank switch 2024-02-02 18:17:53 -06:00
TrèsAbhi 0ab53a7825 feat: apply quaternion rotation 2024-02-02 18:10:43 -06:00
TrèsAbhi 15cb472189 feat: allow thicker lines to depth read 2024-02-02 17:53:13 -06:00
TrèsAbhi 39a6e2c38e feat: more thickness 2024-02-02 17:50:48 -06:00
TrèsAbhi 7c6c0a2b81 feat: trace lines 2024-02-02 17:48:18 -06:00
TrèsAbhi 3c63323814 feat: ricochet angle 2024-02-02 17:41:18 -06:00
TrèsAbhi 44f00e042e feat: no angled value for external 2024-02-02 17:03:49 -06:00
TrèsAbhi 3a99205194 feat: layering 2024-02-02 16:59:59 -06:00
TrèsAbhi c2e550e33d feat: on click display 2024-02-02 16:08:41 -06:00
TrèsAbhi b01a03c6ce feat: basic click functionality 2024-02-02 10:31:40 -06:00
TrèsAbhi 0251bfc842 fix: misc map 2024-02-02 09:46:24 -06:00
TrèsAbhi 89d4cadb61 feat: optimal shell index 2024-02-01 13:09:57 -06:00
TrèsAbhi e33fdffd99 feat: advanced dpm 2024-02-01 13:05:09 -06:00
TrèsAbhi 082996b152 feat: show environment 2024-02-01 12:28:56 -06:00
TrèsAbhi e9c3953085 feat: stats organization 2024-02-01 12:06:13 -06:00
TrèsAbhi 9f2ced6bfd feat: new layout 2024-02-01 11:52:48 -06:00
TrèsAbhi 281549add3 yeah 2024-01-29 19:30:45 -06:00
TrèsAbhi 91945a3b27 feat: brighyer ambinent lgihts 2024-01-29 19:25:34 -06:00
TrèsAbhi 162c7c9e59 feat: remove build prefix 2024-01-29 17:14:51 -06:00
TrèsAbhi ba46a2b801 you know 2024-01-29 16:50:58 -06:00
TrèsAbhi 13af8688a3 feat: more birhgt 2024-01-29 16:33:27 -06:00
TrèsAbhi fcf7c5c056 eh 2024-01-29 16:18:06 -06:00
TrèsAbhi ab8c52bb75 you know... the usual 2024-01-29 16:05:12 -06:00
TrèsAbhi 08d4e301d2 fix: warehouse 2024-01-29 15:53:47 -06:00
TrèsAbhi 428a0bbad5 feat: closer tank models 2024-01-29 15:27:21 -06:00
TrèsAbhi 4da4cf5259 feat: size 2024-01-28 20:56:49 -06:00
TrèsAbhi 72a24b2770 feat: bounding boxes 2024-01-28 20:45:50 -06:00
TrèsAbhi 9141f3d63f feat: camo and view range 2024-01-28 19:32:12 -06:00
TrèsAbhi c34edbd3ac feat: fire chance 2024-01-28 19:06:38 -06:00
TrèsAbhi eea2024549 feat: some stas 2024-01-27 19:06:02 -06:00
TrèsAbhi ee74c5ea5b feat: order 2024-01-27 18:21:35 -06:00
TrèsAbhi 158ceca599 feat: small screen compatibility 2024-01-27 18:01:51 -06:00
TrèsAbhi b8f3c3eece feat: change tank functionality 2024-01-27 17:48:32 -06:00
TrèsAbhi e835498e59 feat: no flags 2024-01-27 17:36:29 -06:00
TrèsAbhi 0250546ab5 feat: sides 2024-01-27 17:19:10 -06:00
TrèsAbhi f820d05082 feat: sticky sandbox 2024-01-27 13:53:19 -06:00
TrèsAbhi 9ba239caf0 feat: undo 2024-01-26 23:29:35 -06:00
TrèsAbhi 51a59fe2d4 feat: load model button 2024-01-26 23:28:17 -06:00
TrèsAbhi 6753d8a073 feat: i forgot 2024-01-26 23:14:54 -06:00
TrèsAbhi 46719cd32a feat: collapse 2024-01-26 23:09:53 -06:00
TrèsAbhi 47b8f75447 feat: no right align 2024-01-26 23:04:06 -06:00
TrèsAbhi ea68c8e82c feat: more responsive 2024-01-26 22:44:15 -06:00
TrèsAbhi 8d1745d47e feat: test stats 2024-01-26 22:33:32 -06:00
TrèsAbhi aa93ae6918 feat: better tool titles 2024-01-26 21:34:40 -06:00
TrèsAbhi 66cb197b53 feat: crew 2024-01-26 21:31:59 -06:00
TrèsAbhi 94cf7ebea8 Merge branch '1.16.0' of https://github.com/tresabhi/blitzkrieg into 1.16.0 2024-01-26 12:58:42 -06:00
TrèsAbhi 5405da365b feat: generalized include check 2024-01-26 12:58:41 -06:00
TrèsAbhi a0df425115 fix: oops 2024-01-26 07:38:42 -06:00
TrèsAbhi 62cf1265f8 fix: no negative index 2024-01-26 07:36:08 -06:00
TrèsAbhi ba7e772e56 feat: 113 2024-01-25 13:55:05 -06:00
TrèsAbhi 8f8c91e0b0 feat: provision filtering 2024-01-25 13:51:30 -06:00
TrèsAbhi 5bbe93bcec feat: provisions 2024-01-25 13:10:20 -06:00
TrèsAbhi 561b59d235 feat: camouflage 2024-01-25 12:45:13 -06:00
TrèsAbhi 38870ca3f7 feat: code split 2024-01-25 12:27:24 -06:00
TrèsAbhi cbe7b42fb8 feat: consumable max 2024-01-25 12:20:34 -06:00
TrèsAbhi ad1ad80026 feat: consumable buttons 2024-01-25 11:50:43 -06:00
TrèsAbhi a6c3ac36ef feat: removed shell incides 2024-01-25 10:44:37 -06:00
TrèsAbhi a9e88f6943 feat: shell numbers 2024-01-24 16:15:23 -06:00
TrèsAbhi 77ac1453f3 fix: high precision depth map 2024-01-24 16:05:59 -06:00
TrèsAbhi e094267223 feat: min/max zoom distances 2024-01-24 15:28:14 -06:00
TrèsAbhi b03589f6f0 fix: missing external modules 2024-01-24 14:57:37 -06:00
TrèsAbhi 3486dde8c1 feat: corrected texture resolutions 2024-01-24 14:52:06 -06:00
TrèsAbhi c08bf359f5 feat: downgrade to three 159 2024-01-24 10:31:06 -06:00
TrèsAbhi 002d0d4bc0 fix: missing turrets 2024-01-23 13:37:32 -06:00
TrèsAbhi 7db02aa579 fix: green penetration reactivity 2024-01-23 13:14:43 -06:00
TrèsAbhi b47e6436e7 fix: removed z-fighting 2024-01-22 19:38:45 -06:00
TrèsAbhi 7b6e291a0a feat: simplified shaders 2024-01-22 19:14:24 -06:00
TrèsAbhi b8f6394d94 feat: optimized shell switching 2024-01-22 18:14:09 -06:00
TrèsAbhi 0b5c2e42fb feat: isolated duel 2024-01-22 18:04:13 -06:00
TrèsAbhi fce5f19100 feat: cleaner quick equipment buttons 2024-01-22 17:06:06 -06:00
TrèsAbhi 05ad24c8f5 feat: gun to 0 0 on default 2024-01-22 16:55:09 -06:00
TrèsAbhi 5295aa37be feat: hull origins 2024-01-22 16:54:07 -06:00
TrèsAbhi 3e12d6bb9d fix: all loaders inside 2024-01-22 16:04:06 -06:00
TrèsAbhi 019514b699 feat: loading number 2024-01-22 15:50:41 -06:00
TrèsAbhi a32ee2a344 feat: non-pbr tank lighting and model cache 2024-01-22 15:42:49 -06:00
TrèsAbhi ea480aa365 feat: update versions 2024-01-22 15:27:45 -06:00
TrèsAbhi d70a980058 feat: depot dodnloader node js 2024-01-22 13:40:37 -06:00
TrèsAbhi 3157bc5e1b idk 2024-01-22 10:40:13 -06:00
TrèsAbhi e72b703ab2 uhhhhhhhhh 2024-01-22 10:36:46 -06:00
TrèsAbhi c1f3c0dc6b asgakgkuashlkgskljgashghsa;lgh;lsd 2024-01-22 10:36:20 -06:00
TrèsAbhi 30f7db1bc2 feat: asset workflow 2024-01-22 10:30:33 -06:00
TrèsAbhi 07a147a043 feat: document undef check 2024-01-21 22:11:23 -06:00
TrèsAbhi 9488602f5b feat: ea and cs 2024-01-21 22:11:01 -06:00
TrèsAbhi a0c9895122 fix: window undefined check 2024-01-21 21:06:12 -06:00
TrèsAbhi 86353dc1cf fix: no gray buttons on no gid 2024-01-21 18:13:53 -06:00
TrèsAbhi 5168fb4098 feat: use dev definitions on localhost 2024-01-21 17:57:40 -06:00
TrèsAbhi c04192e15e feat: full name search 2024-01-21 16:28:26 -06:00
TrèsAbhi 638c2c73da feat: full names and better default names 2024-01-21 16:23:44 -06:00
TrèsAbhi f368769068 feat: full names and better default names 2024-01-21 16:03:38 -06:00
TrèsAbhi 9f347fa1f2 feat: less boxy 2024-01-21 11:09:02 -06:00
TrèsAbhi b44f897528 feat: antagonist selector filters 2024-01-21 11:06:40 -06:00
TrèsAbhi efd1a80ee1 feat: penetration listing 2024-01-21 10:37:26 -06:00
TrèsAbhi a70100c835 feat: persistant tankopedia 2024-01-21 10:34:52 -06:00
TrèsAbhi f51e365ec4 feat: gun up in face hug 2024-01-21 10:20:48 -06:00
TrèsAbhi a5e0f33f02 fix: external module color precision error 2024-01-21 10:14:42 -06:00
TrèsAbhi dc85b49f9c feat: vercel dev 2024-01-21 00:13:02 -06:00
TrèsAbhi 72c90b3e92 feat: vercel dev 2024-01-21 00:10:59 -06:00
TrèsAbhi df566709c8 feat: vercel dev 2024-01-21 00:07:26 -06:00
TrèsAbhi 3617e11792 feat: commit concurrency 2024-01-21 00:01:32 -06:00
TrèsAbhi b243b12d1c feat: i am sleepy 2024-01-20 23:45:45 -06:00
TrèsAbhi d0d28baee0 feat: safer unknown component handling 2024-01-20 23:20:00 -06:00
TrèsAbhi 001b0c356f fix: missing comma 2024-01-20 22:55:38 -06:00
TrèsAbhi 4984c522ba feat: dev use dev branch 2024-01-20 22:46:21 -06:00
TrèsAbhi be64934eb4 feat: optimized blobs 2024-01-20 22:45:25 -06:00
TrèsAbhi 7669c2d76b feat: optimized assets 2024-01-20 22:11:56 -06:00
TrèsAbhi 00244846cb feat: use dev branch for dev activities 2024-01-20 21:35:36 -06:00
TrèsAbhi 480fda34ce feat: move poses 2024-01-20 16:59:18 -06:00
TrèsAbhi 103c1b4338 feat: fixed he-based rounds behind spaced armor 2024-01-20 16:31:20 -06:00
TrèsAbhi 2acee91524 fix: remainging pen going negative 2024-01-20 15:24:20 -06:00
TrèsAbhi 6f42e2835c feat: oops, now it's correct 2024-01-20 15:22:03 -06:00
TrèsAbhi e724069cbd feat: correct he calculations 2024-01-20 15:21:30 -06:00
TrèsAbhi 417858f21a feat: split tutorial bots 2024-01-20 13:43:03 -06:00
TrèsAbhi a362301515 feat: r5g6b5a0 format compatibility 2024-01-20 13:27:56 -06:00
TrèsAbhi 86fd714923 fix: spaced armor bleeding 2024-01-20 12:08:30 -06:00
TrèsAbhi 871b746cce fix: no external module spaced armor occlusion 2024-01-20 12:01:05 -06:00
TrèsAbhi d12440f60a feat: downgraded sharp 2024-01-20 11:46:26 -06:00
TrèsAbhi 4bb78b2532 feat: downgraded sharp 2024-01-20 11:43:57 -06:00
TrèsAbhi 3cd23cd718 fix: min penetration chance 0 2024-01-19 23:33:27 -06:00
TrèsAbhi 59abda933f feat: auto poser 2024-01-19 22:46:10 -06:00
TrèsAbhi 47f5f86da1 feat: yaw and pitch 2024-01-19 22:08:05 -06:00
TrèsAbhi 369ba08780 fix: tank icons dir 2024-01-17 12:39:02 -06:00
TrèsAbhi 0692933148 feat: optimized tank icon building 2024-01-17 12:31:07 -06:00
TrèsAbhi 26852981ba feat: consistent filters 2024-01-17 12:25:53 -06:00
TrèsAbhi 8199bd550d fix: unified max thicknesses 2024-01-17 12:06:22 -06:00
TrèsAbhi c56eaa258d feat: consumable icons 2024-01-17 11:34:27 -06:00
TrèsAbhi d4e8ec125f feat: consumable definitions 2024-01-16 21:49:15 -06:00
TrèsAbhi 3e3ed35fb0 feat: better auto ricochet check for spaced armor 2024-01-16 20:17:19 -06:00
TrèsAbhi 3530e8d0f0 feat: equipment ui 2024-01-16 17:47:41 -06:00
TrèsAbhi a1f6bbdf1f feat: equipment icons 2024-01-16 17:31:20 -06:00
TrèsAbhi b053018958 feat: equipment definitions 2024-01-16 17:19:46 -06:00
TrèsAbhi 5adc69977b feat: equipment icons 2024-01-16 16:58:00 -06:00
TrèsAbhi a741cc305a feat: updated contributors 2024-01-16 14:46:08 -06:00
TrèsAbhi 96f5ad8b96 fix: no control lock on removed hull yaw 2024-01-16 14:28:47 -06:00
TrèsAbhi 18dbb658ff feat: sun lighting 2024-01-16 14:20:43 -06:00
TrèsAbhi 512134162d feat: fullscreen disabling on old browsers 2024-01-16 14:09:19 -06:00
TrèsAbhi 7e1949ccc7 fix: search result selection 2024-01-16 14:07:29 -06:00
TrèsAbhi db0c4a1d71 feat: new search results 2024-01-16 14:02:50 -06:00
TrèsAbhi 1c64305678 feat: isolated model hooks 2024-01-16 13:16:06 -06:00
TrèsAbhi 95b8532660 feat: moved hooks to src 2024-01-16 13:10:06 -06:00
TrèsAbhi 9b3c7451b7 fix: armor highlighting desync 2024-01-16 12:34:00 -06:00
TrèsAbhi 1911508552 fix: typo 2024-01-16 12:26:15 -06:00
TrèsAbhi f5ac301c7e feat: green penetration mode 2024-01-16 12:22:17 -06:00
TrèsAbhi e37b91c2e3 feat: spaced armor auto-ricochet 2024-01-16 12:10:28 -06:00
TrèsAbhi 0a92dc0d2d fix: bleeding spaced armor 2024-01-16 12:06:39 -06:00
TrèsAbhi 754b16af10 fix: focused element disallow shell switch 2024-01-16 11:19:43 -06:00
TrèsAbhi b434bc4c78 lllll 2024-01-16 10:29:55 -06:00
TrèsAbhi dba2e2f170 huh? 2024-01-16 10:25:26 -06:00
TrèsAbhi 312077a74a adbaskdb 2024-01-16 10:22:26 -06:00
TrèsAbhi 817d77a437 ??? 2024-01-16 10:19:52 -06:00
TrèsAbhi 13b85e7164 ugh 2024-01-16 10:16:29 -06:00
TrèsAbhi 352e174af2 fix: removed deprecated build methods 2024-01-16 10:13:38 -06:00
TrèsAbhi aadcaedae5 feat: removed spaced armor visuals 2024-01-16 10:08:28 -06:00
TrèsAbhi 40c69382a1 fix: no outfile testing 2024-01-16 09:56:21 -06:00
TrèsAbhi 8f4ea5f2fd feat: spaced armor thickness 2024-01-16 09:52:30 -06:00
TrèsAbhi a11975adec fix: spaced armor layering 2024-01-16 09:24:12 -06:00
TrèsAbhi 578f6f4d0f feat: accurate external module thickness 2024-01-16 09:05:07 -06:00
TrèsAbhi ae267adf27 feat: external module thickness 2024-01-16 08:40:27 -06:00
TrèsAbhi c76fc97922 fix: spaced armor highlitging 2024-01-15 21:58:03 -06:00
TrèsAbhi 730ad56677 feat: explosive shells under spaced armor 2024-01-15 21:55:09 -06:00
TrèsAbhi 80b161b9fc feat: 3CR exception mask 2024-01-15 18:01:48 -06:00
TrèsAbhi 66016dedf0 feat: simplified duel state 2024-01-15 15:02:04 -06:00
TrèsAbhi 7314f1d9b1 feat: calmer lighting 2024-01-15 11:19:44 -06:00
TrèsAbhi 605edd547d fix: update on input 2024-01-13 18:50:13 -06:00
TrèsAbhi 864c29933a fix: armor desync 2024-01-13 18:49:03 -06:00
TrèsAbhi ff15a37e10 feat: optimized tank extration by a little 2024-01-13 18:40:28 -06:00
TrèsAbhi 237f676af8 feat: async updates 2024-01-13 18:24:04 -06:00
TrèsAbhi 5e0e80e965 feat: increased lighting brightness 2024-01-13 16:51:16 -06:00
TrèsAbhi 586cd84349 feat: optimized state handling x2 2024-01-13 16:48:20 -06:00
TrèsAbhi 230b2df5fd feat: optimized state handling 2024-01-13 13:44:27 -06:00
TrèsAbhi 033d361cea feat: updated module selection buttons 2024-01-13 13:17:48 -06:00
TrèsAbhi dbbde066f8 feat: uhhh I forgot 2024-01-13 13:11:01 -06:00
TrèsAbhi 785e9650e5 feat: swap tanks 2024-01-12 20:55:42 -06:00
TrèsAbhi 0b697a94bd feat: small tank icon 2024-01-12 20:42:10 -06:00
TrèsAbhi 163c41f568 feat: more precise controls 2024-01-12 20:22:53 -06:00
TrèsAbhi 0582339332 fix: negative hard joint handling... correctly this time 2024-01-12 20:20:51 -06:00
TrèsAbhi 062e6e9626 fix: negative hard joint handling 2024-01-12 19:58:02 -06:00
TrèsAbhi 6dfb727fa2 feat: splash transition 2024-01-12 14:57:46 -06:00
TrèsAbhi d469f990f6 feat: more advanced armor highlighting 2024-01-12 13:57:32 -06:00
TrèsAbhi 909474d4cb fix: no null return before hook 2024-01-11 21:44:44 -06:00
TrèsAbhi c9eee14b21 feat: no splash zones 2024-01-11 21:30:34 -06:00
TrèsAbhi 7fe35243db heat: enhanced heat 2024-01-11 20:45:12 -06:00
TrèsAbhi 647c9240a5 feat: spaced armor in space caught shell 2024-01-11 12:53:19 -06:00
TrèsAbhi fe0fdc0964 feat: opaque he 2024-01-11 12:31:45 -06:00
TrèsAbhi 9dc4483d70 feat: he start 2024-01-11 12:15:24 -06:00
TrèsAbhi 9ed2b79a4b feat: ricochet incapable shells 2024-01-11 12:04:01 -06:00
TrèsAbhi 28a9f9a849 Merge branch '1.16.0' of https://github.com/tresabhi/blitzkrieg into 1.16.0 2024-01-11 11:45:39 -06:00
TrèsAbhi 5044a5be48 fix: 3 caliber auto ricochet 2024-01-11 11:45:38 -06:00
TrèsAbhi c4053b9ba8 feat: naaahhhhh 2024-01-10 22:10:44 -06:00
TrèsAbhi 53c00258ac feat: purple spaced armor 2024-01-10 22:08:03 -06:00
TrèsAbhi 99123b733d fix: hide on rotate 2024-01-10 19:27:58 -06:00
TrèsAbhi 92c81d60db feat: spaced armor toggle 2024-01-10 19:08:23 -06:00
TrèsAbhi 9218eb09d6 feat: spaced armor 2024-01-10 19:04:04 -06:00
TrèsAbhi cae4836e17 feat: france 2024-01-10 18:14:55 -06:00
TrèsAbhi 172b410d82 feat: key press shell switching 2024-01-10 18:01:33 -06:00
TrèsAbhi 371dcad568 feat: shell swapping 2024-01-10 17:57:41 -06:00
TrèsAbhi 32ee10445d feat: penetration 2024-01-10 17:36:11 -06:00
TrèsAbhi f9d0659bd4 feat: opaque amror 2024-01-10 17:29:49 -06:00
TrèsAbhi f6b4bb3923 feat: no armor unload 2024-01-10 16:25:59 -06:00
TrèsAbhi 9ccd183607 fix: discard no armor thickness meshes 2024-01-10 16:22:42 -06:00
TrèsAbhi 5e06a606ef fix: darker lime color 2024-01-10 15:41:55 -06:00
TrèsAbhi 2f6b186ea8 feat: hide armor when rotating 2024-01-10 15:39:18 -06:00
TrèsAbhi dd8875e951 feat: armor gradient 2024-01-10 15:27:14 -06:00
TrèsAbhi 232fc3140c feat: all armor rules... I think? 2024-01-10 14:56:33 -06:00
TrèsAbhi 8e6d2b9873 feat: some armor rules 2024-01-10 14:25:48 -06:00
TrèsAbhi 6239a72bce feat: tank armor position stateful 2024-01-10 13:22:55 -06:00
TrèsAbhi 9e0c4ddb9a feat: isolated tank model 2024-01-10 13:00:37 -06:00
TrèsAbhi 4a6b5e3aa9 feat: custom shaders 2024-01-09 22:31:34 -06:00
TrèsAbhi a7d8f08522 feat: armor display 2024-01-09 18:35:42 -06:00
TrèsAbhi 6aa4686c30 feat: moved to blitzkrieg assets 2024-01-09 13:36:33 -06:00
TrèsAbhi f9ccb06d1f feat: armor chunking 2024-01-09 13:07:27 -06:00
TrèsAbhi 1f378137f1 fix: backwards vertex format 2024-01-08 19:13:54 -06:00
TrèsAbhi 7235291e69 feat: started work on armor extraction 2024-01-08 18:34:04 -06:00
TrèsAbhi 1f39ecefe4 feat: code split build assets 2024-01-07 20:07:45 -06:00
TrèsAbhi 5a25f335cd feat: colored search results 2024-01-07 19:41:37 -06:00
TrèsAbhi 88a6ab2d95 feat: prot and anta 2024-01-07 13:13:27 -06:00
TrèsAbhi 5208cf8678 feat: code split 2024-01-06 18:17:04 -06:00
TrèsAbhi 857cba902a feat: settings basework 2024-01-06 17:59:31 -06:00
TrèsAbhi 6753a72d97 feat: better ui sizing 2024-01-06 17:56:12 -06:00
TrèsAbhi 14fb62bda4 feat: pitch and yaw limits for inputs 2024-01-06 13:21:11 -06:00
TrèsAbhi 21122c620b feat: sky lighting 2024-01-05 15:22:00 -06:00
TrèsAbhi cb6ce4f183 feat: unified loader 2024-01-05 15:01:45 -06:00
TrèsAbhi 884531700b feat: better lighting 2024-01-05 14:28:25 -06:00
TrèsAbhi 6ca9e637dc feat: loading bar 2024-01-04 13:37:36 -06:00
TrèsAbhi 0a4e696bc1 feat: code split 2024-01-04 13:06:40 -06:00
TrèsAbhi 2cf0bda352 fix: no accessor overrides 2024-01-04 12:11:36 -06:00
TrèsAbhi 35d03c9e21 fix: streak lod 2024-01-03 19:18:31 -06:00
TrèsAbhi c10060decc feat: initial turret rotation 2024-01-03 18:52:22 -06:00
TrèsAbhi d408df8b4c feat: generalized prevent default 2024-01-03 18:28:02 -06:00
TrèsAbhi b3ecb1e9b9 feat: track turning 2024-01-03 14:15:34 -06:00
TrèsAbhi 9306773b5b fix: no text selection on drag 2024-01-03 14:07:19 -06:00
TrèsAbhi 1245fab7ae fix: next gun/turret selection 2024-01-03 14:05:06 -06:00
TrèsAbhi a6e8dd6632 feat: pitch limits 2024-01-03 13:50:36 -06:00
TrèsAbhi 56d8392f12 feat: code split containers 2024-01-03 13:07:45 -06:00
TrèsAbhi 39e4bce254 feat: hide grid when looking at bottom 2024-01-03 12:35:10 -06:00
TrèsAbhi b745ad2fa7 feat: controls 2024-01-03 12:21:30 -06:00
TrèsAbhi ff66d99fc6 feat: remove controls 2024-01-03 11:56:37 -06:00
TrèsAbhi f15bee34fb feat: cleaned code 2024-01-02 21:10:04 -06:00
TrèsAbhi 60164cfb78 feat: model definitions 2024-01-02 20:17:10 -06:00
TrèsAbhi d543e42268 feat: gun limits 2024-01-02 14:50:46 -06:00
TrèsAbhi 9bd26ef3bf feat: gun pitch 2024-01-02 14:07:40 -06:00
TrèsAbhi 0d5b2aedc7 feat: better alignment 2024-01-02 13:55:41 -06:00
TrèsAbhi 3ea872c6a4 feat: improved lighting and grid 2024-01-02 13:31:34 -06:00
TrèsAbhi e65582a432 feat: simpler turret rotation 2024-01-01 21:24:54 -06:00
TrèsAbhi 88cc37a6af feat: hull rotation 2024-01-01 21:17:22 -06:00
TrèsAbhi b40b7b2472 feat: inputs 2024-01-01 21:03:27 -06:00
TrèsAbhi 73f7e84192 feat: turret rotation 2024-01-01 20:45:23 -06:00
TrèsAbhi c9d5b46ae8 feat: omition 2024-01-01 15:16:28 -06:00
TrèsAbhi d4e16048e9 feat: module hot swapping 2024-01-01 14:48:46 -06:00
TrèsAbhi a1c80c3d27 feat: mesh occlusion base work 2024-01-01 14:00:41 -06:00
TrèsAbhi 8b10f2cc4a feat: models 2023-12-31 20:47:21 -06:00
TrèsAbhi 28dde23115 feat: gun & turret origins 2023-12-31 19:26:08 -06:00
TrèsAbhi b433129354 feat: mesh omit 2023-12-30 13:02:47 -06:00
TrèsAbhi 7e0fb82905 fix: vec4 tangents 2023-12-30 12:43:46 -06:00
TrèsAbhi 75bacd5586 fix: first max float 32 mesh 2023-12-30 11:45:44 -06:00
TrèsAbhi 21daf9f3bb fix: little endian pvr 2023-12-30 11:44:42 -06:00
TrèsAbhi fa625dc887 fix: bc1 alpha check 2023-12-29 19:59:01 -06:00
TrèsAbhi 376b3f04e5 feat: pvr parser 2023-12-29 19:56:50 -06:00
TrèsAbhi 12ee9d381d feat: more game parity x2 2023-12-29 13:01:45 -06:00
TrèsAbhi b90938108a feat: more game parity 2023-12-29 12:41:21 -06:00
TrèsAbhi 7b136e61a1 feat: texture mutation 2023-12-28 20:58:18 -06:00
TrèsAbhi 0f9f15b268 feat: normalized alpha 2023-12-28 18:36:28 -06:00
TrèsAbhi fdd8aac017 fix: i hate ai copilots doing binary 2023-12-28 17:55:47 -06:00
TrèsAbhi ce4f7a55b6 fix: a few bit swaps 2023-12-28 13:46:22 -06:00
TrèsAbhi def75da1d8 feat: generalization x3 2023-12-28 13:11:52 -06:00
TrèsAbhi 2b81c71c5a feat: generalization x2 2023-12-28 12:54:46 -06:00
TrèsAbhi 436a519db8 feat: generalization 2023-12-28 12:24:09 -06:00
TrèsAbhi c624136dc7 feat: more bc versions 2023-12-27 18:54:03 -06:00
TrèsAbhi cd546b274f feat: dds stream 2023-12-26 20:02:01 -06:00
TrèsAbhi 2f650af0f7 feat: more prog 2023-12-26 13:35:07 -06:00
TrèsAbhi 4ffb7cac7c feat: dds decompression 2023-12-25 20:50:19 -06:00
TrèsAbhi 6361b09129 feat: dds stream 2023-12-25 16:49:40 -06:00
TrèsAbhi cb87a3067c feat: code split streams 2023-12-25 12:37:37 -06:00
TrèsAbhi 510eebd2f4 feat: stride mis-match management 2023-12-25 11:21:39 -06:00
TrèsAbhi 15a8e53e22 feat: rename to attribute 2023-12-24 19:02:47 -06:00
TrèsAbhi 99fb44633e feat: lod distance based model loading 2023-12-24 18:38:13 -06:00
TrèsAbhi 9a8c0399be feat: more pbr polish 2023-12-23 19:23:47 -06:00
TrèsAbhi 818a6b38cb feat: moved to scpg stream 2023-12-23 14:28:29 -06:00
TrèsAbhi d60a102c0d feat: pbr 2023-12-22 15:59:06 -06:00
TrèsAbhi 0627f678ea feat: use PVRTexToolCLI 2023-12-20 14:39:32 -06:00
TrèsAbhi 4ad10deb00 feat: texturing 2023-12-19 20:11:37 -06:00
TrèsAbhi f61725a021 feat: attempting textures 2023-12-19 14:32:08 -06:00
TrèsAbhi 6d2bb7b131 feat: glb convertion 2023-12-18 17:49:07 -06:00
TrèsAbhi 9db43db3cf feat: sc2 typings 2023-12-18 16:28:15 -06:00
TrèsAbhi 7a22ce5af5 feat: more definition data 2023-12-17 20:47:33 -06:00
TrèsAbhi 68239351b5 Merge pull request #60 from tresabhi/main 2023-12-15 22:01:40 -06:00
TrèsAbhi 2aefe3f9e3 feat: integrated tank definitions 2023-12-14 22:19:01 -06:00
TrèsAbhi adb0bb2e69 feat: versus tank lookup 2023-12-13 12:47:31 -06:00
TrèsAbhi b1a59aa871 Merge pull request #59 from tresabhi/main 2023-12-13 12:09:11 -06:00
201 changed files with 14175 additions and 4138 deletions

No files matched your search

+53
View File
@@ -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 444200 -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' || '' }}
+2
View File
@@ -1,6 +1,8 @@
name: CD Host
on:
push:
branches: main
schedule:
- cron: '0 */5 * * *'
workflow_dispatch:
+4 -1
View File
@@ -14,4 +14,7 @@ test.*
servers.csv
.next/
tsconfig.tsbuildinfo
temp/
temp/
/.yarn
/.VSCodeCounter
/.yarn
+66
View File
@@ -3,46 +3,98 @@
"AABBOX",
"Abhi",
"accountinfo",
"aeson",
"alphatest",
"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",
"dvpl",
"DVPLCRC",
"dxgi",
"Everytime",
"FASTNAME",
"FOURCC",
"fsformat",
"Fullscreen",
"fullstats",
"fuzzysort",
"gamemode",
"glsl",
"GLTF",
"haha",
"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",
@@ -50,22 +102,33 @@
"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",
"UNORM",
"Unregistering",
"vickers",
"vitepress",
@@ -73,7 +136,10 @@
"wargaming",
"webp",
"winrate",
"wireframe",
"wotblitz",
"xffn",
"xkdkw",
"XMLDVPL",
"YAMLDVPL",
"zustand"
+72
View File
@@ -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
+101
View File
@@ -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).
![an image of file sizes](https://i.imgur.com/AHJiFpq.png)
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
View File
@@ -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
+17
View File
@@ -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.
+9 -1
View File
@@ -4,7 +4,15 @@ 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,
+24 -12
View File
@@ -1,6 +1,6 @@
{
"name": "blitzkrieg",
"version": "1.15.13",
"version": "1.16.0",
"description": "🎉 Tools for everything World of Tanks Blitz",
"main": "src/server.ts",
"type": "module",
@@ -9,7 +9,7 @@
"build:website": "next build",
"build:docs": "vitepress build docs",
"build:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard",
"build:assets": "vite-node --script scripts/buildAssets",
"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",
@@ -19,26 +19,29 @@
"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"
},
"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": "^2.0.1",
"@react-three/drei": "^9.90.1",
"@react-three/fiber": "^8.15.12",
"@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",
@@ -50,14 +53,21 @@
"immer": "^10.0.2",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"lz4js": "^0.2.0",
"markdown-escape": "^2.0.0",
"next": "^14.0.3",
"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.0",
"three": "^0.159.0",
"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",
@@ -66,7 +76,7 @@
},
"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",
@@ -74,8 +84,9 @@
"@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",
"@types/three": "^0.159.0",
"@vanilla-extract/next-plugin": "^2.3.1",
"compare-versions": "^6.1.0",
"crc": "^4.3.2",
@@ -85,5 +96,6 @@
"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

-544
View File
@@ -1,544 +0,0 @@
import { config } from 'dotenv';
import { existsSync } from 'fs';
import { mkdir, readdir, rm } from 'fs/promises';
import { argv } from 'process';
import sharp from 'sharp';
import { TankType } from '../src/components/Tanks';
import { NATION_IDS } from '../src/constants/nations';
import { SCPGStream } from '../src/core/blitz/SCPGStream';
import { readBase64DVPL } from '../src/core/blitz/readBase64DVPL';
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 { toUniqueId } from '../src/core/blitz/toUniqueId';
import commitMultipleFiles, {
FileChange,
} from '../src/core/blitzkrieg/commitMultipleFiles';
import { BlitzkriegGunDefinitions } from '../src/core/blitzkrieg/definitions/guns';
import {
BlitzkriegShellDefinitions,
ShellType,
} from '../src/core/blitzkrieg/definitions/shells';
import {
BlitzkriegTankDefinitions,
Tier,
} from '../src/core/blitzkrieg/definitions/tanks';
import { BlitzkriegTurretDefinitions } from '../src/core/blitzkrieg/definitions/turrets';
config();
// WARNING! MOST OF THESE TYPES ARE NOT EXHAUSTIVE!
interface VehicleDefinitionList {
[key: string]: {
id: number;
userString: string;
shortUserString?: string;
description: string;
price: number | { gold: ''; '#text': number };
sellPrice?: { gold: ''; '#text': number };
enrichmentPermanentCost: number;
notInShop?: boolean;
tags: string;
level: number;
combatRole?: Record<string, string>;
configurationModes: string;
};
}
interface TurretDefinitionsList {
nextAvailableId: number;
ids: Record<string, number>;
}
interface GunDefinitionsList {
nextAvailableId: number;
ids: Record<string, number>;
shared: {
[key: string]: {
userString: string;
tags: string;
level: number;
pitchLimits: string;
burst?: { count: number; rate: number };
clip?: { count: number; rate: number };
shots: {
[key: string]: {
speed: number;
piercingPower: string;
};
};
};
};
}
type ShellKind =
| 'ARMOR_PIERCING'
| 'ARMOR_PIERCING_CR'
| 'HIGH_EXPLOSIVE'
| 'HOLLOW_CHARGE';
type ShellDefinitionsList = Record<
string,
{
id: number;
userString: string;
icon: string;
kind: ShellKind;
caliber: number;
damage: { armor: number; devices: number };
normalizationAngle: number;
ricochetAngle: number;
}
> & {
icons: Record<string, string>;
};
interface VehicleDefinitions {
turrets0: {
[key: string]: {
userString: number;
level: number;
yawLimits: string | string[];
guns: {
[key: string]: {
maxAmmo: number;
pitchLimits?: string | string[];
};
};
};
};
}
interface Strings {
[key: string]: string;
}
type BlitzTankType = 'AT-SPG' | 'lightTank' | 'mediumTank' | 'heavyTank';
interface TankParameters {
resourcesPath: {
smallIconPath: string;
bigIconPath: string;
blitzModelPath: string;
};
}
const blitzTankTypeToBlitzkrieg: Record<BlitzTankType, TankType> = {
'AT-SPG': 'tank_destroyer',
lightTank: 'light',
mediumTank: 'medium',
heavyTank: 'heavy',
};
const blitzShellKindToBLitzkrieg: Record<ShellKind, ShellType> = {
ARMOR_PIERCING: 'ap',
ARMOR_PIERCING_CR: 'ap_cr',
HIGH_EXPLOSIVE: 'he',
HOLLOW_CHARGE: 'hc',
};
const DATA =
'C:/Program Files (x86)/Steam/steamapps/common/World of Tanks Blitz/Data';
const LANGUAGE = 'en';
const DOI = {
vehicleDefinitions: 'XML/item_defs/vehicles',
strings: 'Strings',
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',
};
const allTargets = argv.includes('--all-targets');
const targets = argv
.find((argument) => argument.startsWith('--target'))
?.split('=')[1]
.split(',');
if (!targets && !allTargets) throw new Error('No target(s) specified');
if (allTargets || targets?.includes('definitions')) {
console.log('Building tank definitions...');
const tankDefinitions: BlitzkriegTankDefinitions = {};
const turretDefinitions: BlitzkriegTurretDefinitions = {};
const gunDefinitions: BlitzkriegGunDefinitions = {};
const shellDefinitions: BlitzkriegShellDefinitions = {};
const nations = await readdir(`${DATA}/${DOI.vehicleDefinitions}`).then(
(nations) => nations.filter((nation) => nation !== 'common'),
);
const strings = await readYAMLDVPL<Strings>(
`${DATA}/${DOI.strings}/${LANGUAGE}.yaml.dvpl`,
);
await Promise.all(
nations.map(async (nation) => {
const tankList = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/list.xml.dvpl`,
);
const turretList = await readXMLDVPL<{
root: TurretDefinitionsList;
}>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/components/turrets.xml.dvpl`,
);
const gunList = await readXMLDVPL<{
root: GunDefinitionsList;
}>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/components/guns.xml.dvpl`,
);
const shellList = await readXMLDVPL<{
root: ShellDefinitionsList;
}>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/components/shells.xml.dvpl`,
);
for (const tankKey in tankList.root) {
const tank = tankList.root[tankKey];
const tankDefinition = await readXMLDVPL<{ root: VehicleDefinitions }>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/${tankKey}.xml.dvpl`,
);
const tankId = toUniqueId(nation, tank.id);
const tankTags = tank.tags.split(' ');
const tankTurrets = Object.keys(tankDefinition.root.turrets0).map(
(turretKey) => {
const turret = tankDefinition.root.turrets0[turretKey];
const turretId = toUniqueId(nation, turretList.root.ids[turretKey]);
const turretGuns = Object.keys(turret.guns).map((gunKey) => {
const turretGunEntry = turret.guns[gunKey];
const gunId = toUniqueId(nation, gunList.root.ids[gunKey]);
const gun = gunList.root.shared[gunKey];
const pitchLimitsRaw =
turretGunEntry.pitchLimits ?? gun.pitchLimits;
const gunShells = Object.keys(gun.shots).map((shellKey) => {
const turretShellEntry = gun.shots[shellKey];
const shell = shellList.root[shellKey];
const shellId = toUniqueId(nation, shell.id);
shellDefinitions[shellId] = {
id: shellId,
name:
strings[shell.userString] ?? shellKey.replaceAll('_', ' '),
speed: turretShellEntry.speed,
damage: {
armor: shell.damage.armor,
devices: shell.damage.devices,
},
caliber: shell.caliber,
normalization: shell.normalizationAngle,
ricochet: shell.ricochetAngle,
type: blitzShellKindToBLitzkrieg[shell.kind],
icon: shell.icon,
};
return shellId;
});
gunDefinitions[gunId] = {
id: gunId,
pitch: (typeof pitchLimitsRaw === 'string'
? pitchLimitsRaw
: pitchLimitsRaw.at(-1)!
)
.split(' ')
.map(Number) as [number, number],
name: strings[gun.userString] ?? gunKey.replaceAll('_', ' '),
tier: gun.level as Tier,
shells: gunShells,
};
return gunId;
});
turretDefinitions[turretId] = {
id: turretId,
name:
strings[turret.userString] ??
turretKey
.replaceAll('_', ' ')
.replace(/^(Turret ([0-9] )?)+/, ''),
tier: turret.level as Tier,
yaw: (typeof turret.yawLimits === 'string'
? turret.yawLimits
: turret.yawLimits.at(-1)!
)
.split(' ')
.map(Number) as [number, number],
guns: turretGuns,
};
return turretId;
},
);
tankDefinitions[tankId] = {
id: tankId,
name:
(tank.shortUserString
? strings[tank.shortUserString]
: undefined) ??
strings[tank.userString] ??
tankKey.replaceAll('_', ' '),
nation,
tree_type: (tank.sellPrice ? 'gold' in tank.sellPrice : false)
? 'collector'
: (typeof tank.price === 'number' ? false : 'gold' in tank.price)
? 'premium'
: 'researchable',
tier: tank.level as Tier,
type: blitzTankTypeToBlitzkrieg[tankTags[0] as BlitzTankType],
testing: tankTags.includes('testTank') ? true : undefined,
turrets: tankTurrets,
};
}
}),
);
console.log('Committing tank definitions...');
await commitMultipleFiles(
'tresabhi',
'blitzkrieg-assets',
'main',
'definitions',
[
{
content: JSON.stringify(tankDefinitions),
encoding: 'utf-8',
path: 'definitions/tanks.json',
},
{
content: JSON.stringify(turretDefinitions),
encoding: 'utf-8',
path: 'definitions/turrets.json',
},
{
content: JSON.stringify(gunDefinitions),
encoding: 'utf-8',
path: 'definitions/guns.json',
},
{
content: JSON.stringify(shellDefinitions),
encoding: 'utf-8',
path: 'definitions/shells.json',
},
],
true,
);
}
if (
allTargets ||
targets?.includes('bigTankIcons') ||
targets?.includes('smallTankIcons')
) {
console.log('Building tank icons...');
const changes: FileChange[] = [];
const nations = await readdir(`${DATA}/${DOI.vehicleDefinitions}`).then(
(nations) => nations.filter((nation) => nation !== 'common'),
);
await Promise.all(
nations.map(async (nation) => {
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/list.xml.dvpl`,
);
for (const tankIndex in tanks.root) {
const tank = tanks.root[tankIndex];
const nationVehicleId = tank.id;
const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1;
const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/${DOI.tankParameters}/${nation}/${tankIndex}.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 (allTargets || targets?.includes('bigTankIcons')) {
changes.push({
content: await readBase64DVPL(big),
encoding: 'base64',
path: `icons/big/${id}.webp`,
});
}
if (allTargets || targets?.includes('smallTankIcons')) {
changes.push({
content: await readBase64DVPL(small),
encoding: 'base64',
path: `icons/small/${id}.webp`,
});
}
}
}),
);
console.log('Committing tank icons...');
await commitMultipleFiles(
'tresabhi',
'blitzkrieg-assets',
'main',
'tank icons',
changes,
true,
);
}
if (allTargets || targets?.includes('scratchedFlags')) {
console.log('Building scratched flags...');
const flags = await readdir(`${DATA}/${DOI.flags}`);
console.log('Committing scratched flags...');
commitMultipleFiles(
'tresabhi',
'blitzkrieg-assets',
'main',
'scratched flags',
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}/${DOI.flags}/${flag}`);
const name = flag.match(/flag_tutor-tank_(.+)\.packed\.webp/)![1];
return {
content,
encoding: 'base64',
path: `flags/scratched/${name}.webp`,
} satisfies FileChange;
}),
),
true,
);
}
if (allTargets || targets?.includes('circleFlags')) {
const flags = await readdir(`${DATA}/${DOI.flags}`);
commitMultipleFiles(
'tresabhi',
'blitzkrieg-assets',
'main',
'circle flags',
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}/${DOI.flags}/${flag}`);
const name = flag.match(/flag_profile-stat_(.+)\.packed\.webp/)![1];
return {
content,
encoding: 'base64',
path: `flags/circle/${name}.webp`,
} satisfies FileChange;
}),
),
true,
);
}
if (allTargets || targets?.includes('tankModels')) {
console.log('Building tank models...');
if (existsSync('dist/assets/models')) {
await rm('dist/assets/models', { recursive: true });
}
await mkdir('dist/assets/models', { recursive: true });
const changes: FileChange[] = [];
const nations = await readdir(`${DATA}/${DOI.vehicleDefinitions}`).then(
(nations) => nations.filter((nation) => nation !== 'common'),
);
await Promise.all(
nations.map(async (nation) => {
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/list.xml.dvpl`,
);
for (const tankIndex in tanks.root) {
const tank = tanks.root[tankIndex];
const nationVehicleId = tank.id;
const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1;
// if (id !== 897) continue;
console.log(`Building model ${id} @ ${nation}/${tankIndex}`);
const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/${DOI.tankParameters}/${nation}/${tankIndex}.yaml.dvpl`,
);
const sc2Path = parameters.resourcesPath.blitzModelPath;
const scgPath = parameters.resourcesPath.blitzModelPath.replace(
/\.sc2$/,
'.scg',
);
const sc2Stream = await SCPGStream.fromDVPLFile(
`${DATA}/${DOI['3d']}/${sc2Path}.dvpl`,
);
const scgStream = await SCPGStream.fromDVPLFile(
`${DATA}/${DOI['3d']}/${scgPath}.dvpl`,
);
const sc2 = sc2Stream.consumeSC2();
const scg = scgStream.consumeSCG();
}
}),
);
}
if (allTargets || targets?.includes('shellIcons')) {
console.log('Building shell icons...');
const image = sharp(
await readDVPLFile(
`${DATA}/${DOI.bigShellIcons}/texture0.packed.webp.dvpl`,
),
);
const changes = await Promise.all(
(await readdir(`${DATA}/${DOI.bigShellIcons}`))
.filter((file) => file.endsWith('_l.txt.dvpl'))
.map(async (file) => {
const name = file.match(/(.+)_l\.txt\.dvpl/)![1];
const sizes = (
await readStringDVPL(`${DATA}/${DOI.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: `shells/${name}.webp`,
} satisfies FileChange;
}),
);
console.log('Committing shell icons...');
commitMultipleFiles(
'tresabhi',
'blitzkrieg-assets',
'main',
'shell icons',
changes,
true,
);
}
+48
View File
@@ -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);
}
+20
View File
@@ -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);
}
+29
View File
@@ -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);
}
+39
View File
@@ -0,0 +1,39 @@
import { readdir } from 'fs/promises';
import { argv } from 'process';
const isDepot = argv.includes('--depot');
export const DATA = isDepot
? await (async () => {
const [installationVersion] = await readdir('depots/444202');
return `depots/444202/${installationVersion}/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
+93
View File
@@ -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);
}
+45
View File
@@ -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);
}
+32
View File
@@ -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);
}
+31
View File
@@ -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);
}
+49
View File
@@ -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);
}
+49
View File
@@ -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);
}
+80
View File
@@ -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);
}
+77
View File
@@ -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 -3
View File
@@ -1,6 +1,5 @@
import { Octokit } from '@octokit/rest';
import { rm, writeFile } from 'fs/promises';
import { argv, env } from 'process';
import { argv } from 'process';
import {
RatingsInfo,
RatingsNeighbors,
@@ -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/`,
);
+33
View File
@@ -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'));
+4 -10
View File
@@ -3,13 +3,12 @@
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 {
BlitzkriegTankDefinition,
TankDefinition,
tankDefinitions,
} from '../../../core/blitzkrieg/definitions/tanks';
} from '../../../core/blitzkrieg/tankDefinitions';
import { tankAverages } from '../../../core/blitzstars/tankAverages';
import calculateWN8 from '../../../core/statistics/calculateWN8';
import { deltaTankStats } from '../../../core/statistics/deltaTankStats';
@@ -37,7 +36,7 @@ export default function SessionPage({ naked = true }: SessionPageProps) {
list: {
stats: IndividualTankStats;
tankDefinitions?: BlitzkriegTankDefinition;
tankDefinitions?: TankDefinition;
career: IndividualTankStats;
currentWN8?: number;
careerWN8?: number;
@@ -313,12 +312,7 @@ export default function SessionPage({ naked = true }: SessionPageProps) {
}
type="tank"
tankType={tankDefinitions?.type}
treeType={(() => {
if (tankDefinitions?.tree_type === 'collector')
return TreeTypeEnum.Collector;
if (tankDefinitions?.tree_type === 'premium')
return TreeTypeEnum.Premium;
})()}
treeType={tankDefinitions?.treeType}
stats={session.customColumns.map((customColumn) => {
const rowStat = rowStats[customColumn.display];
+106 -5
View File
@@ -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}>
@@ -37,11 +57,10 @@ export default function RootLayout({ children }: RootLayoutProps) {
style={{
margin: 0,
paddingTop: isEmbed ? 0 : '3.25rem',
background: isEmbed
? 'transparent'
: 'url(https://i.imgur.com/PhS06NJ.png)',
}}
>
<Analytics />
<Theme
appearance="dark"
panelBackground="translucent"
@@ -50,8 +69,90 @@ export default function RootLayout({ children }: RootLayoutProps) {
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>
<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
View File
@@ -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
View File
@@ -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
}
+52
View File
@@ -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
View File
@@ -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(2);
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>
</>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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,653 @@
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.type === 'light' || tank.type === 'medium'),
0.05,
],
[
hasEngineAccelerator &&
(tank.type === 'heavy' || tank.type === 'tankDestroyer'),
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.type === 'tankDestroyer' ? 0.05 : tank.type === 'heavy' ? 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.type === 'heavy'
? 0.03
: tank.type === 'tankDestroyer'
? 0.07
: 0.05,
],
[
camouflage,
tank.type === 'tankDestroyer'
? 0.04
: tank.type === 'heavy'
? 0.03
: 0.02,
],
);
const camouflageSumStill = sum(
[
hasCamouflageNet,
2 *
(tank.type === 'heavy'
? 0.03
: tank.type === 'tankDestroyer'
? 0.07
: 0.05),
],
[
camouflage,
tank.type === 'tankDestroyer'
? 0.04
: tank.type === 'heavy'
? 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,259 @@
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 { ShotDisplay } from '../../../../../../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 { TankArmor } from './components/TankArmor';
import { ExternalModuleMask } from './components/TankArmor/components/ExternalModuleMask';
import { SpacedArmorDepth } from './components/TankArmor/components/SpacedArmorDepth';
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 />}>
<ShotDisplay />
<Lighting />
<TankModel />
<TankArmor />
<ExternalModuleMask ornamental />
<SpacedArmorDepth ornamental />
</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,164 @@
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 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.CheckboxItem
checked={greenPenetration}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.greenPenetration = checked;
});
}}
>
Green penetration
</DropdownMenu.CheckboxItem>
<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.CheckboxItem
checked={wireframe}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.wireframe = checked;
});
}}
>
Wireframe
</DropdownMenu.CheckboxItem>
</DropdownMenu.Content>
</DropdownMenu.Root>
)}
{fullScreenAvailable && (
<Button
variant="ghost"
onClick={() => {
if (isFullScreen) {
document.exitFullscreen();
} else canvasWrapper.current?.requestFullscreen();
}}
>
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
</Button>
)}
</Flex>
);
}
@@ -0,0 +1,213 @@
import { useEffect, useRef } from 'react';
import { Euler, Group, Mesh, Vector3 } from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { ArmorMesh } from '../../../../../../../../../components/ArmorMesh';
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../../../constants/axis';
import {
ModelTransformEventData,
modelTransformEvent,
} from '../../../../../../../../../core/blitzkrieg/modelTransform';
import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
import { useArmor } from '../../../../../../../../../hooks/useArmor';
import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions';
import { useDuel } from '../../../../../../../../../stores/duel';
import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia';
export function ArmorHighlighting() {
const protagonist = useDuel((state) => state.protagonist!);
const wrapper = useRef<Group>(null);
const modelDefinitions = useModelDefinitions();
const gunContainer = useRef<Group>(null);
const turretContainer = useRef<Group>(null);
const initialTankopediaState = useTankopediaPersistent.getState();
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 turretPosition = new Vector3();
const turretRotation = new Euler();
const gunPosition = new Vector3();
const gunRotation = new Euler();
function handleModelTransform({ pitch, yaw }: ModelTransformEventData) {
if (yaw === undefined) return;
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);
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);
}
const unsubscribe = useTankopediaPersistent.subscribe(
(state) => state.mode,
(mode) => {
if (wrapper.current) wrapper.current.visible = mode === 'armor';
},
);
handleModelTransform(protagonist);
modelTransformEvent.on(handleModelTransform);
return () => {
modelTransformEvent.off(handleModelTransform);
unsubscribe();
};
});
const armorGltf = useArmor(protagonist.tank.id);
const armorNodes = Object.values(armorGltf.nodes);
const tankModelDefinition = modelDefinitions[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 hullOrigin = new Vector3(
trackModelDefinition.origin[0],
trackModelDefinition.origin[1],
-trackModelDefinition.origin[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 maxThickness = Math.max(
trackModelDefinition.thickness,
gunModelDefinition.thickness,
...armorNodes
.map((node) => {
const armorId = nameToArmorId(node.name);
return resolveArmor(tankModelDefinition.armor, armorId).thickness;
})
.filter(Boolean),
);
return (
<group
ref={wrapper}
rotation={[-Math.PI / 2, 0, 0]}
visible={initialTankopediaState.mode === 'armor'}
position={hullOrigin}
>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
if (!isVisible || thickness === undefined || spaced) return null;
return (
<ArmorMesh
maxThickness={maxThickness}
key={node.uuid}
geometry={(node as Mesh).geometry}
thickness={thickness}
/>
);
})}
<group ref={turretContainer}>
{armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
turretModelDefinition.armor,
armorId,
);
if (!isVisible || thickness === undefined || spaced) return null;
return (
<ArmorMesh
maxThickness={maxThickness}
key={node.uuid}
geometry={(node as Mesh).geometry}
position={turretOrigin}
thickness={thickness}
/>
);
})}
<group ref={gunContainer}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor,
armorId,
);
if (!isVisible || thickness === undefined || spaced) return null;
return (
<ArmorMesh
maxThickness={maxThickness}
key={node.uuid}
geometry={(node as Mesh).geometry}
position={turretOrigin.clone().add(gunOrigin)}
thickness={thickness}
/>
);
})}
</group>
</group>
</group>
);
}
@@ -0,0 +1,286 @@
import { memo, useEffect, useRef } from 'react';
import { Euler, Group, Vector3 } from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { ArmorMeshExternalModuleMask } from '../../../../../../../../../components/ArmorMesh';
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../../../constants/axis';
import {
ModelTransformEventData,
modelTransformEvent,
} from '../../../../../../../../../core/blitzkrieg/modelTransform';
import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
import { useArmor } from '../../../../../../../../../hooks/useArmor';
import { useModel } from '../../../../../../../../../hooks/useModel';
import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions';
import { useDuel } from '../../../../../../../../../stores/duel';
import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia';
interface ExternalModuleMaskProps {
ornamental?: boolean;
}
/**
* When rendered, generates a mask and thickness buffer for external modules.
* - R: 1 means external module
* - G: normalized thickness; multiply with max thickness to get nominal thickness
* - B: no data
* - A: 1 means is armor; 0 is background
*/
export const ExternalModuleMask = memo<ExternalModuleMaskProps>(
({ ornamental = false }) => {
const protagonist = useDuel((state) => state.protagonist!);
const wrapper = useRef<Group>(null);
const modelDefinitions = useModelDefinitions();
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null);
const initialTankopediaState = useTankopediaPersistent.getState();
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(turretOrigin)
.sub(hullOrigin)
.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);
};
});
useEffect(() => {
const unsubscribe = useTankopediaPersistent.subscribe(
(state) => state.mode,
(mode) => {
if (wrapper.current) wrapper.current.visible = mode === 'armor';
},
);
return unsubscribe;
});
const armorGltf = useArmor(protagonist.tank.id);
const { gltf: modelGltf } = useModel(protagonist.tank.id);
const armorNodes = Object.values(armorGltf.nodes);
const modelNodes = Object.values(modelGltf.nodes);
const tankModelDefinition = modelDefinitions[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 maxThickness = Math.max(
trackModelDefinition.thickness,
gunModelDefinition.thickness,
...armorNodes
.map((node) => {
const armorId = nameToArmorId(node.name);
return resolveArmor(tankModelDefinition.armor, armorId).thickness;
})
.filter(Boolean),
);
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);
return (
<group
ref={wrapper}
rotation={[-Math.PI / 2, 0, 0]}
visible={initialTankopediaState.mode === 'armor'}
>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { thickness, spaced } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
if (!isVisible || thickness === undefined || spaced) return null;
return (
<ArmorMeshExternalModuleMask
ornamental={ornamental}
exclude
key={node.uuid}
node={node}
/>
);
})}
</group>
{modelNodes.map((node) => {
const isWheel = node.name.startsWith('chassis_wheel_');
const isTrack = node.name.startsWith('chassis_track_');
const isVisible = isWheel || isTrack;
if (!isVisible) return null;
return (
<ArmorMeshExternalModuleMask
ornamental={ornamental}
maxThickness={maxThickness}
thickness={trackModelDefinition.thickness}
key={node.uuid}
node={node}
/>
);
})}
<group ref={turretContainer}>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name);
const { thickness, spaced } = resolveArmor(
turretModelDefinition.armor,
armorId,
);
if (!isVisible || thickness === undefined || spaced) return null;
return (
<group key={node.uuid} position={turretOrigin}>
<ArmorMeshExternalModuleMask
ornamental={ornamental}
exclude
node={node}
/>
</group>
);
})}
</group>
<group ref={gunContainer}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name);
const { thickness, spaced } = resolveArmor(
gunModelDefinition.armor,
armorId,
);
if (!isVisible || thickness === undefined || spaced) return null;
return (
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<ArmorMeshExternalModuleMask
ornamental={ornamental}
exclude
node={node}
/>
</group>
);
})}
{modelNodes.map((node) => {
const isCurrentGun =
node.name ===
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
const isVisible = isCurrentGun;
if (!isVisible) return null;
return (
<ArmorMeshExternalModuleMask
ornamental={ornamental}
maxThickness={maxThickness}
thickness={gunModelDefinition.thickness}
key={node.uuid}
node={node}
/>
);
})}
</group>
</group>
</group>
);
},
);
@@ -0,0 +1,299 @@
import { memo, useEffect, useRef } from 'react';
import { Euler, Group, Vector3 } from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { ArmorMeshSpacedArmorDepth } from '../../../../../../../../../components/ArmorMesh/components/SpacedArmorDepth';
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../../../constants/axis';
import {
ModelTransformEventData,
modelTransformEvent,
} from '../../../../../../../../../core/blitzkrieg/modelTransform';
import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
import { useArmor } from '../../../../../../../../../hooks/useArmor';
import { useModel } from '../../../../../../../../../hooks/useModel';
import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions';
import { useDuel } from '../../../../../../../../../stores/duel';
import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia';
interface SpacedArmorDepthProps {
ornamental?: boolean;
}
/**
* When rendered, this component provides the depth buffer for the armor which
* when compared to the depth of the core armor will give you the distance
* between the first point of contact and the core armor. The color channels
* hold information about the thickness of the spaced armor (external modules
* not included).
* - R: angle of the armor where 0 is 0 and 1 is 90
* - G: normalized thickness; multiply with max thickness to get nominal thickness
* - B: no data
* - A: 1 means is armor; 0 is background
*/
export const SpacedArmorDepth = memo<SpacedArmorDepthProps>(
({ ornamental = false }) => {
const protagonist = useDuel((state) => state.protagonist!);
const wrapper = useRef<Group>(null);
const modelDefinitions = useModelDefinitions();
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null);
const initialTankopediaState = useTankopediaPersistent.getState();
useEffect(() => {
if (!modelDefinitions) return;
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);
};
});
useEffect(() => {
const unsubscribe = useTankopediaPersistent.subscribe(
(state) => state.mode,
(mode) => {
if (wrapper.current) wrapper.current.visible = mode === 'armor';
},
);
return unsubscribe;
});
const armorGltf = useArmor(protagonist.tank.id);
const { gltf: modelGltf } = useModel(protagonist.tank.id);
const armorNodes = Object.values(armorGltf.nodes);
const modelNodes = Object.values(modelGltf.nodes);
const tankModelDefinition = modelDefinitions[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 maxThickness = Math.max(
trackModelDefinition.thickness,
gunModelDefinition.thickness,
...armorNodes
.map((node) => {
const armorId = nameToArmorId(node.name);
return resolveArmor(tankModelDefinition.armor, armorId).thickness;
})
.filter(Boolean),
);
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);
return (
<group
ref={wrapper}
rotation={[-Math.PI / 2, 0, 0]}
visible={initialTankopediaState.mode === 'armor'}
>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
if (!isVisible || thickness === undefined) return null;
return (
<ArmorMeshSpacedArmorDepth
ornamental={ornamental}
include={spaced}
isExternalModule={false}
thickness={thickness}
maxThickness={maxThickness}
key={node.uuid}
node={node}
/>
);
})}
</group>
{modelNodes.map((node) => {
const isWheel = node.name.startsWith('chassis_wheel_');
const isTrack = node.name.startsWith('chassis_track_');
const isVisible = isWheel || isTrack;
if (!isVisible) return null;
return (
<ArmorMeshSpacedArmorDepth
ornamental={ornamental}
isExternalModule
key={node.uuid}
node={node}
/>
);
})}
<group ref={turretContainer}>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
turretModelDefinition.armor,
armorId,
);
if (!isVisible || thickness === undefined) return null;
return (
<group key={node.uuid} position={turretOrigin}>
<ArmorMeshSpacedArmorDepth
ornamental={ornamental}
include={spaced}
isExternalModule={false}
thickness={thickness}
maxThickness={maxThickness}
node={node}
/>
</group>
);
})}
</group>
<group ref={gunContainer}>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor,
armorId,
);
if (!isVisible || thickness === undefined) return null;
return (
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<ArmorMeshSpacedArmorDepth
ornamental={ornamental}
include={spaced}
isExternalModule={false}
thickness={thickness}
maxThickness={maxThickness}
node={node}
/>
</group>
);
})}
</group>
{modelNodes.map((node) => {
const isCurrentGun =
node.name ===
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
const isVisible = isCurrentGun;
if (!isVisible) return null;
return (
<ArmorMeshSpacedArmorDepth
ornamental={ornamental}
isExternalModule
key={node.uuid}
node={node}
/>
);
})}
</group>
</group>
</group>
);
},
);
@@ -0,0 +1,67 @@
import { createPortal, useFrame } from '@react-three/fiber';
import { memo } from 'react';
import { DepthTexture, Scene, Vector2 } from 'three';
import { externalModuleMaskRenderTarget } from '../../../../../../../../components/ArmorMesh';
import { spacedArmorDepthRenderTarget } from '../../../../../../../../components/ArmorMesh/components/SpacedArmorDepth';
import { ArmorHighlighting } from './components/ArmorHighlighting';
import { ExternalModuleMask } from './components/ExternalModuleMask';
import { SpacedArmorDepth } from './components/SpacedArmorDepth';
export const TankArmor = memo(() => {
const externalModuleMaskScene = new Scene();
const spacedArmorDepthScene = new Scene();
const armorHighlightingScene = new Scene();
const externalModuleMaskPortal = createPortal(
<ExternalModuleMask />,
externalModuleMaskScene,
);
const spacedArmorDepthPortal = createPortal(
<SpacedArmorDepth />,
spacedArmorDepthScene,
);
const armorHighlightingPortal = createPortal(
<ArmorHighlighting />,
armorHighlightingScene,
);
const renderSize = new Vector2();
const newRenderSize = new Vector2();
useFrame(({ gl, scene, camera }) => {
const pixelRatio = gl.getPixelRatio();
gl.autoClear = true;
gl.getSize(newRenderSize).multiplyScalar(pixelRatio);
if (!newRenderSize.equals(renderSize)) {
renderSize.copy(newRenderSize);
spacedArmorDepthRenderTarget.depthTexture = new DepthTexture(
renderSize.x,
renderSize.y,
);
}
externalModuleMaskRenderTarget.setSize(renderSize.x, renderSize.y);
gl.setRenderTarget(externalModuleMaskRenderTarget);
gl.render(externalModuleMaskScene, camera);
gl.clearDepth();
spacedArmorDepthRenderTarget.setSize(renderSize.x, renderSize.y * 0.5);
gl.setRenderTarget(spacedArmorDepthRenderTarget);
gl.render(spacedArmorDepthScene, camera);
gl.autoClear = false;
gl.setRenderTarget(null);
gl.render(scene, camera);
gl.clearDepth();
gl.render(armorHighlightingScene, camera);
}, 1);
return (
<>
{externalModuleMaskPortal}
{spacedArmorDepthPortal}
{armorHighlightingPortal}
</>
);
});
@@ -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 { normalizeAnglePI } from '../../../../../../../core/math/normalizeAngle180';
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 = normalizeAnglePI(pitch);
draft.protagonist!.yaw = normalizeAnglePI(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 = normalizeAnglePI(pitch);
draft.protagonist!.yaw = normalizeAnglePI(yaw);
});
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
}
return jsxTree(node, {
castShadow: true,
receiveShadow: true,
onPointerDown,
});
})}
</group>
</group>
</group>
);
});
@@ -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,32 @@
import { Color } from 'three';
import InfiniteGridHelper from '../../../../../components/InfiniteGridHelper';
import { useTankopediaPersistent } from '../../../../../stores/tankopedia';
export function SceneProps() {
const showGrid = useTankopediaPersistent(
(state) => state.model.visual.showGrid,
);
const showEnvironment = useTankopediaPersistent(
(state) => state.model.visual.showEnvironment,
);
if (!showGrid || showEnvironment) return null;
return (
<>
<InfiniteGridHelper
size1={1 / 5}
size2={1}
distance={20}
color={new Color('#ffffff')}
/>
<InfiniteGridHelper
position={[0, 1e-4, 0]}
size1={0}
size2={100}
distance={25}
color={new Color('red')}
/>
</>
);
}
@@ -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.type
]
}
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>
</>
);
}
+93 -234
View File
@@ -1,250 +1,109 @@
'use client';
import { ArrowLeftIcon, CaretRightIcon } from '@radix-ui/react-icons';
import {
Button,
Card,
Checkbox,
Dialog,
Flex,
Heading,
Slider,
Tabs,
Text,
Theme,
Tooltip,
} from '@radix-ui/themes';
import { OrbitControls } from '@react-three/drei';
import { Canvas } from '@react-three/fiber';
import Link from 'next/link';
import { use, useEffect, useState } from 'react';
import { Flag } from '../../../../components/Flag';
import { Flex } from '@radix-ui/themes';
import { useEffect } from 'react';
import PageWrapper from '../../../../components/PageWrapper';
import { asset } from '../../../../core/blitzkrieg/asset';
import { gunDefinitions } from '../../../../core/blitzkrieg/definitions/guns';
import { shellDefinitions } from '../../../../core/blitzkrieg/definitions/shells';
import {
TIER_ROMAN_NUMERALS,
tankDefinitions,
tanksDefinitionsArray,
} from '../../../../core/blitzkrieg/definitions/tanks';
import { turretDefinitions } from '../../../../core/blitzkrieg/definitions/turrets';
import * as styles from '../page.css';
type Mode = 'model' | 'armor';
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 id = parseInt(params.id);
const awaitedTankDefinitions = use(tankDefinitions);
const awaitedTanksDefinitionsArray = use(tanksDefinitionsArray);
const awaitedTurretDefinitions = use(turretDefinitions);
const awaitedGunDefinitions = use(gunDefinitions);
const awaitedShellDefinitions = use(shellDefinitions);
const tank = awaitedTankDefinitions[id];
const [turret, setTurret] = useState(
awaitedTurretDefinitions[tank.turrets[0]],
);
const [gun, setGun] = useState(awaitedGunDefinitions[turret.guns[0]]);
const [crew, setCrew] = useState(100);
const [versusTank, setVersusTank] = useState(
awaitedTanksDefinitionsArray[0].id,
);
const [mode, setMode] = useState<Mode>('model');
const initialId = parseInt(params.id);
const assigned = useDuel((state) => state.assigned);
const wideFormat = useWideFormat();
console.log(versusTank);
useEffect(() => {
assignDuelMember('both', initialId);
useEffect(() => setGun(awaitedGunDefinitions[turret.guns[0]]), [turret]);
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>
<Flex gap="8" direction="column">
<Flex gap="4" direction="column">
<Flex justify="between" align="center">
<Link
href="/tools/tankopedia"
style={{ color: 'unset', textDecoration: 'none' }}
<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,
}}
>
<Flex gap="1" align="center">
<ArrowLeftIcon />
<Text>Back to tankopedia</Text>
</Flex>
</Link>
<TankSandbox />
<AntagonistBar />
<Flex gap="2" align="center">
<Flag nation={tank.nation} />
<Heading>{tank.name}</Heading>
<Flex direction="column" gap="4">
<Modules />
<Provisions />
<Equipments />
<Miscellaneous />
<Consumables />
</Flex>
</Flex>
<Flex style={{ width: wideFormat ? 320 : '100%' }}>
<Characteristics />
</Flex>
</Flex>
<Card style={{ position: 'relative' }}>
<Flex direction="column" gap="2">
<Tabs.Root
value={mode}
onValueChange={(value) => setMode(value as Mode)}
>
<Tabs.List>
<Tabs.Trigger value="model">Model</Tabs.Trigger>
<Tabs.Trigger value="armor">Armor</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<div style={{ height: '50vh', maxHeight: 576 }}>
<Canvas onPointerDown={(event) => event.preventDefault()}>
<OrbitControls />
<ambientLight />
<directionalLight />
<mesh>
<torusKnotGeometry args={[undefined, undefined, 128, 16]} />
<meshStandardMaterial color="cyan" />
</mesh>
</Canvas>
</div>
{mode === 'armor' && (
<Flex gap="2" align="center" className={styles.enhancedArmor}>
<Checkbox defaultChecked />
<Text>Enhanced armor</Text>
</Flex>
)}
</Flex>
</Card>
{mode === 'armor' && (
<Card>
<Flex align="center" justify="between" gap="2">
<Flex align="center" gap="4">
<Text>Versus</Text>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="ghost">
{awaitedTankDefinitions[versusTank].name}
<CaretRightIcon />
</Button>
</Dialog.Trigger>
<Dialog.Content>Do something lol</Dialog.Content>
</Dialog.Root>
</Flex>
<Flex gap="2" align="center">
<Checkbox defaultChecked />
<Text>Calibrated shells</Text>
</Flex>
<Flex gap="1">
<Button variant="solid" radius="small">
<img src={asset('shells/ap.webp')} width={24} height={24} />
</Button>
<Button variant="soft" radius="small" color="gray">
<img
src={asset('shells/hc_premium.webp')}
width={24}
height={24}
/>
</Button>
<Button variant="soft" radius="small" color="gray">
<img src={asset('shells/he.webp')} width={24} height={24} />
</Button>
</Flex>
</Flex>
</Card>
)}
</Flex>
<Flex gap="6" direction="column">
<Heading>Build</Heading>
<Flex gap="2" direction="column">
<Heading size="5" weight="regular">
Modules
</Heading>
<Theme radius="small">
<Flex gap="4">
<Flex gap="1">
{tank.turrets.map((turretId) => {
const thisTurret = awaitedTurretDefinitions[turretId];
return (
<Tooltip content={thisTurret.name}>
<Button
onClick={() => setTurret(thisTurret)}
variant={
turret.id === thisTurret.id ? 'solid' : 'soft'
}
>
<img
src="/images/modules/turret.png"
width={32}
height={32}
/>
{TIER_ROMAN_NUMERALS[thisTurret.tier]}
</Button>
</Tooltip>
);
})}
</Flex>
<Flex gap="1">
{turret.guns.map((gunId, index) => {
const thisGun = awaitedGunDefinitions[gunId];
return (
<Tooltip content={thisGun.name}>
<Button
onClick={() => setGun(thisGun)}
variant={gun.id === thisGun.id ? 'solid' : 'soft'}
>
<img
src="/images/modules/gun.png"
width={32}
height={32}
/>
{TIER_ROMAN_NUMERALS[thisGun.tier]}
</Button>
</Tooltip>
);
})}
</Flex>
</Flex>
</Theme>
</Flex>
<Flex gap="2" direction="column">
<Heading size="5" weight="regular">
Crew
</Heading>
<Flex gap="4" align="center">
<Text>{crew}%</Text>
<Slider
style={{ flex: 1 }}
value={[crew]}
min={50}
max={100}
onValueChange={([crew]) => setCrew(crew)}
/>
</Flex>
</Flex>
</Flex>
<Flex gap="2" direction="column">
<Heading size="5">Shells</Heading>
{gun.shells.map((shellId) => {
const shell = awaitedShellDefinitions[shellId];
return (
<Text>
{shell.name} ({shell.type}): {shell.damage.armor}
</Text>
);
})}
</Flex>
</Flex>
</>
)}
</PageWrapper>
);
}
@@ -1,388 +0,0 @@
'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_TYPES,
TREE_TYPES,
TREE_TYPE_ICONS,
TREE_TYPE_IMAGES,
TreeTypeEnum,
} from '../../../../../components/Tanks';
import {
NATIONS,
TIERS,
TIER_ROMAN_NUMERALS,
} from '../../../../../core/blitzkrieg/definitions/tanks';
import mutateTankopedia, {
TankopediaTestTankDisplay,
useTankopedia,
} from '../../../../../stores/tankopedia';
import * as styles from './index.css';
export function Options() {
const nations = use(NATIONS);
const tankopediaState = useTankopedia();
return (
<Flex justify="center" direction="column" gap="2">
<Flex gap="2" style={{ flex: 1 }} wrap="wrap">
<Button
color="red"
onClick={() =>
useTankopedia.setState({
filters: {
nations: [],
tiers: [],
treeTypes: [],
types: [],
test: 'include',
},
})
}
>
Clear
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft">
Test tanks
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup
value={tankopediaState.filters.test}
onValueChange={(value) =>
mutateTankopedia((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 style={{ flex: 1 }}>
{TREE_TYPES.map((type, index) => (
<Button
key={type}
variant={
tankopediaState.filters.treeTypes.includes(type)
? 'solid'
: '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={() =>
mutateTankopedia((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 style={{ flex: 1 }}>
{[...TANK_TYPES].reverse().map((type, index) => (
<Button
key={type}
variant={
tankopediaState.filters.types.includes(type) ? 'solid' : 'soft'
}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius:
index === TANK_TYPES.length - 1 ? undefined : 0,
borderBottomRightRadius:
index === TANK_TYPES.length - 1 ? undefined : 0,
}}
onClick={() =>
mutateTankopedia((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[TreeTypeEnum.TechTree][type]}
style={{ width: '1em', height: '1em' }}
/>
</Button>
))}
</Flex>
<Flex
direction="column"
className={styles.splitFilters}
style={{ width: '100%' }}
>
<Flex>
{[...TIERS]
.reverse()
.slice(0, 5)
.map((tier, index) => (
<Button
key={tier}
variant={
tankopediaState.filters.tiers.includes(tier)
? 'solid'
: 'soft'
}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: 0,
borderTopRightRadius:
index === TIERS.length - Math.round(TIERS.length / 2) - 1
? undefined
: 0,
borderBottomRightRadius: 0,
}}
onClick={() =>
mutateTankopedia((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]
.reverse()
.slice(5, 10)
.map((tier, index) => (
<Button
key={tier}
variant={
tankopediaState.filters.tiers.includes(tier)
? 'solid'
: 'soft'
}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius: 0,
borderBottomRightRadius:
index === TIERS.length - Math.round(TIERS.length / 2) - 1
? undefined
: 0,
}}
onClick={() =>
mutateTankopedia((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}
variant={
tankopediaState.filters.nations.includes(nation)
? 'solid'
: 'soft'
}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: 0,
borderTopRightRadius:
index === nations.length - Math.round(nations.length / 2)
? undefined
: 0,
borderBottomRightRadius:
index === nations.length - 1 ? undefined : 0,
}}
onClick={() =>
mutateTankopedia((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, 10).map((nation, index) => (
<Button
key={nation}
variant={
tankopediaState.filters.nations.includes(nation)
? 'solid'
: 'soft'
}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius:
index === nations.length - 1 ? undefined : 0,
borderBottomRightRadius:
index ===
nations.length - Math.round(nations.length / 2) - 1
? undefined
: 0,
}}
onClick={() =>
mutateTankopedia((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>
<Flex gap="2" style={{ flex: 1 }} wrap="wrap">
<Flex className={styles.oneLineFilters} style={{ flex: 1 }}>
{[...TIERS].reverse().map((tier, index) => (
<Button
key={tier}
variant={
tankopediaState.filters.tiers.includes(tier) ? 'solid' : '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={() =>
mutateTankopedia((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 className={styles.oneLineFilters} style={{ flex: 1 }}>
{nations.map((nation, index) => (
<Button
key={nation}
variant={
tankopediaState.filters.nations.includes(nation)
? 'solid'
: '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={() =>
mutateTankopedia((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>
);
}
@@ -1,21 +1,18 @@
import { CaretLeftIcon, CaretRightIcon } from '@radix-ui/react-icons';
import { Button, Flex, TextField } from '@radix-ui/themes';
import { useEffect, useRef } from 'react';
import { BlitzkriegTankDefinition } from '../../../../core/blitzkrieg/definitions/tanks';
import { TankDefinition } from '../../../../core/blitzkrieg/tankDefinitions';
import mutateTankopediaPersistent, {
useTankopediaPersistent,
} from '../../../../stores/tankopedia';
interface PageTurnerProps {
page: number;
setPage: (page: number) => void;
searchedList: BlitzkriegTankDefinition[];
searchedList: TankDefinition[];
tanksPerPage: number;
}
export function PageTurner({
page,
setPage,
searchedList,
tanksPerPage,
}: PageTurnerProps) {
export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
const page = useTankopediaPersistent((state) => state.filters.page);
const pageInput = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -27,7 +24,11 @@ export function PageTurner({
<Button
variant="soft"
disabled={page === 0}
onClick={() => setPage(Math.max(0, page - 1))}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.max(0, page - 1);
});
}}
>
<CaretLeftIcon />
</Button>
@@ -41,15 +42,15 @@ export function PageTurner({
max={Math.floor(searchedList.length / tanksPerPage) + 1}
style={{ width: 64, textAlign: 'center' }}
onBlur={(event) => {
setPage(
Math.max(
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') {
@@ -64,11 +65,14 @@ export function PageTurner({
<Button
variant="soft"
disabled={Math.floor(searchedList.length / tanksPerPage) === page}
onClick={() =>
setPage(
Math.min(Math.floor(searchedList.length / tanksPerPage), page + 1),
)
}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.min(
Math.floor(searchedList.length / tanksPerPage),
page + 1,
);
});
}}
>
<CaretRightIcon />
</Button>
@@ -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,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_TYPES,
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_TYPES].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_TYPES.length - 1 ? undefined : 0,
borderBottomRightRadius:
index === TANK_TYPES.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,896 @@
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 { TankType, 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 tankTypeOrder: TankType[] = ['light', 'medium', 'heavy', 'tankDestroyer'];
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) =>
(tankTypeOrder.indexOf(a.type) - tankTypeOrder.indexOf(b.type)) *
(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.type)) &&
(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.type]
}
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} />
</>
);
}
+42 -11
View File
@@ -7,18 +7,24 @@ export const flag = style({
transitionDuration: theme.durations.regular,
left: '0',
top: '0',
scale: 1.5,
opacity: 0.75,
[`.${listing}:hover &, .${listing}:active &`]: {
transform: 'translateX(-25%)',
filter: 'blur(3rem)',
opacity: 1,
},
// [`.${listing}:hover &, .${listing}:active &`]: {
// transform: 'translateX(-25%)',
// filter: 'blur(3rem)',
// },
});
export const listingShadow = style({
boxShadow: `inset 128px -128px 128px -128px ${theme.colors.appBackground1}`,
boxShadow: `inset 64px -80px 64px -64px ${theme.colors.appBackground1}`,
[`.${listing}:hover &, .${listing}:active &`]: {
boxShadow: `inset 0 0 0 -128px ${theme.colors.appBackground1}`,
boxShadow: `inset 0 0 0 -64px ${theme.colors.appBackground1}`,
},
});
@@ -26,22 +32,23 @@ export const listingLabel = style({
transition: `transform ${theme.durations.regular}`,
transform: 'translateY(0)',
[`.${listing}:hover &, .${listing}:active &`]: {
transform: 'translateY(200%)',
},
// [`.${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%)',
},
// [`.${listing}:hover &, .${listing}:active &`]: {
// transform: 'translate(-25%, 10%)',
// },
});
export const enhancedArmor = style({
export const alignTopRight = style({
'@media': {
'screen and (min-width: 400px)': {
position: 'absolute',
@@ -55,3 +62,27 @@ export const enhancedArmor = style({
},
},
});
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,
});
+9 -319
View File
@@ -1,329 +1,19 @@
'use client';
import { slateDark } from '@radix-ui/colors';
import {
CaretDownIcon,
CaretUpIcon,
MagnifyingGlassIcon,
} from '@radix-ui/react-icons';
import {
Button,
Card,
DropdownMenu,
Flex,
Inset,
Text,
TextField,
} from '@radix-ui/themes';
import { go } from 'fuzzysort';
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import PageWrapper from '../../../components/PageWrapper';
import { asset } from '../../../core/blitzkrieg/asset';
import {
BlitzkriegTankDefinition,
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
TIER_ROMAN_NUMERALS,
tanksDefinitionsArray,
} from '../../../core/blitzkrieg/definitions/tanks';
import { tankIcon } from '../../../core/blitzkrieg/tankIcon';
import { theme } from '../../../stitches.config';
import mutateTankopedia, {
TankopediaSortBy,
TankopediaSortDirection,
useTankopedia,
} from '../../../stores/tankopedia';
import { Options } from './components/Options';
import { PageTurner } from './components/PageTurner';
import * as styles from './page.css';
const TANKS_PER_PAGE = 3 * 2 ** 3;
import { TankSearch } from './components/TankSearch';
export default function Page() {
const tankopediaState = useTankopedia();
const awaitedTanks = use(tanksDefinitionsArray);
const input = useRef<HTMLInputElement>(null);
const searchableTanks = useMemo(
() =>
awaitedTanks
.filter(
(tank) =>
(tankopediaState.filters.tiers.length === 0
? true
: tankopediaState.filters.tiers.includes(tank.tier)) &&
(tankopediaState.filters.types.length === 0
? true
: tankopediaState.filters.types.includes(tank.type)) &&
(tankopediaState.filters.treeTypes.length === 0
? true
: tankopediaState.filters.treeTypes.includes(tank.tree_type)) &&
(tankopediaState.filters.nations.length === 0
? true
: tankopediaState.filters.nations.includes(tank.nation)) &&
(tankopediaState.filters.test === 'include'
? true
: tankopediaState.filters.test === 'exclude'
? !tank.testing
: tank.testing),
)
.sort((a, b) => {
let diff = 0;
if (tankopediaState.sort.by === 'tier') {
diff = a.tier - b.tier;
}
if (tankopediaState.sort.by === 'name') {
diff = a.name.localeCompare(b.name);
}
return tankopediaState.sort.direction === 'ascending' ? diff : -diff;
}),
[tankopediaState.filters, tankopediaState.sort],
);
const [searchedList, setSearchedList] = useState(searchableTanks);
const [page, setPage] = useState(0);
const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
setSearchedList(searchableTanks);
if (input.current) input.current.value = '';
}, [searchableTanks]);
useEffect(() => {
setPage(
Math.min(
Math.max(0, page),
Math.ceil(searchedList.length / TANKS_PER_PAGE) - 1,
),
);
}, [searchedList]);
const router = useRouter();
return (
<PageWrapper size="wide" color="purple">
<Suspense fallback={<Text>Loading...</Text>}>
<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) => {
if (event.target.value.length === 0) {
setSearchedList(searchableTanks);
} else {
setSearchedList(
go(event.target.value, searchableTanks, {
keys: [
'name',
'id',
] satisfies (keyof BlitzkriegTankDefinition)[],
}).map(({ obj }) => obj),
);
}
}}
/>
</TextField.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft">
Sort
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>By</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={tankopediaState.sort.by}
onValueChange={(value) =>
mutateTankopedia((draft) => {
draft.sort.by = value as TankopediaSortBy;
})
}
>
<DropdownMenu.RadioItem value="tier">
Tier
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="name">
Name
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator />
<DropdownMenu.Label>Direction</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={tankopediaState.sort.direction}
onValueChange={(value) =>
mutateTankopedia((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
variant="soft"
onClick={() => setShowFilters((state) => !state)}
>
Filters {showFilters ? <CaretUpIcon /> : <CaretDownIcon />}
</Button>
</Flex>
{showFilters && (
<Card>
<Suspense fallback={<Text>Loading...</Text>}>
<Options />
</Suspense>
</Card>
)}
</Flex>
<PageTurner
page={page}
setPage={setPage}
tanksPerPage={TANKS_PER_PAGE}
searchedList={searchedList}
/>
<Flex wrap="wrap" gap="3" justify="center">
{searchedList
.map((tank) => (
<a
key={tank.id}
href={`/tools/tankopedia/${tank.id}`}
style={{
flex: 1,
textDecoration: 'none',
color: 'unset',
}}
>
<Card className={styles.listing}>
<Inset
key={tank.id}
style={{
minWidth: 256,
minHeight: 128,
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
style={{
width: 256,
height: 128,
}}
className={styles.listingImage}
>
<img
src={tankIcon(tank.id)}
style={{
objectFit: 'contain',
objectPosition: 'left center',
}}
/>
</div>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<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.tree_type === 'collector'
? TANK_ICONS_COLLECTOR
: tank.tree_type === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.type]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.tree_type === 'collector'
? 'blue'
: tank.tree_type === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{
color:
tank.tree_type === '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>
</a>
))
.slice(page * TANKS_PER_PAGE, (page + 1) * TANKS_PER_PAGE)}
</Flex>
<PageTurner
page={page}
setPage={setPage}
tanksPerPage={TANKS_PER_PAGE}
searchedList={searchedList}
/>
</Suspense>
<PageWrapper size="double" color="purple">
<TankSearch
onSelect={(tank) => {
router.push(`./tankopedia/${tank.id}`);
}}
/>
</PageWrapper>
);
}
+61
View File
@@ -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>
);
}
+67 -67
View File
@@ -7,9 +7,11 @@ 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 { tankDefinitions } from '../core/blitzkrieg/definitions/tanks';
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';
@@ -36,7 +38,7 @@ import getWN8Percentile from '../core/statistics/getWN8Percentile';
import { CommandRegistryPromisable } from '../events/interactionCreate';
const ROWS_PER_PAGE = 8;
const MAX_PAGES = 10;
const MAX_PAGES = 9;
export async function renderBreakdown(
{ region, id }: ResolvedPlayer,
@@ -180,68 +182,68 @@ export async function renderBreakdown(
/>,
);
await Promise.all(
filteredOrder.map(async (id, index) => {
const tankDefinition = awaitedTankDefinitions[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={tankDefinition?.type}
treeType={tankDefinition ? await getTreeType(id) : undefined}
title={awaitedTankDefinitions[id].name}
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?.type}
treeType={tankDefinition?.treeType}
title={tankDefinition?.name ?? `Unknown tank ${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,
},
]}
/>,
);
});
}
const pages = chunk(
@@ -282,9 +284,7 @@ export async function renderBreakdown(
description={`${name} • ${filterDescriptions}`}
/>
{filteredOrder.length === 0 && (
<NoData type={NoDataType.BattlesInPeriod} />
)}
<NoData type={NoDataType.BattlesInPeriod} />
</Wrapper>,
];
}
+1 -1
View File
@@ -1,8 +1,8 @@
import { SlashCommandBuilder } from 'discord.js';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { tankDefinitions } from '../core/blitzkrieg/definitions/tanks';
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';
+6 -7
View File
@@ -7,14 +7,13 @@ import { encyclopediaInfo } from '../core/blitz/encyclopediaInfo';
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 { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
import {
BlitzkriegTankDefinition,
TIER_ROMAN_NUMERALS,
TankDefinition,
Tier,
tankDefinitions,
} from '../core/blitzkrieg/definitions/tanks';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
} from '../core/blitzkrieg/tankDefinitions';
import { tankIcon } from '../core/blitzkrieg/tankIcon';
import addTierChoices from '../core/discord/addTierChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
@@ -47,7 +46,7 @@ export const ownedTanksCommand: CommandRegistry = {
)
).filter((tank) => tank.tankDefinitions?.tier === tier);
const clanAccountInfo = await getClanAccountInfo(server, id, ['clan']);
const groupedTanks: Record<string, BlitzkriegTankDefinition[]> = {};
const groupedTanks: Record<string, TankDefinition[]> = {};
const nations: string[] = [];
filteredTanks.forEach((tank) => {
@@ -98,7 +97,7 @@ export const ownedTanksCommand: CommandRegistry = {
name={tank.name}
tankType={tank.type}
image={tankIcon(tank.id)}
treeType={await getTreeType(tank.id)}
treeType={tank.treeType}
/>
)),
)}
@@ -111,7 +110,7 @@ export const ownedTanksCommand: CommandRegistry = {
name={tank.name}
tankType={tank.type}
image={tankIcon(tank.id)}
treeType={await getTreeType(tank.id)}
treeType={tank.treeType}
/>
)),
)}
+3 -1
View File
@@ -22,12 +22,13 @@ import { getArchivedLatestSeasonNumber } from '../core/blitzkrieg/getArchivedLat
import getArchivedRatingsInfo from '../core/blitzkrieg/getArchivedRatingsInfo';
import { getArchivedRatingsLeaderboard } from '../core/blitzkrieg/getArchivedRatingsLeaderboard';
import getArchivedRatingsMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingsMidnightLeaderboard';
import { UserError } from '../core/blitzkrieg/userError';
import addRegionChoices from '../core/discord/addRegionChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import embedNegative from '../core/discord/embedNegative';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistryPromisable } from '../events/interactionCreate';
import { UserError } from '../hooks/userError';
export interface RatingsPlayer {
spa_id: number;
@@ -132,6 +133,7 @@ export const ratingsCommand = new Promise<CommandRegistryPromisable>(
function addSubcommands(option: SlashCommandSubcommandBuilder) {
return option
.addStringOption(addUsernameChoices)
.addIntegerOption((option) =>
option
.setName('limit')
+2 -2
View File
@@ -1,7 +1,7 @@
import { SlashCommandBuilder } from 'discord.js';
import { go } from 'fuzzysort';
import markdownEscape from 'markdown-escape';
import { tankNamesDiacritics } from '../core/blitzkrieg/definitions/tanks';
import { tankNames } from '../core/blitzkrieg/tankDefinitions';
import addTankChoices from '../core/discord/addTankChoices';
import embedInfo from '../core/discord/embedInfo';
import { CommandRegistry } from '../events/interactionCreate';
@@ -25,7 +25,7 @@ export const searchTanksCommand: CommandRegistry = {
async handler(interaction) {
const tank = interaction.options.getString('tank')!;
const limit = interaction.options.getInteger('limit') ?? 25;
const results = go(tank, await tankNamesDiacritics, {
const results = go(tank, await tankNames, {
limit,
keys: ['combined'],
}).map((result) => result.obj.original);
@@ -0,0 +1,459 @@
import { MeshProps, useFrame, useThree } from '@react-three/fiber';
import { memo, useEffect, useRef } from 'react';
import {
IUniform,
Mesh,
Quaternion,
Raycaster,
ShaderMaterial,
Vector2,
Vector3,
} from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { canRicochet } from '../../../../core/blitz/canRicochet';
import { canSplash } from '../../../../core/blitz/canSplash';
import { isExplosive } from '../../../../core/blitz/isExplosive';
import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
import { hasEquipment } from '../../../../core/blitzkrieg/hasEquipment';
import { useAwait } from '../../../../hooks/useAwait';
import { useDuel } from '../../../../stores/duel';
import {
ArmorPiercingLayer,
Shot,
mutateTankopediaTemporary,
useTankopediaPersistent,
} from '../../../../stores/tankopedia';
import { externalModuleMaskRenderTarget } from '../ExternalModuleMask';
import { spacedArmorDepthRenderTarget } from '../SpacedArmorDepth';
import fragmentShader from './shaders/fragment.glsl';
import vertexShader from './shaders/vertex.glsl';
interface ArmorMeshProps extends MeshProps {
thickness: number;
maxThickness: number;
}
export type ArmorMeshUserData = {
type: 'coreArmor' | 'spacedArmor' | 'externalModule';
thickness: number;
};
export const ArmorMesh = memo<ArmorMeshProps>(
({ thickness, maxThickness, ...props }) => {
const camera = useThree((state) => state.camera);
const initialShell = useDuel.getState().antagonist!.shell;
const initialTankopedia = useTankopediaPersistent.getState();
const material = useRef<ShaderMaterial>(null);
const explosionCapable = isExplosive(initialShell.type);
const ricochetCapable = canRicochet(initialShell.type);
const cameraNormal = new Vector3();
const shellNormal = new Vector3();
const surfaceNormal = new Vector3();
const mesh = useRef<Mesh>(null);
const scene = useThree((state) => state.scene);
const hasCalibratedShells = useAwait(hasEquipment(103, true));
const hasEnhancedArmor = useAwait(hasEquipment(110));
const wireframe = initialTankopedia.model.visual.wireframe;
useEffect(() => {
async function updateQuickEquipments() {
const shell = useDuel.getState().antagonist!.shell;
const nearPenetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(103, true);
const hasEnhancedArmor = await hasEquipment(110);
if (material.current) {
material.current.uniforms.thickness.value =
thickness * (hasEnhancedArmor ? 1.04 : 1);
material.current.uniforms.penetration.value =
nearPenetration *
(hasCalibratedShells ? (isExplosive(shell.type) ? 1.1 : 1.05) : 1);
}
}
function updateShellProperties() {
const shell = useDuel.getState().antagonist!.shell;
if (material.current) {
material.current.uniforms.canRicochet.value = canRicochet(shell.type);
material.current.uniforms.isExplosive.value = isExplosive(shell.type);
material.current.uniforms.canSplash.value = canSplash(shell.type);
material.current.uniforms.caliber.value = shell.caliber;
material.current.uniforms.ricochetAngle.value = degToRad(
shell.ricochet ?? 90,
);
material.current.uniforms.damage.value = shell.damage.armor;
material.current.uniforms.explosionRadius.value =
shell.explosionRadius ?? 0;
}
updateQuickEquipments();
}
const unsubscribes = [
useDuel.subscribe(
(state) => state.protagonist!.equipment,
updateQuickEquipments,
),
useDuel.subscribe(
(state) => state.antagonist!.equipment,
updateQuickEquipments,
),
useDuel.subscribe(
(state) => state.antagonist!.shell,
updateShellProperties,
),
useTankopediaPersistent.subscribe(
(state) => state.model.visual.greenPenetration,
(greenPenetration) => {
if (material.current) {
material.current.uniforms.greenPenetration.value =
greenPenetration;
}
},
),
useTankopediaPersistent.subscribe(
(state) => state.model.visual.wireframe,
(wireframe) => {
if (material.current) {
material.current.wireframe = wireframe;
material.current.uniforms.wireframe.value = wireframe;
}
},
),
];
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
}, []);
useFrame(({ gl, camera }) => {
if (material.current) {
gl.getSize(material.current.uniforms.resolution.value);
(
material.current.uniforms.resolution as IUniform<Vector2>
).value.multiplyScalar(gl.getPixelRatio());
material.current.uniforms.externalModuleMask.value =
externalModuleMaskRenderTarget.texture;
material.current.uniforms.spacedArmorDepth.value =
spacedArmorDepthRenderTarget.depthTexture;
material.current.uniforms.spacedArmorMask.value =
spacedArmorDepthRenderTarget.texture;
material.current.uniforms.projectionMatrixInverse.value =
camera.projectionMatrixInverse;
}
});
return (
<>
<mesh {...props} renderOrder={0}>
<meshBasicMaterial colorWrite={false} />
</mesh>
<mesh
{...props}
renderOrder={1}
userData={
{
type: 'coreArmor',
thickness,
} satisfies ArmorMeshUserData
}
ref={mesh}
onClick={async (event) => {
// if (useTankopediaTemporary.getState().mode !== 'armor') return;
shellNormal.copy(
cameraNormal.copy(camera.position).sub(event.point).normalize(),
);
surfaceNormal
.copy(event.normal!)
.applyQuaternion(
event.object.getWorldQuaternion(new Quaternion()),
);
const angle = Math.acos(surfaceNormal.dot(shellNormal));
const shell = useDuel.getState().antagonist!.shell;
const hasCalibratedShells = await hasEquipment(103, true);
const resolvedPenetration =
resolveNearPenetration(shell.penetration) *
(hasCalibratedShells
? isExplosive(shell.type)
? 1.1
: 1.05
: 1);
const indexedObjects = new Set<ArmorMeshUserData['type']>();
const intersections = event.intersections
.filter(({ object }) => Boolean(object.userData.type))
.map((event) => {
const typedEvent = event as typeof event & {
object: (typeof event)['object'] & {
userData: ArmorMeshUserData;
};
};
return typedEvent;
})
.filter(({ object }) => {
if (indexedObjects.has(object.userData.type)) return false;
indexedObjects.add(object.userData.type);
return true;
});
const intersectionsTillFirstCoreArmor = intersections.slice(
0,
intersections.findIndex(
({ object }) => object.userData.type === 'coreArmor',
) + 1,
);
const coreArmorMesh =
intersectionsTillFirstCoreArmor.at(-1)!.object;
const hasEnhancedArmor = await hasEquipment(110);
const thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1;
if (mesh.current !== (coreArmorMesh as unknown as Mesh)) return;
let hasRicochet = false;
const armorPiercingLayersRaw = intersectionsTillFirstCoreArmor
.map((event, index) => {
if (hasRicochet) return null;
switch (event.object.userData.type) {
case 'externalModule': {
return {
nominal:
event.object.userData.thickness * thicknessCoefficient,
angled:
event.object.userData.thickness * thicknessCoefficient,
type: 'external',
distance: event.distance,
ricochet: false,
} satisfies ArmorPiercingLayer;
}
case 'spacedArmor':
case 'coreArmor': {
const threeCalibersRule =
shell.caliber >
3 *
(event.object.userData.thickness *
thicknessCoefficient);
if (
index === 0 &&
canRicochet(shell.type) &&
!threeCalibersRule &&
angle >= degToRad(shell.ricochet!)
) {
hasRicochet = true;
}
const twoCalibersRule =
shell.caliber >
2 *
(event.object.userData.thickness *
thicknessCoefficient);
const normalization =
twoCalibersRule && event.object.userData.thickness > 0
? ((shell.normalization ?? 0) * 1.4 * shell.caliber) /
(2 *
event.object.userData.thickness *
thicknessCoefficient)
: shell.normalization ?? 0;
const angled =
(event.object.userData.thickness * thicknessCoefficient) /
Math.cos(angle - degToRad(normalization));
return {
ricochet: hasRicochet,
nominal:
event.object.userData.thickness * thicknessCoefficient,
angled,
type:
event.object.userData.type === 'coreArmor'
? 'core'
: 'spaced',
distance: event.distance,
} satisfies ArmorPiercingLayer;
}
}
})
.filter(Boolean);
const gaps = armorPiercingLayersRaw.map((layerA, aIndex) => {
if (aIndex === armorPiercingLayersRaw.length - 1) return null;
const layerB = armorPiercingLayersRaw[aIndex + 1];
return {
type: 'gap',
gap: Math.abs(layerB.distance - layerA.distance),
} satisfies ArmorPiercingLayer;
});
const armorPiercingLayers: ArmorPiercingLayer[] = [];
armorPiercingLayersRaw.forEach((layer, index) => {
const gap = gaps[index];
armorPiercingLayers.push(layer);
if (gap) armorPiercingLayers.push(gap);
});
let remainingPenetration = resolvedPenetration;
const explosiveCapable = isExplosive(shell.type);
armorPiercingLayers.forEach((layer) => {
if (layer.type !== 'gap') {
if (layer.ricochet) {
remainingPenetration *= 0.75;
} else {
remainingPenetration -= layer.angled;
}
} else if (explosiveCapable) {
// there is a 50% penetration loss per meter for HE based shells
remainingPenetration -= 0.5 * remainingPenetration * layer.gap;
}
remainingPenetration = Math.max(0, remainingPenetration);
});
const hasPenetrated = remainingPenetration > 0;
let ricochet: Shot['ricochet'] = undefined;
if (hasRicochet) {
const raycaster = new Raycaster();
const bounceNormal = shellNormal
.clone()
.multiplyScalar(-1)
.sub(
surfaceNormal
.clone()
.multiplyScalar(
2 *
surfaceNormal.dot(
shellNormal.clone().multiplyScalar(-1),
),
),
);
raycaster.set(event.point, bounceNormal);
const intersections = raycaster
.intersectObjects(scene.children, true)
.filter(
(intersection) =>
typeof intersection.object.userData?.type === 'string',
);
const indexOfCoreArmor = intersections.findIndex(
({ object }) => object.userData.type === 'coreArmor',
);
const intersectionsTillFirstCoreArmor = intersections.slice(
0,
indexOfCoreArmor + 1,
);
if (intersectionsTillFirstCoreArmor.length > 0) {
intersectionsTillFirstCoreArmor.forEach((intersection) => {
switch (intersection.object.userData.type) {
case 'externalModule': {
remainingPenetration -=
intersection.object.userData.thickness;
break;
}
case 'spacedArmor':
case 'coreArmor': {
const angle = bounceNormal.angleTo(
intersection.object.position.clone().sub(event.point),
);
const twoCalibersRule =
shell.caliber >
2 *
(intersection.object.userData.thickness *
thicknessCoefficient);
const normalization = twoCalibersRule
? ((shell.normalization ?? 0) * 1.4 * shell.caliber) /
(2 *
intersection.object.userData.thickness *
thicknessCoefficient)
: shell.normalization ?? 0;
const angled =
(intersection.object.userData.thickness *
thicknessCoefficient) /
Math.cos(angle - degToRad(normalization));
remainingPenetration -= angled;
}
}
});
ricochet = {
point: intersectionsTillFirstCoreArmor
.at(-1)!
.point.toArray(),
distance: intersectionsTillFirstCoreArmor.at(-1)!.distance,
penetration: remainingPenetration > 0,
};
}
}
mutateTankopediaTemporary((draft) => {
draft.shot = {
type: hasRicochet
? 'ricochet'
: hasPenetrated
? 'penetration'
: 'block',
point: event.point.toArray(),
thicknesses: armorPiercingLayers,
shellNormal: shellNormal.toArray(),
surfaceNormal: surfaceNormal.toArray(),
angle,
ricochet,
};
});
}}
>
<shaderMaterial
wireframe={wireframe}
ref={material}
fragmentShader={fragmentShader}
vertexShader={vertexShader}
transparent
depthWrite={false}
uniforms={{
resolution: { value: new Vector2() },
externalModuleMask: { value: null },
spacedArmorDepth: { value: null },
spacedArmorMask: { value: null },
projectionMatrixInverse: { value: null },
zNear: { value: camera.near },
zFar: { value: camera.far },
greenPenetration: {
value: initialTankopedia.model.visual.greenPenetration,
},
maxThickness: { value: maxThickness },
isExplosive: { value: explosionCapable },
canRicochet: { value: ricochetCapable },
canSplash: { value: canSplash(initialShell.type) },
thickness: {
value: thickness * (hasEnhancedArmor ? 1.04 : 1),
},
penetration: {
value:
resolveNearPenetration(initialShell.penetration) *
(hasCalibratedShells ? (explosionCapable ? 1.1 : 1.05) : 1),
},
caliber: { value: initialShell.caliber },
ricochetAngle: { value: degToRad(initialShell.ricochet ?? 90) },
normalization: {
value: degToRad(initialShell.normalization ?? 0),
},
damage: { value: initialShell.damage.armor },
explosionRadius: { value: initialShell.explosionRadius ?? 0 },
wireframe: { value: wireframe },
}}
/>
</mesh>
</>
);
},
() => true,
);
@@ -0,0 +1,120 @@
#define PI 3.1415926535897932384626433832795
varying vec3 vViewPosition;
varying vec3 vNormal;
uniform bool isExplosive;
uniform bool canRicochet;
uniform bool canSplash;
uniform float thickness;
uniform float penetration;
uniform float caliber;
uniform float ricochetAngle;
uniform float normalization;
uniform sampler2D externalModuleMask;
uniform highp sampler2D spacedArmorDepth;
uniform sampler2D spacedArmorMask;
uniform vec2 resolution;
varying mat4 vProjectionMatrix;
uniform float maxThickness;
uniform bool greenPenetration;
uniform float damage;
uniform float explosionRadius;
uniform bool wireframe;
float depthToDistance(float depth) {
mat4 projectionMatrixInverse = inverse(vProjectionMatrix);
vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
vec4 eyePosition = projectionMatrixInverse * clipPosition;
vec3 NDCPosition = eyePosition.xyz / eyePosition.w;
float worldDistance = length(NDCPosition);
return worldDistance;
}
float angleTo(vec3 a, vec3 b) {
return acos(dot(a, b) / (length(a) * length(b)));
}
void main() {
float penetrationChance = -1.0;
float splashChance = -1.0;
vec2 screenCoordinates = gl_FragCoord.xy / resolution;
vec4 externalModuleMaskColor = texture2D(externalModuleMask, screenCoordinates);
vec4 spacedArmorMaskColor = texture2D(spacedArmorMask, screenCoordinates);
bool isUnderExternalModule = externalModuleMaskColor.r == 1.0;
bool isUnderSpacedArmor = spacedArmorMaskColor.a == 1.0;
float spacedArmorAngle = spacedArmorMaskColor.r * (PI / 2.0);
float coreArmorAngle = angleTo(vNormal, -vViewPosition);
bool coreArmorThreeCalibersRule = caliber > 3.0 * thickness;
float spacedArmorNominalThickness = spacedArmorMaskColor.g * maxThickness;
bool spacedArmorThreeCalibersRule = isUnderSpacedArmor && caliber > 3.0 * spacedArmorNominalThickness;
bool hasRicochetedSpacedArmor = isUnderSpacedArmor && !spacedArmorThreeCalibersRule && spacedArmorAngle >= ricochetAngle;
bool hasRicochetedCoreArmor = !hasRicochetedSpacedArmor && !isUnderExternalModule && canRicochet && !coreArmorThreeCalibersRule && coreArmorAngle >= ricochetAngle;
if (hasRicochetedCoreArmor || hasRicochetedSpacedArmor) {
penetrationChance = 0.0;
splashChance = 0.0;
} else {
// time to chip away at the penetration, one case at a time
float remainingPenetration = penetration;
if (isUnderExternalModule) {
// external modules don't care about angle, they'll reduce penetration by their thickness
float externalModuleThickness = externalModuleMaskColor.g * maxThickness;
remainingPenetration -= externalModuleThickness;
}
if (isUnderSpacedArmor) {
// spaced armor on the other hand, does care about angle
bool spacedArmorTwoCalibersRule = caliber > 2.0 * spacedArmorNominalThickness && spacedArmorNominalThickness > 0.0;
float finalSpacedArmorNormalization = spacedArmorTwoCalibersRule ? (normalization * 1.4 * caliber) / (2.0 * spacedArmorNominalThickness) : normalization;
float finalSpacedArmorThickness = spacedArmorNominalThickness / cos(spacedArmorAngle - finalSpacedArmorNormalization);
remainingPenetration -= finalSpacedArmorThickness;
}
remainingPenetration = max(remainingPenetration, 0.0);
float spacedArmorDepth = texture2D(spacedArmorDepth, screenCoordinates).r;
float spacedArmorDistance = depthToDistance(spacedArmorDepth);
float coreArmorDistance = depthToDistance(gl_FragCoord.z);
float distanceFromSpacedArmor = abs(coreArmorDistance - spacedArmorDistance);
if (isExplosive) {
// there is a 50% penetration loss per meter for HE based shells
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
}
bool coreArmorTwoCalibersRule = caliber > 2.0 * thickness && thickness > 0.0;
float finalNormalization = coreArmorTwoCalibersRule ? (normalization * 1.4 * caliber) / (2.0 * thickness) : normalization;
float finalCoreArmorThickness = thickness / cos(coreArmorAngle - finalNormalization);
float deltaPenetration = finalCoreArmorThickness - remainingPenetration;
float randomRadius = remainingPenetration * 0.05;
if (deltaPenetration > randomRadius) {
// even with a 5% buff to the penetration, can't penetrate
penetrationChance = 0.0;
} else if (deltaPenetration < -randomRadius) {
// even with a 5% nerf, can penetrate
penetrationChance = 1.0;
} else {
// transition area
penetrationChance = max(1.0 - (deltaPenetration + randomRadius) / (2.0 * randomRadius), 0.0);
}
if (canSplash) {
// only allow splasing if the damage equation deals more than 0 damage
float nominalArmorThickness = finalCoreArmorThickness - remainingPenetration;
float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * nominalArmorThickness;
splashChance = finalDamage > 0.0 ? 1.0 : 0.0;
} else {
splashChance = 0.0;
}
}
vec3 color = vec3(1.0, splashChance * 0.392, 0.0);
if (greenPenetration || wireframe) {
gl_FragColor = vec4(mix(color, vec3(0.0, 1.0, 0.0), penetrationChance), wireframe ? 1.0 : 0.5);
} else {
gl_FragColor = vec4(color, (1.0 - penetrationChance) * 0.5);
}
}
@@ -0,0 +1,13 @@
varying vec3 vNormal;
varying vec3 vViewPosition;
varying mat4 vProjectionMatrix;
void main() {
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
vViewPosition = vViewPosition4.xyz;
vProjectionMatrix = projectionMatrix;
vNormal = normalMatrix * normal;
gl_Position = projectionMatrix * vViewPosition4;
}
@@ -0,0 +1,52 @@
import { Color, MeshBasicMaterial, Object3D, WebGLRenderTarget } from 'three';
import { jsxTree } from '../../../core/blitzkrieg/jsxTree';
import { ArmorMeshUserData } from './Armor';
export const externalModuleMaskRenderTarget = new WebGLRenderTarget();
type ArmorMeshExternalModuleMaskProps = {
ornamental?: boolean;
node: Object3D;
} & (
| {
exclude?: false;
maxThickness: number;
thickness: number;
}
| {
exclude: true;
}
);
const excludeMaterial = new MeshBasicMaterial({
colorWrite: false,
});
export function ArmorMeshExternalModuleMask({
ornamental = false,
node,
...props
}: ArmorMeshExternalModuleMaskProps) {
return (
<>
{props.exclude &&
!ornamental &&
jsxTree(node, { renderOrder: 0, material: excludeMaterial })}
{!props.exclude &&
jsxTree(node, {
renderOrder: 1,
onClick: ornamental ? () => {} : undefined,
userData: {
type: 'externalModule',
thickness: props.thickness,
} satisfies ArmorMeshUserData,
material: new MeshBasicMaterial({
color: new Color(1, props.thickness / props.maxThickness, 0),
depthWrite: false,
colorWrite: !ornamental,
}),
})}
</>
);
}
@@ -0,0 +1,72 @@
import {
MeshBasicMaterial,
Object3D,
ShaderMaterial,
WebGLRenderTarget,
} from 'three';
import { jsxTree } from '../../../../core/blitzkrieg/jsxTree';
import { ArmorMeshUserData } from '../Armor';
import fragmentShader from './shaders/fragment.glsl';
import vertexShader from './shaders/vertex.glsl';
export const spacedArmorDepthRenderTarget = new WebGLRenderTarget();
type ArmorMeshSpacedArmorDepthProps = {
ornamental?: boolean;
node: Object3D;
} & (
| {
include?: false;
isExternalModule: boolean;
}
| {
include: true;
thickness: number;
maxThickness: number;
}
);
const excludeMaterial = new MeshBasicMaterial({
colorWrite: false,
});
const includeMaterial = new MeshBasicMaterial({
colorWrite: false,
depthWrite: false,
});
export function ArmorMeshSpacedArmorDepth({
ornamental = false,
node,
...props
}: ArmorMeshSpacedArmorDepthProps) {
return (
<>
{!props.include &&
!ornamental &&
jsxTree(node, {
renderOrder: props.isExternalModule ? 2 : 0,
material: excludeMaterial,
})}
{props.include &&
jsxTree(node, {
renderOrder: 1,
onClick: ornamental ? () => {} : undefined,
userData: {
type: 'spacedArmor',
thickness: props.thickness,
} satisfies ArmorMeshUserData,
material: ornamental
? includeMaterial
: new ShaderMaterial({
fragmentShader,
vertexShader,
uniforms: {
thickness: { value: props.thickness },
maxThickness: { value: props.maxThickness },
},
}),
})}
</>
);
}
@@ -0,0 +1,11 @@
#define PI 3.1415926535897932384626433832795
varying vec3 vViewPosition;
varying vec3 vNormal;
uniform float thickness;
uniform float maxThickness;
void main() {
float angle = acos(dot(vNormal, -normalize(vViewPosition)));
gl_FragColor = vec4(angle / (PI / 2.0), thickness / maxThickness, 0.0, 1.0);
}
@@ -0,0 +1,10 @@
varying vec3 vViewPosition;
varying vec3 vNormal;
void main() {
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
vViewPosition = vViewPosition4.xyz;
vNormal = normalMatrix * normal;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
+2
View File
@@ -0,0 +1,2 @@
export * from './components/Armor';
export * from './components/ExternalModuleMask';
+6 -6
View File
@@ -5,7 +5,7 @@ import {
GrayColor,
PALETTES,
} from '../../../constants/radixColors';
import { TREE_TYPE_ICONS, TankType, TreeTypeEnum } from '../../Tanks';
import { TREE_TYPE_ICONS, TankType, TreeType } from '../../Tanks';
import { RowStat } from './RowStat';
export interface RowStatItem {
@@ -21,7 +21,7 @@ interface RowProps {
naked?: boolean;
title: string;
minimized?: boolean;
treeType?: TreeTypeEnum;
treeType?: TreeType;
tankType?: TankType;
color?: AccentColor | GrayColor;
stats: (RowStatItem | undefined)[];
@@ -44,7 +44,7 @@ export function Row({
<div
className="session-tracker-row"
style={{
maxWidth: naked ? 600 : undefined,
maxWidth: naked ? 600 : Infinity,
display: 'flex',
flexDirection: 'column',
borderRadius: 4,
@@ -68,7 +68,7 @@ export function Row({
>
{type === 'tank' && tankType !== undefined && (
<img
src={TREE_TYPE_ICONS[treeType ?? TreeTypeEnum.TechTree][tankType]}
src={TREE_TYPE_ICONS[treeType ?? 'researchable'][tankType]}
style={{ width: 16, height: 16 }}
/>
)}
@@ -77,9 +77,9 @@ export function Row({
style={{
color:
type === 'tank'
? treeType === TreeTypeEnum.Collector
? treeType === 'collector'
? theme.textLowContrast_blue
: treeType === TreeTypeEnum.Premium
: treeType === 'premium'
? theme.textLowContrast_amber
: theme.textHighContrast
: theme.textLowContrast,
+53
View File
@@ -0,0 +1,53 @@
import { createPortal, useFrame, useThree } from '@react-three/fiber';
import { isInteger } from 'lodash';
import { ReactNode } from 'react';
import { Scene } from 'three';
interface HeadsUpDisplayProps {
priority?: number;
children: ReactNode;
}
/**
* Renders `children` above the default scene, even if the priority is a
* negative number.
*/
export function HeadsUpDisplay({
priority = 1,
children,
}: HeadsUpDisplayProps) {
const { gl: renderer, scene: defaultScene, camera } = useThree();
const scene = new Scene();
const portal = createPortal(children, scene, {
events: { priority: priority + 1 },
});
useFrame(() => {
if (priority === 1) {
renderer.clear();
renderer.render(defaultScene, camera);
}
renderer.clearDepth();
renderer.render(scene, camera);
}, priority);
renderer.autoClear = false;
if (!isInteger(priority)) {
throw new TypeError('Priority property must be an integer');
}
return portal;
}
/**
* Use this component if the default scene disappears on usage of the
* HeadsUpDisplay; caused because there are not HeadsUpDisplay components
* with `1` priority.
*/
export function RenderDefault() {
const { gl: renderer, scene, camera } = useThree();
useFrame(() => renderer.render(scene, camera));
return null;
}
+76
View File
@@ -0,0 +1,76 @@
import { MeshProps } from '@react-three/fiber';
import { forwardRef } from 'react';
import { DoubleSide, Mesh } from 'three';
interface InfiniteGridHelperProps extends MeshProps {
size1?: number;
size2?: number;
color?: THREE.Color;
distance?: number;
}
/**
* Thanks, Fyrestar, for [THREE.InfiniteGridHelper](https://github.com/Fyrestar/THREE.InfiniteGridHelper/)!
*/
const InfiniteGridHelper = forwardRef<Mesh, InfiniteGridHelperProps>(
({ size1, size2, color, distance, ...props }, ref) => {
return (
<mesh {...props} ref={ref} frustumCulled={false}>
<shaderMaterial
args={[
{
side: DoubleSide,
uniforms: {
uSize1: { value: size1 },
uSize2: { value: size2 },
uColor: { value: color },
uDistance: { value: distance },
},
extensions: { derivatives: true },
transparent: true,
vertexShader: `
varying vec3 worldPosition;
uniform float uDistance;
void main() {
vec3 pos = position.xzy * uDistance;
worldPosition = pos;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`,
fragmentShader: `
varying vec3 worldPosition;
uniform float uSize1;
uniform float uSize2;
uniform vec3 uColor;
uniform float uDistance;
vec2 origin = vec2(0.0, 0.0);
float getGrid(float size) {
vec2 r = worldPosition.xz / size;
vec2 grid = abs(fract(r - 0.5) - 0.5) / fwidth(r);
float line = min(grid.x, grid.y);
return (1.0 - min(line, 1.0)) * max(0.0, min(1.0, cameraPosition.y / 5.0));
}
void main() {
float d = 1.0 - min(distance(origin, worldPosition.xz) / uDistance, 1.0);
float g1 = getGrid(uSize1);
float g2 = getGrid(uSize2);
gl_FragColor = vec4(uColor.rgb, mix(g2, g1, g1) * pow(d, 10.0));
gl_FragColor.a = mix(0.5 * gl_FragColor.a, gl_FragColor.a, g2);
if ( gl_FragColor.a <= 0.0 ) discard;
}
`,
},
]}
/>
<planeGeometry />
</mesh>
);
},
);
export default InfiniteGridHelper;
@@ -0,0 +1,62 @@
import { ClockIcon, ReloadIcon } from '@radix-ui/react-icons';
import { Flex, Text } from '@radix-ui/themes';
import { asset } from '../../core/blitzkrieg/asset';
import { useDelta } from '../../hooks/useDelta';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
interface ConsumableButtonProps extends TankComponentButtonProps {
consumable: number;
duration?: number;
cooldown: number;
}
export function ConsumableButton({
consumable,
cooldown,
duration,
...props
}: ConsumableButtonProps) {
const cooldownDelta = useDelta(cooldown);
const durationDelta = useDelta(duration ?? 0);
return (
<GenericTankComponentButton
icon={asset(`icons/consumables/${consumable}.webp`)}
{...props}
>
<Flex direction="column" style={{ transform: 'translateX(-10px)' }}>
{duration && (
<Text
color={
durationDelta < 0
? 'green'
: durationDelta > 0
? 'tomato'
: undefined
}
>
<Flex align="center" gap="1" style={{ marginBottom: -4 }}>
<ClockIcon width={12} height={12} />
{Math.round(duration)}s
</Flex>
</Text>
)}
<Text
color={
cooldownDelta < 0
? 'green'
: cooldownDelta > 0
? 'tomato'
: undefined
}
>
<Flex align="center" gap="1">
<ReloadIcon width={12} height={12} />
{Math.round(cooldown)}s
</Flex>
</Text>
</Flex>
</GenericTankComponentButton>
);
}
@@ -0,0 +1,16 @@
import { asset } from '../../core/blitzkrieg/asset';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
interface EquipmentButtonProps extends TankComponentButtonProps {
equipment: number;
}
export function EquipmentButton({ equipment, ...props }: EquipmentButtonProps) {
return (
<GenericTankComponentButton
icon={asset(`icons/equipment/${equipment}.webp`)}
{...props}
/>
);
}
@@ -0,0 +1,47 @@
import { HTMLAttributes } from 'react';
import {
TankComponentButton,
TankComponentButtonProps,
} from './TankComponentButton';
interface GenericTankComponentButtonProps extends TankComponentButtonProps {
icon: string;
iconStyles?: HTMLAttributes<HTMLImageElement>['style'];
}
export function GenericTankComponentButton({
icon,
iconStyles,
children,
...props
}: GenericTankComponentButtonProps) {
return (
<TankComponentButton {...props}>
<div
style={{
width: 48,
height: 40,
position: 'relative',
}}
>
<img
draggable={false}
src={icon}
style={{
objectFit: 'contain',
width: 32,
height: 32,
position: 'absolute',
opacity: props.disabled ? 0.5 : 1,
transform: 'translate(-50%, -50%) scale(0.75)',
top: '50%',
left: '50%',
...iconStyles,
}}
/>
</div>
{children}
</TankComponentButton>
);
}
@@ -0,0 +1,21 @@
import { asset } from '../../core/blitzkrieg/asset';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
interface ModuleProps extends TankComponentButtonProps {
module: string;
}
export function ModuleButton({ module, ...props }: ModuleProps) {
return (
<GenericTankComponentButton
icon={asset(`icons/modules/${module}.webp`)}
iconStyles={{
top: '50%',
left: '50%',
transform: 'translate(calc(-50% + 2px), calc(-50% + 2px))',
}}
{...props}
/>
);
}
@@ -0,0 +1,16 @@
import { asset } from '../../core/blitzkrieg/asset';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
interface ProvisionButtonProps extends TankComponentButtonProps {
provision: number;
}
export function ProvisionButton({ provision, ...props }: ProvisionButtonProps) {
return (
<GenericTankComponentButton
icon={asset(`icons/provisions/${provision}.webp`)}
{...props}
/>
);
}
@@ -0,0 +1,16 @@
import { asset } from '../../core/blitzkrieg/asset';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
interface ShellButtonProps extends TankComponentButtonProps {
shell: string;
}
export function ShellButton({ shell, ...props }: ShellButtonProps) {
return (
<GenericTankComponentButton
icon={asset(`icons/shells/${shell}.webp`)}
{...props}
/>
);
}
@@ -0,0 +1,78 @@
import { Button, Text } from '@radix-ui/themes';
import { ComponentProps, ReactNode } from 'react';
export interface TankComponentButtonProps
extends ComponentProps<typeof Button> {
selected?: boolean;
first?: boolean;
last?: boolean;
rowChild?: boolean;
children?: ReactNode;
discriminator?: ReactNode;
disabled?: boolean;
banner?: string;
}
export function TankComponentButton({
selected,
first = false,
last = false,
rowChild,
discriminator,
children,
banner,
...props
}: TankComponentButtonProps) {
return (
<Button
radius="medium"
color={selected ? undefined : 'gray'}
variant={selected ? 'surface' : 'soft'}
style={{
padding: 0,
minWidth: 48,
height: 40,
position: 'relative',
borderTopLeftRadius: first ? undefined : 0,
borderTopRightRadius: last ? undefined : 0,
borderBottomLeftRadius: first ? undefined : 0,
borderBottomRightRadius: last ? undefined : 0,
margin: rowChild ? -0.5 : 'unset',
overflow: 'hidden',
}}
{...props}
>
{banner !== undefined && (
<div
style={{
position: 'absolute',
width: '200%',
height: 12,
backgroundColor: banner,
top: '50%',
left: '-50%',
transform: 'translateY(-50%) rotate(-22.5deg)',
}}
/>
)}
{discriminator !== undefined && (
<Text
size="1"
style={{
textShadow: '0 0 4px black',
zIndex: 1,
position: 'absolute',
top: '50%',
right: 8,
textAlign: 'right',
}}
>
{discriminator}
</Text>
)}
{children}
</Button>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { DiscordLogoIcon } from '@radix-ui/react-icons';
import { Flex, Text, Theme } from '@radix-ui/themes';
import Link from 'next/link';
import { theme } from '../../stitches.config';
import { BlitzkriegWormWide } from '../BlitzkriegWormWide';
import { BlitzkriegWormWide } from '../../icons/BlitzkriegWormWide';
export default function Navbar() {
return (
+2 -2
View File
@@ -3,7 +3,7 @@ import { ComponentProps } from 'react';
interface PageWrapperProps extends ComponentProps<typeof Flex> {
color?: ComponentProps<typeof Theme>['accentColor'];
size?: 'regular' | 'wide';
size?: 'regular' | 'wide' | 'double';
}
export default function PageWrapper({
@@ -19,7 +19,7 @@ export default function PageWrapper({
gap="4"
style={{
width: '100%',
maxWidth: size === 'regular' ? 800 : 960,
maxWidth: size === 'regular' ? 800 : size === 'double' ? 1200 : 960,
margin: 'auto',
padding: 16,
boxSizing: 'border-box',
+226
View File
@@ -0,0 +1,226 @@
import { Card, Flex, Text } from '@radix-ui/themes';
import { Html } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import { ReactNode, useEffect, useRef } from 'react';
import { Group, Mesh, Vector3 } from 'three';
import { radToDeg } from 'three/src/math/MathUtils';
import { isExplosive } from '../core/blitz/isExplosive';
import { AngledPenetration } from '../icons/AngledPenetration';
import { Block } from '../icons/Block';
import { NominalPenetration } from '../icons/NominalPenetration';
import { Ricochet } from '../icons/Ricochet';
import { useDuel } from '../stores/duel';
import { Shot, useTankopediaTemporary } from '../stores/tankopedia';
export const SHOT_NAMES: Record<Shot['type'], string> = {
ricochet: 'Ricochet',
penetration: 'Penetration',
block: 'Blocked',
};
export const SHOT_ICONS: Record<Shot['type'], ReactNode> = {
ricochet: <Ricochet width={24} height={24} />,
block: <Block width={24} height={24} />,
penetration: <NominalPenetration width={24} height={24} />,
};
const THICKNESS = 0.05;
const LENGTH = 4;
export function ShotDisplay() {
const shot = useTankopediaTemporary((state) => state.shot);
const entryWrapper = useRef<Group>(null);
const entryLine = useRef<Mesh>(null);
const circle = useRef<Mesh>(null);
const ricochetCircle = useRef<Mesh>(null);
const exitWrapper = useRef<Group>(null);
const exitLine = useRef<Mesh>(null);
const secondLineLength = shot?.ricochet ? shot?.ricochet.distance : LENGTH;
const number = shot && (shot.thicknesses.length > 1 || shot.ricochet);
const shell = useDuel((state) => state.antagonist!.shell);
useFrame(({ clock }) => {
const t1 = clock.elapsedTime % 1;
const t2 = (clock.elapsedTime + 0.5) % 1;
entryLine.current?.position.set(0, 0, LENGTH - t1 * LENGTH);
entryLine.current?.scale.set(1, -2 * Math.abs(t1 - 0.5) + 1, 1);
exitLine.current?.position.set(0, 0, t2 * secondLineLength);
exitLine.current?.scale.set(1, -2 * Math.abs(t2 - 0.5) + 1, 1);
});
useEffect(() => {
if (!shot) return;
const point = new Vector3(...shot.point);
const shellNormal = new Vector3(...shot.shellNormal);
const surfaceNormal = new Vector3(...shot.surfaceNormal);
const bounceNormal = shellNormal
.clone()
.multiplyScalar(-1)
.sub(
surfaceNormal
.clone()
.multiplyScalar(
2 * surfaceNormal.dot(shellNormal.clone().multiplyScalar(-1)),
),
);
entryWrapper.current?.position.set(...shot.point);
entryWrapper.current?.lookAt(point.clone().add(shellNormal));
exitWrapper.current?.position.set(...shot.point);
exitWrapper.current?.lookAt(point.clone().add(bounceNormal));
circle.current?.position.set(...shot.point);
circle.current?.lookAt(point.clone().add(surfaceNormal));
}, [shot]);
if (!shot) return null;
return (
<>
<mesh ref={circle}>
<torusGeometry args={[0.05, 0.0125, 4, 16]} />
<meshBasicMaterial
color={shot.type === 'penetration' ? 'green' : 'red'}
depthTest={false}
/>
</mesh>
<group ref={entryWrapper}>
<Html
position={[0, 0, 0]}
style={{
paddingLeft: 16,
}}
>
<Card
style={{
width: number ? 300 : 200,
userSelect: 'none',
}}
>
<Flex direction="column" gap="1">
<Flex align="center" gap="2">
{SHOT_ICONS[shot.type]}
<Text>
{SHOT_NAMES[shot.type]} at {Math.round(radToDeg(shot.angle))}°
</Text>
</Flex>
<Flex
direction="column"
style={{
paddingLeft: number ? 8 : undefined,
}}
>
{shot.thicknesses.map((layer, index) => (
<Flex gap="2" key={index}>
{number && (
<Text>
{index + 1}. {layer.type[0].toUpperCase()}
{layer.type.slice(1)}
</Text>
)}
<Flex gap="1">
{layer.type !== 'gap' && (
<NominalPenetration width={24} height={24} />
)}
<Text>
{Math.round(
layer.type === 'gap'
? layer.gap * 1000
: layer.nominal,
)}
mm
{layer.type === 'gap' && isExplosive(shell.type)
? ` (-${Math.round(Math.min(100, 0.5 * layer.gap * 100))}% penetration)`
: ''}
</Text>
</Flex>
{layer.type !== 'external' && layer.type !== 'gap' && (
<Flex gap="1">
<AngledPenetration width={24} height={24} />
<Text>{Math.round(layer.angled)}mm</Text>
</Flex>
)}
</Flex>
))}
{shot.ricochet && (
<>
<Flex gap="1">
<Text>{shot.thicknesses.length + 1}. Ricochet</Text>
<Ricochet width={24} height={24} />
<Text>(-25% penetration)</Text>
</Flex>
{shot.ricochet.penetration ? (
<Flex gap="1">
<Text>{shot.thicknesses.length + 2}. Penetration</Text>
<NominalPenetration width={24} height={24} />
</Flex>
) : (
<Flex gap="1">
<Text>{shot.thicknesses.length + 2}. Blocked</Text>
<Block width={24} height={24} />
</Flex>
)}
</>
)}
</Flex>
</Flex>
</Card>
</Html>
<mesh
renderOrder={1}
position={[0, 0, LENGTH / 2]}
rotation={[Math.PI / 2, 0, 0]}
ref={entryLine}
>
<cylinderGeometry args={[THICKNESS / 2, THICKNESS / 2, LENGTH, 8]} />
<meshBasicMaterial color="#ffff80" />
</mesh>
<mesh position={[0, 0, LENGTH / 2]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[THICKNESS / 4, THICKNESS / 4, LENGTH, 8]} />
<meshBasicMaterial color="#ffffff" depthTest={false} />
</mesh>
</group>
{shot.type === 'ricochet' && (
<group ref={exitWrapper}>
<mesh
renderOrder={1}
position={[0, 0, secondLineLength / 2]}
rotation={[Math.PI / 2, 0, 0]}
ref={exitLine}
>
<cylinderGeometry
args={[THICKNESS / 2, THICKNESS / 2, secondLineLength, 8]}
/>
<meshBasicMaterial color="#ff8080" />
</mesh>
<mesh
position={[0, 0, secondLineLength / 2]}
rotation={[Math.PI / 2, 0, 0]}
>
<cylinderGeometry
args={[THICKNESS / 4, THICKNESS / 4, secondLineLength, 8]}
/>
<meshBasicMaterial color="#ffffff" depthTest={false} />
</mesh>
{shot.ricochet && (
<mesh ref={ricochetCircle} position={[0, 0, secondLineLength]}>
<icosahedronGeometry args={[0.05, 1]} />
<meshBasicMaterial
color={shot.ricochet.penetration ? 'green' : 'red'}
depthTest={false}
/>
</mesh>
)}
</group>
)}
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { asset } from '../core/blitzkrieg/asset';
interface SmallTankIconProps {
id: number;
size?: number;
}
const WIDTH = 70;
const HEIGHT = 24;
export function SmallTankIcon({ id, size = HEIGHT }: SmallTankIconProps) {
return (
<div
style={{
height: size,
overflow: 'hidden',
width: WIDTH * (size / HEIGHT),
}}
>
<img
src={asset(`icons/tanks/small/${id}.webp`)}
style={{
transform: `scale(${(size / HEIGHT) * 100}%)`,
transformOrigin: 'top left',
}}
/>
</div>
);
}
+13 -19
View File
@@ -2,34 +2,28 @@ import {
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
} from '../../../core/blitzkrieg/definitions/tanks';
} from '../../../core/blitzkrieg/tankDefinitions';
import { theme } from '../../../stitches.config';
export enum TreeTypeEnum {
TechTree,
Premium,
Collector,
}
export const TANK_TYPES = [
'tank_destroyer',
'light',
'medium',
'heavy',
'tankDestroyer',
] as const;
export const TREE_TYPES = ['researchable', 'premium', 'collector'] as const;
export type TankType = (typeof TANK_TYPES)[number];
export type TreeTypeString = (typeof TREE_TYPES)[number];
export type TreeType = (typeof TREE_TYPES)[number];
export const TREE_TYPE_NAMES: Record<TreeTypeString, string> = {
export const TREE_TYPE_NAMES: Record<TreeType, string> = {
researchable: 'Tech tree',
premium: 'Premium',
collector: 'Collector',
};
export const TREE_TYPE_IMAGES: Record<TreeTypeString, string> = {
export const TREE_TYPE_IMAGES: Record<TreeType, string> = {
researchable: 'https://i.imgur.com/pJxO2XY.png',
premium: 'https://i.imgur.com/mZzSwOU.png',
collector: 'https://i.imgur.com/7A0RsG5.png',
@@ -39,19 +33,19 @@ export interface ItemProps {
image?: string;
tankType?: TankType;
name: string;
treeType: TreeTypeEnum;
treeType: TreeType;
}
const TREE_TYPE_COLOR = {
[TreeTypeEnum.TechTree]: '',
[TreeTypeEnum.Premium]: '_amber',
[TreeTypeEnum.Collector]: '_blue',
researchable: '',
premium: '_amber',
collector: '_blue',
} as const;
export const TREE_TYPE_ICONS = {
[TreeTypeEnum.TechTree]: TANK_ICONS,
[TreeTypeEnum.Premium]: TANK_ICONS_PREMIUM,
[TreeTypeEnum.Collector]: TANK_ICONS_COLLECTOR,
export const TREE_TYPE_ICONS: Record<TreeType, Record<TankType, string>> = {
researchable: TANK_ICONS,
premium: TANK_ICONS_PREMIUM,
collector: TANK_ICONS_COLLECTOR,
};
export function Item({ image, tankType, name, treeType }: ItemProps) {
+1 -1
View File
@@ -1,4 +1,4 @@
import { Tier } from '../core/blitzkrieg/definitions/tanks';
import { Tier } from '../core/blitzkrieg/tankDefinitions';
import { theme } from '../stitches.config';
export type TierWeightsRecord = Partial<Record<Tier, number>>;
+5
View File
@@ -0,0 +1,5 @@
import { Vector3 } from 'three';
export const I_HAT = new Vector3(1, 0, 0);
export const J_HAT = new Vector3(0, 1, 0);
export const K_HAT = new Vector3(0, 0, 1);
+26 -20
View File
@@ -3,6 +3,7 @@ interface Tool {
title: string;
description: string;
disabled?: boolean;
href?: string;
}
export const TOOLS: Tool[] = [
@@ -11,33 +12,38 @@ export const TOOLS: Tool[] = [
title: 'Session tracker',
description: 'Live session performance tracker',
},
{
id: 'tankopedia',
title: 'Tankopedia',
description: 'Blitz tank encyclopedia',
},
{
id: 'ratings',
title: 'Ratings',
description: 'Ratings gamemode leaderboards',
},
{
id: 'tankopedia',
title: 'Tankopedia',
description: 'Blitz tank encyclopedia',
disabled: true,
},
{
id: 'inactive',
title: 'Inactivity tracker',
description: 'Find inactive members of a clan',
disabled: true,
},
{
id: 'profile',
title: 'Player profile',
description: "A player's basic non-statistical info",
disabled: true,
id: 'discord',
title: 'Discord bot',
description: 'Stats and tools right in Discord',
href: 'https://discord.com/application-directory/1097673957865443370',
},
// {
// title: 'More coming soon',
// description: 'New tools are added often',
// banner: '/assets/banners/more.png',
// id: 'https://discord.gg/nDt7AjGJQH',
// id: 'inactive',
// title: 'Inactivity tracker',
// description: 'Find inactive members of a clan',
// disabled: true,
// },
// {
// id: 'profile',
// title: 'Player profile',
// description: "A player's basic non-statistical info",
// disabled: true,
// },
{
id: 'more',
title: 'More coming soon',
description: 'This site is being actively developed',
href: 'https://discord.gg/nDt7AjGJQH',
},
];
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff. Show more