Compare commits

...
126 Commits
Author SHA1 Message Date
TrèsAbhi 17c7a5c7f5 feat: start of selector 2025-09-13 23:57:10 -05:00
TrèsAbhi f5aa2ff9de feat: reenable mixer 2025-09-13 23:42:26 -05:00
TrèsAbhi 96a8f225fc feat: start on api 2025-09-13 23:07:58 -05:00
TrèsAbhi d15a5aee69 fix: no overflow navbar on certain devices 2025-09-13 22:54:45 -05:00
TrèsAbhi 84dc5e4960 feat: data-driven tools 2025-09-13 22:53:29 -05:00
TrèsAbhi 58b5ee7f83 feat: data-driven tools 2025-09-13 21:54:43 -05:00
TrèsAbhi 8a99d34af9 fix: check intialization this mount 2025-09-13 21:33:16 -05:00
TrèsAbhi 7cd7bd83dc Merge branch 'main' into dev 2025-09-13 21:28:58 -05:00
TrèsAbhi c29d36a4b7 feat: update to main 2025-09-13 10:03:22 -05:00
TrèsAbhi 7d3b6221d7 feat: remake decodeAny 2025-09-13 10:02:46 -05:00
TrèsAbhi fd66f9ab17 feat: compiler based client 2025-09-12 16:49:26 -05:00
TrèsAbhi 6fbb0e81cb simplify error handling 2025-09-12 14:08:53 -05:00
TrèsAbhi 6dc82588c1 feat: cleanup 2025-09-12 13:42:33 -05:00
TrèsAbhi c6eae9ad51 feat: update closed 2025-09-12 13:29:10 -05:00
TrèsAbhi ebdd7629ff update 2025-09-12 13:02:43 -05:00
TrèsAbhi 646bccf370 feat: logs 2025-09-07 16:38:07 -05:00
TrèsAbhi 86206f2d89 Merge branch 'main' into dev 2025-09-07 16:29:28 -05:00
TrèsAbhi bc0dd87e31 Merge branch 'main' into dev 2025-09-07 16:29:09 -05:00
TrèsAbhi e26bb4efe0 feat: some changes to lighting... again 2025-09-06 12:40:35 -05:00
TrèsAbhi 66cf1fd223 feat: baked ao 2025-09-06 00:48:39 -05:00
TrèsAbhi 335cd4d699 feat: rewrite some systems 2025-09-05 23:34:59 -05:00
TrèsAbhi 95fa5bc1a5 fix: use base color map 2025-09-05 20:45:04 -05:00
TrèsAbhi 50eac9eba6 New translations en.json (French) (#169) 2025-09-04 08:04:17 -05:00
TrèsAbhi 1598ae973f feat: auto framerate 2025-09-02 22:44:46 -05:00
TrèsAbhi 69f3a95379 feat: use armor model 2025-09-02 22:16:48 -05:00
TrèsAbhi 3846cbab4a fix: save persistent data 2025-09-02 16:31:11 -05:00
TrèsAbhi 30e87743fc feat: upgrade rest of them 2025-09-02 16:24:13 -05:00
TrèsAbhi fe4afdcbf0 feat: upgrade CompareEphemeral 2025-09-01 23:05:22 -05:00
TrèsAbhi ef2a47e756 feat: upgrade ComparePersistent 2025-09-01 23:02:20 -05:00
TrèsAbhi c2d625e1ed feat: upgrade EmbedBreakdown 2025-09-01 23:00:44 -05:00
TrèsAbhi d33990bdb4 feat: upgrade Gallery 2025-09-01 22:58:26 -05:00
TrèsAbhi 96482d4268 feat: renaming 2025-09-01 22:56:39 -05:00
TrèsAbhi 8b527f2333 fix: last and next checking 2025-09-01 22:55:31 -05:00
TrèsAbhi f56c31fb48 feat: upgrade Guess 2025-09-01 19:22:38 -05:00
TrèsAbhi 7d164e415a feat: upgrade Mixer 2025-09-01 19:19:44 -05:00
TrèsAbhi b3a9f0889e feat: upgrade Session 2025-09-01 19:16:36 -05:00
TrèsAbhi 6d24ed92d0 feat: upgrade TankFilters 2025-09-01 19:13:08 -05:00
TrèsAbhi d287e66265 feat: upgrade tankopediaEphemeral 2025-09-01 19:01:01 -05:00
TrèsAbhi ac29d1ead0 feat: upgrade tankopediaEphemeral 2025-09-01 18:51:04 -05:00
TrèsAbhi b6c3fdc4e9 feat: upgrade performance 2025-09-01 18:16:41 -05:00
TrèsAbhi 4efdaaf7c9 feat: upgrade TankSort 2025-09-01 18:13:41 -05:00
TrèsAbhi 87b6a36520 feat: use custom state manager 2025-09-01 18:03:07 -05:00
TrèsAbhi cf38cb3a6e feat: optimize legacy redirect pages 2025-09-01 17:27:42 -05:00
TrèsAbhi 97fc386bc7 New Crowdin updates (#168)
* New translations en.json (Russian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Japanese)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Japanese)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Russian)

* New translations en.json (Russian)
2025-08-31 09:54:49 -05:00
TrèsAbhi abfb3f8ddb fix: typo 2025-08-29 23:58:47 -05:00
TrèsAbhi 9cb5113f6e feat: haha 2025-08-29 23:58:14 -05:00
TrèsAbhi 7dd1c0c32a feat: logs 2025-08-29 23:57:33 -05:00
TrèsAbhi 03233f3fdc fix: align antagonist selector filters correctly 2025-08-29 23:46:57 -05:00
TrèsAbhi 377159c298 fix: consider tank models 2025-08-29 23:41:35 -05:00
TrèsAbhi d8431fb620 fix: gap alignemnt 2025-08-29 23:28:49 -05:00
TrèsAbhi 0546ddefbd fix: better shot card alignment 2025-08-29 23:24:12 -05:00
TrèsAbhi 26f6889012 feat: revert subtler advanced highlighting coloring 2025-08-29 23:10:20 -05:00
TrèsAbhi 3ad6088565 feat: skip button for reload visualizer 2025-08-29 23:08:44 -05:00
TrèsAbhi b6fd568cc6 fix: screenshot props 2025-08-29 23:00:02 -05:00
TrèsAbhi 3861b07aef fix: remove "show grid" 2025-08-29 22:53:04 -05:00
TrèsAbhi 2bf57e2c72 feat: better framing 2025-08-29 22:51:24 -05:00
TrèsAbhi 744c6654cf feat: cooler initial position 2025-08-29 22:39:34 -05:00
TrèsAbhi b82e905757 fix: dead code 2025-08-29 13:42:44 -05:00
TrèsAbhi c42e5b7dc3 feat: logs 2025-08-29 13:32:46 -05:00
TrèsAbhi b716218ecd fix: loading bar 2025-08-29 13:31:48 -05:00
TrèsAbhi 511346e4bb fix: select 2025-08-29 13:26:34 -05:00
TrèsAbhi 5c2e49453e feat: more lighting 2025-08-29 13:21:41 -05:00
TrèsAbhi 5d397de766 feat: more progress 2025-08-28 18:23:02 -05:00
TrèsAbhi aa763f65d1 feat: more progress 2025-08-28 17:59:31 -05:00
TrèsAbhi 94cbd95509 feat: start of new lighting 2025-08-28 16:52:23 -05:00
TrèsAbhi f3762e356f feat: start of mixer 2025-08-28 16:12:40 -05:00
TrèsAbhi 5747d7549f feat: tweak a few things and enable guess game 2025-08-27 17:43:42 -05:00
TrèsAbhi aea4905d20 fix: vars 2025-08-27 17:22:06 -05:00
TrèsAbhi 1558b69931 Merge branch 'main' of https://github.com/blitzkit/blitzkit 2025-08-27 17:19:45 -05:00
TrèsAbhi a560a3d3ae feat: logs 2025-08-27 17:19:40 -05:00
TrèsAbhi b620110d0f New Crowdin updates (#167)
* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Japanese)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)
2025-08-27 11:36:57 -05:00
TrèsAbhi 23ea72bf40 Merge branch 'dev' 2025-08-26 11:29:09 -05:00
TrèsAbhi aaee0b7965 feat: servicing 2025-08-26 11:28:57 -05:00
TrèsAbhi 347523f9e7 feat: burst dpm 2025-08-22 13:38:02 -05:00
TrèsAbhi 3205630a0f feat: recoil 2025-08-22 12:44:34 -05:00
TrèsAbhi 8446e3fbbd feat: flashes 2025-08-22 12:40:26 -05:00
TrèsAbhi 6d25a97fc8 fix: dynamic sizing 2025-08-22 12:23:57 -05:00
TrèsAbhi bdbfce0e18 feat: auto reloader compatibility 2025-08-22 12:21:04 -05:00
TrèsAbhi 8033e8f7cc feat: burst compatiblity 2025-08-21 20:22:12 -05:00
TrèsAbhi f98c801d6c New translations en.json (Russian) (#163) 2025-08-21 13:45:37 -05:00
TrèsAbhi 339663adaf Merge branch 'main' into dev 2025-08-20 11:07:50 -05:00
TrèsAbhi 2b15357dee New Crowdin updates (#162)
* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Japanese)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Russian)
2025-08-19 19:02:36 -05:00
TrèsAbhi 1e05717daf fix: remove logs 2025-08-18 12:07:02 -05:00
TrèsAbhi c20f8c5040 feat: burst data 2025-08-18 12:06:14 -05:00
TrèsAbhi f22f4eeccd Update website.yml 2025-08-18 11:56:41 -05:00
TrèsAbhi 79c450798c feat: logs 2025-08-18 11:28:16 -05:00
TrèsAbhi d247e67690 fix: link the whole frame 2025-08-18 11:21:16 -05:00
TrèsAbhi 69b2035cfc fix: module damage no longer scales with assault shells 2025-08-18 11:14:46 -05:00
TrèsAbhi 62fed63310 fix: max width docs images 2025-08-18 11:11:35 -05:00
TrèsAbhi 7c29046390 fix: doubled range 2025-08-18 11:05:10 -05:00
TrèsAbhi b65c3c1d93 feat: some recoloring 2025-08-18 10:54:15 -05:00
TrèsAbhi 87f5d8fe8f Merge branch 'main' into dev 2025-08-18 10:51:38 -05:00
TrèsAbhi 525b9377cc feat: optimize info lines 2025-08-12 13:48:10 -05:00
TrèsAbhi 94cba0c9ba feat: remove reactive pitch and yaw 2025-08-12 13:43:11 -05:00
TrèsAbhi 4ddf77fc7f fix: invalidate 2025-08-12 13:20:29 -05:00
TrèsAbhi ae6dc259dc feat: rename things 2025-08-12 13:17:07 -05:00
TrèsAbhi c2e95f6ef6 feat: hide ReloadVisualizer 2025-08-12 13:11:59 -05:00
TrèsAbhi 868812e578 feat adjust coloring 2025-08-12 13:11:34 -05:00
TrèsAbhi b5064f9e8b fix: touch action 2025-08-12 12:59:46 -05:00
TrèsAbhi a0eb0d54f0 feat: syncronize tank model 2025-08-12 12:52:57 -05:00
TrèsAbhi e47b1315a9 feat: hull rotate 2025-08-12 12:45:23 -05:00
TrèsAbhi cde6904513 feat: yaw support 2025-08-11 11:34:51 -05:00
TrèsAbhi 997d1df8f9 fix: auto rotate disabling 2025-08-11 11:26:53 -05:00
TrèsAbhi 9f15ded0c9 feat: optimize rendering 2025-08-11 11:25:25 -05:00
TrèsAbhi b2ee97b3d4 feat: active camera positioner 2025-08-11 11:17:26 -05:00
TrèsAbhi 03d819a6c1 feat: dog 2025-08-11 11:08:47 -05:00
TrèsAbhi 8ff26d57f6 feat: some model 2025-08-10 20:36:48 -05:00
TrèsAbhi c243ebd19a feat: embedded canvas 2025-08-10 14:03:13 -05:00
TrèsAbhi 40f5042896 feat: cursor 2025-08-09 13:30:53 -05:00
TrèsAbhi 21130b29f8 fix: 90 / 2 2025-08-09 12:39:20 -05:00
TrèsAbhi d50b054243 fix: unintended space before cross 2025-08-09 12:32:37 -05:00
TrèsAbhi 6b7f8049fe feat: elevation 2025-08-09 12:31:36 -05:00
TrèsAbhi e6029157aa feat: allow scrolling on hero section 2025-08-09 12:07:26 -05:00
TrèsAbhi ba656eb603 fix: remove dead code 2025-08-09 11:59:47 -05:00
TrèsAbhi e8dd26b2ba feat: moved shadow down 2025-08-09 11:52:10 -05:00
TrèsAbhi 897da9479d feat: transition and continous 2025-08-08 12:29:21 -05:00
TrèsAbhi 291395718f feat: yaw 2025-08-08 12:09:16 -05:00
TrèsAbhi 42e860b171 feat: start of gun depression visualzier 2025-08-08 12:00:33 -05:00
TrèsAbhi 3b6d339310 feat: compact info lines 2025-08-08 11:03:20 -05:00
TrèsAbhi 728fd5b824 feat: remove dpm counter 2025-08-08 11:01:26 -05:00
TrèsAbhi 34217b2754 feat: more progress 2025-08-08 10:57:39 -05:00
TrèsAbhi 0b5c88531f feat: guidesblitz 2025-08-07 20:24:19 -05:00
TrèsAbhi e145d7c6e8 Merge branch 'main' into dev 2025-08-07 14:11:24 -05:00
TrèsAbhi 86f4712b87 feat: subtler advanced highlighting 2025-08-06 12:33:50 -05:00
TrèsAbhi ea90ca21b1 feat: remove shooting range 2025-08-06 12:23:57 -05:00
TrèsAbhi b3046d8655 feat: enable reload visualizer 2025-08-06 12:16:14 -05:00
242 changed files with 6936 additions and 7047 deletions

No files matched your search

+13
View File
@@ -22,6 +22,19 @@ env:
PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }} PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }}
PUBLIC_ASSET_FAST_USER: ${{ secrets.PUBLIC_ASSET_FAST_USER }} PUBLIC_ASSET_FAST_USER: ${{ secrets.PUBLIC_ASSET_FAST_USER }}
PUBLIC_ASSET_FAST_REPO: ${{ secrets.PUBLIC_ASSET_FAST_REPO }} PUBLIC_ASSET_FAST_REPO: ${{ secrets.PUBLIC_ASSET_FAST_REPO }}
PUBLIC_PROMOTE_OPENTEST: ${{ secrets.PUBLIC_PROMOTE_OPENTEST }}
ALLOW_ROBOTS: ${{ secrets.ALLOW_ROBOTS }}
WOTB_GLOSSARY: ${{ secrets.WOTB_GLOSSARY }}
PUBLIC_GOOGLE_TAG_MANAGER_ID: ${{ secrets.PUBLIC_GOOGLE_TAG_MANAGER_ID }}
PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ${{ secrets.PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID }}
GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}
WOTB_GLOSSARY_DOMAIN: ${{ secrets.WOTB_GLOSSARY_DOMAIN }}
WOTB_GLOSSARY_PATH: ${{ secrets.WOTB_GLOSSARY_PATH }}
PUBLIC_DISCORD_SERVER_ID: ${{ secrets.PUBLIC_DISCORD_SERVER_ID }}
PUBLIC_ASSET_BASE: ${{ secrets.PUBLIC_ASSET_BASE }}
PUBLIC_BRANCH: ${{ secrets.PUBLIC_BRANCH }}
PUBLIC_GUIDE_WEBSITE: ${{ secrets.PUBLIC_GUIDE_WEBSITE }}
SERVICING: ${{ secrets.SERVICING }}
jobs: jobs:
host: host:
+2
View File
@@ -29,6 +29,8 @@ env:
PUBLIC_DISCORD_SERVER_ID: ${{ secrets.PUBLIC_DISCORD_SERVER_ID }} PUBLIC_DISCORD_SERVER_ID: ${{ secrets.PUBLIC_DISCORD_SERVER_ID }}
PUBLIC_ASSET_BASE: ${{ secrets.PUBLIC_ASSET_BASE }} PUBLIC_ASSET_BASE: ${{ secrets.PUBLIC_ASSET_BASE }}
PUBLIC_BRANCH: ${{ secrets.PUBLIC_BRANCH }} PUBLIC_BRANCH: ${{ secrets.PUBLIC_BRANCH }}
PUBLIC_GUIDE_WEBSITE: ${{ secrets.PUBLIC_GUIDE_WEBSITE }}
SERVICING: ${{ secrets.SERVICING }}
jobs: jobs:
build: build:
-7
View File
@@ -1,7 +0,0 @@
{
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 80
}
+3 -6
View File
@@ -167,6 +167,7 @@
"searchtanks", "searchtanks",
"SHAREDEXP", "SHAREDEXP",
"SKLLD", "SKLLD",
"smoothstep",
"Smri", "Smri",
"Specularity", "Specularity",
"SSAO", "SSAO",
@@ -222,11 +223,7 @@
"yolo", "yolo",
"youtuber", "youtuber",
"youtubers", "youtubers",
"zustand" "zoomable"
], ],
"deno.enable": false, "typescript.tsdk": "node_modules\\typescript\\lib"
"deno.unstable": false,
"cSpell.enableFiletypes": ["glsl", "proto3"],
"typescript.tsdk": "node_modules\\typescript\\lib",
"liveServer.settings.multiRootWorkspaceName": "blitzkit"
} }
BIN
View File
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
# BlitzKit 1.27.16
## Features
- Added links to GuidesBlitz to Tankopedia to aid users understand tanks beyond just numbers and stats
- Added a gun depression and yaw visualizer
## Changes
- Made the colors of the advanced highlighting mode more subtle
- Made the Tankopedia stat lines more condensed
- You can now scroll the page even with your cursor on the Tankopedia sandbox
## Fixes
- Fixed a bug where the range of increased gun depression was incorrectly doubled
- Removed an unintended space before the "x" in the "Gun damaged" stat
- Fixed an issue causing the 3D renderer to render frames even when off screen
- Fixed a differential issue causing excessive rerendering of the stat lines
- Images no longer exceed the width of the device in the docs
- Assault shells no longer scale the module damage with distance
## Technical Changes
- Removed all code relating to the canceled "Shooting Range" mode
+18
View File
@@ -0,0 +1,18 @@
# BlitzKit 1.27.17
This update was released early for the website. Today, it'll be officially announced and released for the bot
## Features
- Added the shell reload visualizer to aid people in getting a feel for how long shell reloads really are
- Added lines of stats for the burst shells
## Changes
- Removed the redundant word "Shell" from "Shell reload" and "Shell reloads"
- Removed the penetration over distance slider
## Fixes
- Fixed the DPM of tanks with burst shells like the ATAC
- The DPM will still be different from the game due to a bug in World of Tanks Blitz; I am trying to get Wargaming to fix it; trust BlitzKit!
+11
View File
@@ -0,0 +1,11 @@
# BlitzKit 1.27.18
## Features
- Added a mini-game to BlitzKit similar to "Guess Who's That Pokemon" but with tanks: http://blitzkit.vercel.app/guess
- Switched to a new renderer for better lighting for the Tankopedia
## Fixes
- The loading bar for the Tankopedia sandbox now shows up again
- Removed a lot of dead code, improving performance
+18
View File
@@ -0,0 +1,18 @@
# BlitzKit 1.27.19
Two updates in a day? I just got a little excited by the new Tankopedia lighting.
## Features
- The camera is now lower down in the Tankopedia, giving the tank a more heroic feel
- Tanks are better framed across all devices, minimizing whitespace
- Added a skip button for the reload visualizer to skip to the next event
## Fixes
- Removed the useless "show grid" setting
- Taking screenshots now no longer includes the scene props like the floor
- The subtler color contrast for the advanced highlighting has now been reverted back to the higher contrast
- The shot display card is now properly aligned as a proper table again
- Tank modules are once again considered in the penetration tester
- Tank filters in the antagonist selector in the Tankopedia sandbox are now properly aligned
+13
View File
@@ -0,0 +1,13 @@
# BlitzKit 1.27.20
It's all technical updates! The HD tanks will be rolled out over the span of few days.
## Technical Changes
- Shadows are disabled if your framerate drops below 30fps
- Pages that redirect to other pages (like the old /tools/tankopedia) now load a few hundred times faster
- Replaced Zustand with a custom state manager called Varuna with the goal to rewrite a significant portion of the website to not require any React for rendering increasing performance
- The transition wireframe in the Tankopedia sandbox now uses a lower quality model for performance reasons
- Tanks with PBR now use the correct maximum resolution textures
- This is a significant update as you can now literally see individual splats of dust on tanks and scratches haha
- Baked the ambient occlusion maps into the textures, hence reducing the overall model load times even with higher resolution textures
+5 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "blitzkit", "name": "blitzkit",
"private": true, "private": true,
"version": "1.27.15", "version": "1.27.20",
"description": "🎉 Tools for everything World of Tanks Blitz", "description": "🎉 Tools for everything World of Tanks Blitz",
"type": "module", "type": "module",
"types": "./types.d.ts", "types": "./types.d.ts",
@@ -16,6 +16,7 @@
"build:prisma": "prisma generate", "build:prisma": "prisma generate",
"build:sitemap": "bun scripts/sitemap.ts", "build:sitemap": "bun scripts/sitemap.ts",
"build:docs": "vitepress build docs", "build:docs": "vitepress build docs",
"build:proxyclient": "cd packages/scripts && bun run build:proxyclient",
"build:ratingleaderboard": "cd packages/scripts && bun run build:ratingleaderboard", "build:ratingleaderboard": "cd packages/scripts && bun run build:ratingleaderboard",
"dev:bot": "bun run build:bot --dev && bun start:bot", "dev:bot": "bun run build:bot --dev && bun start:bot",
"start:bot": "bun dist/bot/main.js", "start:bot": "bun dist/bot/main.js",
@@ -41,6 +42,9 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/xmpp__client": "^0.13.3",
"@xmpp/client": "^0.13.6",
"cheerio": "^1.1.2",
"googleapis": "^144.0.0", "googleapis": "^144.0.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"protobufjs": "^7.5.2", "protobufjs": "^7.5.2",
+3 -8
View File
@@ -197,15 +197,10 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
turret.guns.forEach((gun) => { turret.guns.forEach((gun) => {
if ( if (
gun.gun_type!.value.base.research_cost !== undefined && gun.research_cost !== undefined &&
typeof gun.gun_type!.value.base.research_cost typeof gun.research_cost.research_cost_type!.value === 'number'
.research_cost_type!.value === 'number'
) { ) {
gunXps.set( gunXps.set(gun.id, gun.research_cost.research_cost_type!.value);
gun.gun_type!.value.base.id,
gun.gun_type!.value.base.research_cost.research_cost_type!
.value,
);
} }
}); });
}); });
+12 -14
View File
@@ -1,8 +1,8 @@
import { ReadStream } from '@blitzkit/core'; import { ReadStream } from "@blitzkit/core";
import AdmZip from 'adm-zip'; import AdmZip from "adm-zip";
import { readFile, writeFile } from 'fs/promises'; import { readFile } from "fs/promises";
import { Parser } from 'pickleparser'; import { Parser } from "pickleparser";
import protobuf from 'protobufjs'; import protobuf from "protobufjs";
interface WotbReplayMetaJson { interface WotbReplayMetaJson {
version: string; version: string;
@@ -24,26 +24,24 @@ export class WotbReplayReadStream extends ReadStream {
async wotbReplay() { async wotbReplay() {
const pickleParser = new Parser(); const pickleParser = new Parser();
const zip = new AdmZip(Buffer.from(this.buffer)); const zip = new AdmZip(Buffer.from(this.buffer));
const metaJsonBuffer = zip.getEntry('meta.json')?.getData(); const metaJsonBuffer = zip.getEntry("meta.json")?.getData();
const battleResultsDat = zip.getEntry('battle_results.dat')?.getData(); const battleResultsDat = zip.getEntry("battle_results.dat")?.getData();
if (!metaJsonBuffer || !battleResultsDat) if (!metaJsonBuffer || !battleResultsDat)
throw new SyntaxError('meta.json not found'); throw new SyntaxError("meta.json not found");
const metaJson = JSON.parse( const metaJson = JSON.parse(
metaJsonBuffer.toString(), metaJsonBuffer.toString()
) as WotbReplayMetaJson; ) as WotbReplayMetaJson;
const unpickledBattleResults = pickleParser.parse(battleResultsDat); const unpickledBattleResults = pickleParser.parse(battleResultsDat);
const [, battleResultsString] = unpickledBattleResults as [number, string]; const [, battleResultsString] = unpickledBattleResults as [number, string];
const root = await protobuf.load( const root = await protobuf.load(
`${__dirname}/../../protos/BattleResultsClient.proto`, `${__dirname}/../../protos/BattleResultsClient.proto`
); );
const BattleResults = root.lookupType( const BattleResults = root.lookupType(
'BattleResultsGenerated.BattleResultsClient', "BattleResultsGenerated.BattleResultsClient"
); );
writeFile('test.dat', Buffer.from(battleResultsString));
const reader = protobuf.Reader.create(Buffer.from(battleResultsString)); const reader = protobuf.Reader.create(Buffer.from(battleResultsString));
// const decodedBattleResults = BattleResults.decodeDelimited( // const decodedBattleResults = BattleResults.decodeDelimited(
@@ -53,7 +51,7 @@ export class WotbReplayReadStream extends ReadStream {
} }
} }
const replay = await readFile('temp/test3.wotbreplay'); const replay = await readFile("temp/test3.wotbreplay");
const stream = new WotbReplayReadStream(replay.buffer as ArrayBuffer); const stream = new WotbReplayReadStream(replay.buffer as ArrayBuffer);
stream.wotbReplay(); stream.wotbReplay();
+39 -27
View File
@@ -1,6 +1,6 @@
import { import {
GunDefinition, GunDefinition,
GunDefinitionAutoReloaderProperties, GunDefinitionAutoReloader,
ShellDefinition, ShellDefinition,
} from '../protos'; } from '../protos';
@@ -15,34 +15,46 @@ export function resolveDpm(
let dps: number; let dps: number;
if (gun.gun_type!.$case === 'regular') { if (gun.gun_type!.$case === 'regular') {
dps = alpha / (reloadCoefficient * gun.gun_type!.value.extension.reload); dps = alpha / (reloadCoefficient * gun.gun_type!.value.reload);
} else if (gun.gun_type!.$case === 'auto_loader') { } else if (gun.gun_type!.$case === 'auto_loader') {
dps = const damage = alpha * gun.gun_type!.value.shell_count;
(alpha * gun.gun_type!.value.extension.shell_count) / let time = gun.gun_type!.value.clip_reload * reloadCoefficient;
(gun.gun_type!.value.extension.clip_reload * reloadCoefficient +
(gun.gun_type!.value.extension.shell_count - 1) *
gun.gun_type!.value.extension.intra_clip *
intraClipCoefficient);
} else {
const mostOptimalShell =
gun.gun_type!.value.extension.shell_reloads.reduce<null | {
index: number;
reload: number;
}>((current, reloadRaw, index) => {
const reload =
reloadRaw * reloadCoefficient +
(index > 0
? (
gun.gun_type!.value
.extension as GunDefinitionAutoReloaderProperties
).intra_clip * intraClipCoefficient
: 0);
if (current === null || reload < current.reload) { if (gun.burst) {
return { index, reload }; time +=
} (gun.gun_type!.value.shell_count / gun.burst.count - 1) *
return current; gun.gun_type!.value.intra_clip *
}, null)!; intraClipCoefficient;
time +=
(gun.gun_type!.value.shell_count / gun.burst.count) *
(gun.burst.count - 1) *
gun.burst.interval;
} else {
time +=
(gun.gun_type!.value.shell_count - 1) *
gun.gun_type!.value.intra_clip *
intraClipCoefficient;
}
dps = damage / time;
} else {
const mostOptimalShell = gun.gun_type!.value.shell_reloads.reduce<null | {
index: number;
reload: number;
}>((current, reloadRaw, index) => {
const reload =
reloadRaw * reloadCoefficient +
(index > 0
? (gun.gun_type!.value as GunDefinitionAutoReloader).intra_clip *
intraClipCoefficient
: 0);
if (current === null || reload < current.reload) {
return { index, reload };
}
return current;
}, null)!;
dps = alpha / mostOptimalShell?.reload; dps = alpha / mostOptimalShell?.reload;
} }
+2 -2
View File
@@ -7,8 +7,8 @@ export function uniqueGuns(turrets: TurretDefinition[]) {
forEachRight(turrets, (turret) => { forEachRight(turrets, (turret) => {
forEachRight(turret.guns, (gun) => { forEachRight(turret.guns, (gun) => {
if (!gunIds.has(gun.gun_type!.value.base.id)) { if (!gunIds.has(gun.id)) {
gunIds.add(gun.gun_type!.value.base.id); gunIds.add(gun.id);
guns.push({ turret, gun }); guns.push({ turret, gun });
} }
}); });
+22 -33
View File
@@ -108,58 +108,47 @@ message GunDefinition {
GunDefinitionAutoLoader auto_loader = 2; GunDefinitionAutoLoader auto_loader = 2;
GunDefinitionAutoReloader auto_reloader = 3; GunDefinitionAutoReloader auto_reloader = 3;
} }
required uint32 id = 4;
required float rotation_speed = 5;
optional ResearchCost research_cost = 6;
required uint32 weight = 7;
required I18nString name = 8;
required uint32 tier = 9;
repeated ShellDefinition shells = 10;
required float camouflage_loss = 11;
required float aim_time = 12;
required float dispersion_base = 13;
required float dispersion_traverse = 14;
required float dispersion_shot = 15;
required float dispersion_damaged = 16;
repeated Unlock unlocks = 17;
required uint32 shell_capacity = 18;
optional AssaultRanges assault_ranges = 19;
optional Burst burst = 20;
} }
message GunDefinitionRegular { message GunDefinitionRegular {
required GunDefinitionBase base = 1;
required GunDefinitionRegularProperties extension = 2;
}
message GunDefinitionRegularProperties {
required float reload = 1; required float reload = 1;
} }
message GunDefinitionAutoLoader { message GunDefinitionAutoLoader {
required GunDefinitionBase base = 1;
required GunDefinitionAutoLoaderProperties extension = 2;
}
message GunDefinitionAutoLoaderProperties {
required float clip_reload = 1; required float clip_reload = 1;
required float intra_clip = 2; required float intra_clip = 2;
required float shell_count = 3; required float shell_count = 3;
} }
message GunDefinitionAutoReloader { message Burst {
required GunDefinitionBase base = 1; required uint32 count = 1;
required GunDefinitionAutoReloaderProperties extension = 2; required float interval = 2;
} }
message GunDefinitionAutoReloaderProperties { message GunDefinitionAutoReloader {
repeated float shell_reloads = 1; repeated float shell_reloads = 1;
required float intra_clip = 2; required float intra_clip = 2;
required float shell_count = 3; required float shell_count = 3;
} }
message GunDefinitionBase {
required uint32 id = 1;
required float rotation_speed = 2;
optional ResearchCost research_cost = 3;
required uint32 weight = 4;
required I18nString name = 5;
required uint32 tier = 6;
repeated ShellDefinition shells = 7;
required float camouflage_loss = 8;
required float aim_time = 9;
required float dispersion_base = 10;
required float dispersion_traverse = 11;
required float dispersion_shot = 12;
required float dispersion_damaged = 13;
repeated Unlock unlocks = 14;
required uint32 shell_capacity = 15;
optional AssaultRanges assault_ranges = 16;
}
message AssaultRanges { message AssaultRanges {
repeated AssaultRange ranges = 1; repeated AssaultRange ranges = 1;
repeated ShellType types = 2; repeated ShellType types = 2;
File diff suppressed because it is too large. Load diff
+29 -18
View File
@@ -861,6 +861,13 @@
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -887,6 +894,10 @@
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.", "tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -913,6 +924,13 @@
"percentage": "%s1%", "percentage": "%s1%",
"ghost_factory": "Ghost Factory", "ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail" "dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
} }
}, },
@@ -928,9 +946,7 @@
"green_penetration": "Green penetration", "green_penetration": "Green penetration",
"hide_model_under_armor": "Hide model under armor", "hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque", "opaque": "Opaque",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Environment",
"show_grid": "Show grid"
}, },
"screenshot": { "screenshot": {
@@ -1026,7 +1042,6 @@
"review": { "review": {
"title": "Review videos", "title": "Review videos",
"disclaimer": "BlitzKit does not endorse any review videos.",
"join": "Apply to %s1 YouTuber program" "join": "Apply to %s1 YouTuber program"
}, },
@@ -1090,8 +1105,8 @@
"dpmEffective": "Effective at 60s", "dpmEffective": "Effective at 60s",
"shells": "Shells", "shells": "Shells",
"mostOptimalShellIndex": "Most optimal shell", "mostOptimalShellIndex": "Most optimal shell",
"shellReloads": "Shell reloads", "shellReloads": "Reloads",
"shellReload": "Shell reload", "shellReload": "Reload",
"intraClip": "Intra-clip", "intraClip": "Intra-clip",
"caliber": "Caliber", "caliber": "Caliber",
"penetration": "Penetration", "penetration": "Penetration",
@@ -1149,7 +1164,9 @@
"shellRange": "Shell range", "shellRange": "Shell range",
"shellCapacity": "Shell capacity", "shellCapacity": "Shell capacity",
"gunType": "Gun type", "gunType": "Gun type",
"penetrationAt250m": "Penetration at 250m" "penetrationAt250m": "Penetration at 250m",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
} }
}, },
@@ -1429,17 +1446,11 @@
"description": "Get started with BlitzKit", "description": "Get started with BlitzKit",
"seo_description": "Get started with BlitzKit" "seo_description": "Get started with BlitzKit"
}, },
"blitz_analysis": { "api": {
"name": "BlitzAnalysis[]", "name": "API",
"button": "Analyze now", "button": "To the docs",
"description": "Rich historic average stats", "description": "BlitzKit hosted API",
"seo_description": "Rich historic average stats in World of Tanks Blitz" "seo_description": "BlitzKit hosted static API for World of Tanks Blitz"
},
"blitz_insider": {
"name": "Blitz Insider",
"button": "Discover today",
"description": "In-game session stats",
"seo_description": "In-game session stats in World of Tanks Blitz"
} }
}, },
+21 -7
View File
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Nivel %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)", "tank_seo_title": "%s1 - Nivel %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 blindaje, estadisticas, equipamiento, proviciones y mas.\n%s2 DPM, %3hp alfa, %s4mm penetracion, %s5kph velocidad maxima, %s6 hp salud", "tank_seo_description": "%s1 blindaje, estadisticas, equipamiento, proviciones y mas.\n%s2 DPM, %3hp alfa, %s4mm penetracion, %s5kph velocidad maxima, %s6 hp salud",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -810,6 +819,12 @@
"percentage": "%s1%", "percentage": "%s1%",
"ghost_factory": "Ghost Factory", "ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail" "dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
} }
}, },
"sandbox": { "sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "Penetración verde", "green_penetration": "Penetración verde",
"hide_model_under_armor": "Ocultar modelo bajo la armadura", "hide_model_under_armor": "Ocultar modelo bajo la armadura",
"opaque": "Opaco", "opaque": "Opaco",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Entorno",
"show_grid": "Mostrar cuadrícula"
}, },
"screenshot": { "screenshot": {
"download": "Descargar", "download": "Descargar",
@@ -912,7 +925,6 @@
}, },
"review": { "review": {
"title": "Vídeos de reseña", "title": "Vídeos de reseña",
"disclaimer": "BlitzKit no respalda ningún vídeo de reseñas.",
"join": "Aplica para el programa de YouTube de %s1" "join": "Aplica para el programa de YouTube de %s1"
}, },
"configuration": { "configuration": {
@@ -973,8 +985,8 @@
"dpmEffective": "Effective at 60s", "dpmEffective": "Effective at 60s",
"shells": "Proyectiles", "shells": "Proyectiles",
"mostOptimalShellIndex": "Proyectil más óptimo", "mostOptimalShellIndex": "Proyectil más óptimo",
"shellReloads": "Tiempos de recarga", "shellReloads": "Reloads",
"shellReload": "Tiempo de recarga", "shellReload": "Recarga",
"intraClip": "Intra-clip", "intraClip": "Intra-clip",
"caliber": "Calibre", "caliber": "Calibre",
"penetration": "Penetración", "penetration": "Penetración",
@@ -1032,7 +1044,9 @@
"shellRange": "Rango del proyectil", "shellRange": "Rango del proyectil",
"shellCapacity": "Capacidad de proyectiles", "shellCapacity": "Capacidad de proyectiles",
"gunType": "Tipo de cañón", "gunType": "Tipo de cañón",
"penetrationAt250m": "Penetración a 250 m" "penetrationAt250m": "Penetración a 250 m",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
} }
}, },
"firepower": { "firepower": {
+62 -48
View File
@@ -364,9 +364,9 @@
"non_tech_tree": "# %s1 n'est pas dans l'arbre technologique\n\nVous ne pouvez pas rechercher un char prenium/de collection.", "non_tech_tree": "# %s1 n'est pas dans l'arbre technologique\n\nVous ne pouvez pas rechercher un char prenium/de collection.",
"tier_1": "# %s1 est un char de tier I\n\nLe char de tier 1 sont déverrouillé gratuitement pour tous les joueurs.", "tier_1": "# %s1 est un char de tier I\n\nLe char de tier 1 sont déverrouillé gratuitement pour tous les joueurs.",
"already_researched": "# %s1 est déjà recherché\n\nEssayez de fournir un `char de départ` en utilisant la commande pour voir la progression d'une lignée.", "already_researched": "# %s1 est déjà recherché\n\nEssayez de fournir un `char de départ` en utilisant la commande pour voir la progression d'une lignée.",
"start_end_equal": "# %s1 was provided twice\n\nMake sure `starting-tank` and `target-tank` are different.", "start_end_equal": "# %s1 a été fourni deux fois\n\nAssurez-vous que `starting-tank` et `target-tank` sont différents.",
"tanks_not_on_line": "# %s1 and %s2 are not on the same line\n\nMake sure `starting-tank` and `target-tank` are on the same tech-tree line.", "tanks_not_on_line": "%s1 et %s2 ne sont pas sur la même ligne \n\nAssurez-vous que « starting-tank » et « target-tank » sont sur la même ligne d'arbre technologique.",
"unordered": "# %s1 higher tiered to %s2\n\nMake sure to provide %s3 as the `starting-tank` instead as it is the lower tiered tank." "unordered": "# %s1 niveau supérieur à %s2\n\nAssurez-vous de fournir %s3 comme le `starting-tank` à la place car c'est le réservoir de niveau inférieur."
} }
}, },
"about": { "about": {
@@ -380,7 +380,7 @@
}, },
"embeds": { "embeds": {
"name": "intègre", "name": "intègre",
"description": "How embeds work" "description": "Comment fonctionnent les intégrations"
}, },
"introduction": { "introduction": {
"name": "introduction", "name": "introduction",
@@ -402,7 +402,7 @@
}, },
"breakdown": { "breakdown": {
"name": "décomposition", "name": "décomposition",
"description": "A period's breakdown by tanks played", "description": "Répartition d'une période par chars joués",
"body": { "body": {
"total": "Total", "total": "Total",
"battles": "Batailles", "battles": "Batailles",
@@ -471,7 +471,7 @@
"accuracy": "Précision" "accuracy": "Précision"
}, },
"lethality": { "lethality": {
"title": "Lethality", "title": "Létalité",
"average_damage": "Dégâts moyens", "average_damage": "Dégâts moyens",
"damage_per_tier": "Dégâts par tier", "damage_per_tier": "Dégâts par tier",
"damage_ratio": "Rapport de dégâts", "damage_ratio": "Rapport de dégâts",
@@ -499,20 +499,20 @@
}, },
"embed": { "embed": {
"title": "Compte invalide ?", "title": "Compte invalide ?",
"description": "Use the command again and select a username from the search results." "description": "Utilisez à nouveau la commande et sélectionnez un nom d'utilisateur à partir des résultats de recherche."
} }
}, },
"search_clans": { "search_clans": {
"name": "search-clans", "name": "clans de recherche",
"description": "Search clans", "description": "Rechercher des clans",
"options": { "options": {
"limit": { "limit": {
"name": "limite", "name": "limite",
"description": "The max number of clan results (default: %s1)" "description": "Nombre maximum de résultats du clan (par défaut: %s1)"
} }
}, },
"body": { "body": {
"title": "Clan search for %s1 in region %s2", "title": "Recherche de clan %s1 dans la région %s2",
"no_results": "Pas de clan trouvé" "no_results": "Pas de clan trouvé"
} }
}, },
@@ -535,69 +535,69 @@
"description": "Statistiques des batailles de cotés", "description": "Statistiques des batailles de cotés",
"subcommands": { "subcommands": {
"neighbors": { "neighbors": {
"name": "neighbors", "name": "voisins",
"description": "Neighboring players on the leaderboard" "description": "Joueurs voisins dans le classement"
}, },
"league": { "league": {
"name": "ligue", "name": "ligue",
"description": "Top players of a league" "description": "Meilleurs joueurs d'une ligue"
} }
}, },
"options": { "options": {
"limit": { "limit": {
"name": "limite", "name": "limite",
"description": "How many neighbors to display (default: %s1)" "description": "Combien de voisins afficher (par défaut : %s1)"
}, },
"season": { "season": {
"name": "season", "name": "saison",
"description": "Season number (default: current)", "description": "Numéro de saison (défaut : courant)",
"choices": { "choices": {
"current": "%s1 (current)" "current": "%s1 (actuel)"
} }
}, },
"league": { "league": {
"name": "ligue", "name": "ligue",
"description": "The league to display" "description": "La ligue à afficher"
} }
}, },
"body": { "body": {
"top_players": "Top %s1 players", "top_players": "Top %s1 joueurs",
"no_participation": "# %s1 didn't play rating in season %s2\n\nThis player did not participate in this season or did not get past calibration.", "no_participation": "%s1 n'a pas joué au classement dans la saison %s2",
"no_ongoing_season": "# No ongoing season\n\nThere isn't a currently running rating season.", "no_ongoing_season": "Pas de saison en cours\n\nIl n'y a pas de saison de notation en cours actuellement.",
"deleted_player": "Deleted %s1", "deleted_player": "%s1 supprimé",
"neighbors": "Rating neighbors", "neighbors": "évaluer les voisins",
"more": "+ %s1 more" "more": "+ %s1 de plus"
} }
}, },
"today": { "today": {
"name": "aujourd'hui", "name": "aujourd'hui",
"description": "Today's played tanks and statistics" "description": "Les chars et les statistiques joués aujourd'hui"
}, },
"stats": { "stats": {
"name": "statistiques", "name": "statistiques",
"description": "Quick statistics for a period" "description": "Statistiques rapides pour une période"
}, },
"owned_tanks": { "owned_tanks": {
"name": "owned-tanks", "name": "chars possédés",
"description": "Shows all own tanks in a tier", "description": "Affiche tous les chars d'un niveau",
"body": { "body": {
"title": "# %s1's owned tier %s2 tanks\n\n" "title": "# %s1 possédait des chars de rang %s2"
} }
}, },
"permissions": { "permissions": {
"name": "permissions", "name": "permissions",
"description": "Checks wether the bot has the required permissions to function", "description": "Vérifie si le bot a les permissions nécessaires pour fonctionner",
"body": { "body": {
"title": "# Permissions\n\n%s1", "title": "# Permissions\n\n%s1",
"view_channels": "View channels: needed to find the message the refresh button was used on", "view_channels": "Afficher les canaux : nécessaire pour trouver le message sur lequel le bouton d'actualisation a été utilisé",
"read_message_history": "Read message history: needed to find the message to refresh", "read_message_history": "Lire l'historique des messages : nécessaire pour trouver le message à rafraîchir",
"attach_files": "Attach files: needed to add images to image-based commands", "attach_files": "Joindre des fichiers : nécessaire pour ajouter des images à des commandes basées sur l'image",
"external_emojis": "External emojis: needed to add national flags and tank class emojis" "external_emojis": "Émoticônes externes: nécessaire pour ajouter des drapeaux nationaux et des émojis de classe de char"
} }
}, },
"ping": { "ping": {
"name": "ping", "name": "ping",
"description": "Check wether the bot is alive.", "description": "Vérifiez si le bot est en vie.",
"body": { "body": {
"pong": "Pong 🏓", "pong": "Pong 🏓",
"pong_with_time": "Pong 🏓 - %s1ms" "pong_with_time": "Pong 🏓 - %s1ms"
@@ -605,7 +605,7 @@
"subcommands": { "subcommands": {
"blitzkit": { "blitzkit": {
"name": "blitzkit", "name": "blitzkit",
"description": "Ping BlitzKit" "description": "Ping blitzkit"
}, },
"wotb": { "wotb": {
"name": "wotb", "name": "wotb",
@@ -614,10 +614,10 @@
} }
}, },
"player_info": { "player_info": {
"name": "player-info", "name": "info-joueur",
"description": "Basic information about a player", "description": "Informations de base sur un joueur",
"body": { "body": {
"title": "%s1's information", "title": "Informations de %s1",
"nickname": "Nickname", "nickname": "Nickname",
"battles": "Batailles", "battles": "Batailles",
"winrate": "Taux de victoires", "winrate": "Taux de victoires",
@@ -635,7 +635,7 @@
"description": "Today's rating statistics" "description": "Today's rating statistics"
}, },
"season": { "season": {
"name": "season", "name": "saison",
"description": "This season's rating statistics" "description": "This season's rating statistics"
} }
}, },
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)", "tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.", "tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -810,6 +819,12 @@
"percentage": "%s1%", "percentage": "%s1%",
"ghost_factory": "Ghost Factory", "ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail" "dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
} }
}, },
"sandbox": { "sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "Green penetration", "green_penetration": "Green penetration",
"hide_model_under_armor": "Hide model under armor", "hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque", "opaque": "Opaque",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Environment",
"show_grid": "Show grid"
}, },
"screenshot": { "screenshot": {
"download": "Download", "download": "Download",
@@ -912,7 +925,6 @@
}, },
"review": { "review": {
"title": "Review videos", "title": "Review videos",
"disclaimer": "BlitzKit does not endorse any review videos.",
"join": "Apply to %s1 YouTuber program" "join": "Apply to %s1 YouTuber program"
}, },
"configuration": { "configuration": {
@@ -973,8 +985,8 @@
"dpmEffective": "Effective at 60s", "dpmEffective": "Effective at 60s",
"shells": "Shells", "shells": "Shells",
"mostOptimalShellIndex": "Most optimal shell", "mostOptimalShellIndex": "Most optimal shell",
"shellReloads": "Shell reloads", "shellReloads": "Reloads",
"shellReload": "Shell reload", "shellReload": "Reload",
"intraClip": "Intra-clip", "intraClip": "Intra-clip",
"caliber": "Caliber", "caliber": "Caliber",
"penetration": "Penetration", "penetration": "Penetration",
@@ -1032,7 +1044,9 @@
"shellRange": "Shell range", "shellRange": "Shell range",
"shellCapacity": "Shell capacity", "shellCapacity": "Shell capacity",
"gunType": "Gun type", "gunType": "Gun type",
"penetrationAt250m": "Penetration at 250m" "penetrationAt250m": "Penetration at 250m",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
} }
}, },
"firepower": { "firepower": {
+20 -6
View File
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)", "tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.", "tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -810,6 +819,12 @@
"percentage": "%s1%", "percentage": "%s1%",
"ghost_factory": "Ghost Factory", "ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail" "dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
} }
}, },
"sandbox": { "sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "貫通可能域を緑色表示", "green_penetration": "貫通可能域を緑色表示",
"hide_model_under_armor": "車輌モデルを非表示", "hide_model_under_armor": "車輌モデルを非表示",
"opaque": "貫通アシストを不透明にする", "opaque": "貫通アシストを不透明にする",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "環境",
"show_grid": "グリッドを表示"
}, },
"screenshot": { "screenshot": {
"download": "保存する", "download": "保存する",
@@ -912,7 +925,6 @@
}, },
"review": { "review": {
"title": "Review videos", "title": "Review videos",
"disclaimer": "BlitzKitはいかなるレビュー動画も後援していません。",
"join": "%s1 YouTuberプログラムに応募する" "join": "%s1 YouTuberプログラムに応募する"
}, },
"configuration": { "configuration": {
@@ -973,7 +985,7 @@
"dpmEffective": "60秒での有効値", "dpmEffective": "60秒での有効値",
"shells": "砲弾", "shells": "砲弾",
"mostOptimalShellIndex": "最適な弾数", "mostOptimalShellIndex": "最適な弾数",
"shellReloads": "自動装填時間", "shellReloads": "Reloads",
"shellReload": "装填時間", "shellReload": "装填時間",
"intraClip": "砲弾再装填時間", "intraClip": "砲弾再装填時間",
"caliber": "口径", "caliber": "口径",
@@ -1032,7 +1044,9 @@
"shellRange": "射程", "shellRange": "射程",
"shellCapacity": "総弾数", "shellCapacity": "総弾数",
"gunType": "主砲タイプ", "gunType": "主砲タイプ",
"penetrationAt250m": "250m での貫通力" "penetrationAt250m": "250m での貫通力",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
} }
}, },
"firepower": { "firepower": {
+21 -7
View File
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)", "tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.", "tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -810,6 +819,12 @@
"percentage": "%s1%", "percentage": "%s1%",
"ghost_factory": "Ghost Factory", "ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail" "dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
} }
}, },
"sandbox": { "sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "Green penetration", "green_penetration": "Green penetration",
"hide_model_under_armor": "Hide model under armor", "hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque", "opaque": "Opaque",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Environment",
"show_grid": "Show grid"
}, },
"screenshot": { "screenshot": {
"download": "Download", "download": "Download",
@@ -912,7 +925,6 @@
}, },
"review": { "review": {
"title": "Review videos", "title": "Review videos",
"disclaimer": "BlitzKit does not endorse any review videos.",
"join": "Apply to %s1 YouTuber program" "join": "Apply to %s1 YouTuber program"
}, },
"configuration": { "configuration": {
@@ -973,8 +985,8 @@
"dpmEffective": "Effective at 60s", "dpmEffective": "Effective at 60s",
"shells": "Shells", "shells": "Shells",
"mostOptimalShellIndex": "Most optimal shell", "mostOptimalShellIndex": "Most optimal shell",
"shellReloads": "Shell reloads", "shellReloads": "Reloads",
"shellReload": "Shell reload", "shellReload": "Reload",
"intraClip": "Intra-clip", "intraClip": "Intra-clip",
"caliber": "Caliber", "caliber": "Caliber",
"penetration": "Penetration", "penetration": "Penetration",
@@ -1032,7 +1044,9 @@
"shellRange": "Shell range", "shellRange": "Shell range",
"shellCapacity": "Shell capacity", "shellCapacity": "Shell capacity",
"gunType": "Gun type", "gunType": "Gun type",
"penetrationAt250m": "Penetration at 250m" "penetrationAt250m": "Penetration at 250m",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
} }
}, },
"firepower": { "firepower": {
+21 -7
View File
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Nível %s2 %s3 %s4 | %s5 Tanquepedia | World of Tanks Blitz (WoTB)", "tank_seo_title": "%s1 - Nível %s2 %s3 %s4 | %s5 Tanquepedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 blindagem, estatísticas, equipamentos, provisões e mais. %s2 DPM, %s3hp alfa, %s4mm de penetração, %s5km/h velocidade máxima, %s6hp vida.", "tank_seo_description": "%s1 blindagem, estatísticas, equipamentos, provisões e mais. %s2 DPM, %s3hp alfa, %s4mm de penetração, %s5km/h velocidade máxima, %s6hp vida.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -810,6 +819,12 @@
"percentage": "%s1%", "percentage": "%s1%",
"ghost_factory": "Ghost Factory", "ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail" "dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
} }
}, },
"sandbox": { "sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "Penetração verde", "green_penetration": "Penetração verde",
"hide_model_under_armor": "Ocultar modelo sob blindagem", "hide_model_under_armor": "Ocultar modelo sob blindagem",
"opaque": "Opaco", "opaque": "Opaco",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Ambiente",
"show_grid": "Exibir grade"
}, },
"screenshot": { "screenshot": {
"download": "Download", "download": "Download",
@@ -912,7 +925,6 @@
}, },
"review": { "review": {
"title": "Vídeos de análise", "title": "Vídeos de análise",
"disclaimer": "BlitzKit não aprova nenhum vídeo de análise.",
"join": "Candidate-se para %s1 um programa YouTuber" "join": "Candidate-se para %s1 um programa YouTuber"
}, },
"configuration": { "configuration": {
@@ -973,8 +985,8 @@
"dpmEffective": "Efetivo aos 60s", "dpmEffective": "Efetivo aos 60s",
"shells": "Projéteis", "shells": "Projéteis",
"mostOptimalShellIndex": "Projétil mais ideal", "mostOptimalShellIndex": "Projétil mais ideal",
"shellReloads": "Recarga de projétil", "shellReloads": "Reloads",
"shellReload": "Recarregamento de projétil", "shellReload": "Recarga",
"intraClip": "Intra-clipe", "intraClip": "Intra-clipe",
"caliber": "Calibre", "caliber": "Calibre",
"penetration": "Penetração", "penetration": "Penetração",
@@ -1032,7 +1044,9 @@
"shellRange": "Alcance de projétil", "shellRange": "Alcance de projétil",
"shellCapacity": "Capacidade de projéteis", "shellCapacity": "Capacidade de projéteis",
"gunType": "Tipo de arma", "gunType": "Tipo de arma",
"penetrationAt250m": "Penetração à 250m" "penetrationAt250m": "Penetração à 250m",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
} }
}, },
"firepower": { "firepower": {
+67 -53
View File
@@ -1,8 +1,8 @@
{ {
"test": { "test": {
"default": "тест", "default": "тест",
"long": "Это длинная тестовая строка. Вы НИКОГДА не должны видеть её в пользовательском интерфейсе. Мой любимый фрукт - %s1, потому что его цвет %s2. Он мне так нравится, что я произнесу его имя 3 раза! %s1! %s1! %s1! И ещё раз, это %s2, а не %s3.", "long": "Это длинная тестовая строка. Вы НИКОГДА не должны видеть её в пользовательском интерфейсе. Мой любимый фрукт — %s1, потому что это цвет %s2. Он мне так нравится, что я произнесу его имя 3 раза! %s1! %s1! %s1! И ещё раз, это %s2, а не %s3.",
"fox": "Это длинная тестовая строка. Вы НИКОГДА не должны видеть её в пользовательском интерфейсе. Мой любимый фрукт - %s1, потому что его цвет %s2. Он мне так нравится, что я произнесу его имя 3 раза! %s1! %s1! %s1! И ещё раз, это %s2, а не %s3." "fox": "Съешь же ещё этих мягких французских булок, да выпей чаю."
}, },
"common": { "common": {
"units": { "units": {
@@ -283,15 +283,15 @@
"name": "уровень", "name": "уровень",
"description": "Уровень", "description": "Уровень",
"choices": { "choices": {
"tier": "Уровень %s - %s" "tier": "Уровень %s1 - %s2"
} }
} }
}, },
"periods": { "periods": {
"career": "Карьера", "career": "Карьера",
"today": "Cегодня", "today": "Cегодня",
"days": "%s дней", "days": "%s1 дней",
"custom": "%s - %s дней" "custom": "%s1 - %s2 дней"
}, },
"filters": { "filters": {
"none": "Без фильтров", "none": "Без фильтров",
@@ -323,9 +323,9 @@
"description": "Танки доступные на аукционе сейчас", "description": "Танки доступные на аукционе сейчас",
"body": { "body": {
"title": "Blitz аукцион", "title": "Blitz аукцион",
"subtitle": "%s доступные • следующие цены %s\nПокупка сейчас: %s", "subtitle": "%s1 доступные • следующие цены %s2\nПокупка сейчас: %s3",
"next": "Следующая цена: %s", "next": "Следующая цена: %s1",
"available": "Доступно: %s / %s" "available": "Доступно: %s1 / %s2"
}, },
"errors": { "errors": {
"no_auction": "# Аукцион сейчас не проходит\n\nНа данный момент нет танков для покупки на аукционе. Если считаете, что это ошибка, пожалуйста о несоответствии на [официальном сервере Discord](https://discord.gg/nDt7AjGJQH)." "no_auction": "# Аукцион сейчас не проходит\n\nНа данный момент нет танков для покупки на аукционе. Если считаете, что это ошибка, пожалуйста о несоответствии на [официальном сервере Discord](https://discord.gg/nDt7AjGJQH)."
@@ -354,19 +354,19 @@
"body": { "body": {
"subtitle": "Исследование", "subtitle": "Исследование",
"total": "Всего", "total": "Всего",
"research": "Исследование: %s", "research": "Исследование: %s1",
"upgrades": "Улучшения: %s", "upgrades": "Улучшения: %s1",
"purchase": "Покупка: %s", "purchase": "Покупка: %s1",
"equipment": "Снаряжение: %s", "equipment": "Снаряжение: %s1",
"estimation": "# Это приблизительная оценка!\n\nВ этот список могут входить уже исследованные танки из-за неточной информации, предоставляемой Wargaming. Попробуйте указать «стартовый-танк» в строке при использовании команды, если сможете." "estimation": "# Это приблизительная оценка!\n\nВ этот список могут входить уже исследованные танки из-за неточной информации, предоставляемой Wargaming. Попробуйте указать «стартовый-танк» в строке при использовании команды, если сможете."
}, },
"errors": { "errors": {
"non_tech_tree": "# %s отсутствует в дереве танков\n\nВы не можете исследовать премиум / коллекционный танк.", "non_tech_tree": "# %s1 отсутствует в дереве танков\n\nВы не можете исследовать премиум / коллекционный танк.",
"tier_1": "# %s - это уровень I\n\nУровень I всегда открыт бесплатно для всех игроков.", "tier_1": "# %s1 - это уровень I\n\nУровень I всегда открыт бесплатно для всех игроков.",
"already_researched": "# %s уже исследован\n\nПопробуйте предоставить «starting-tank» при использовании команды для просмотра итогов уже исследованной ветки.", "already_researched": "# %s1 уже исследован\n\nПопробуйте предоставить «starting-tank» при использовании команды для просмотра итогов уже исследованной ветки.",
"start_end_equal": "# %s был предоставлен дважды\n\nУбедитесь, что «starting-tank» и «target-tank» различаются.", "start_end_equal": "# %s1 был предоставлен дважды\n\nУбедитесь, что «starting-tank» и «target-tank» различаются.",
"tanks_not_on_line": "# %s и %s находятся не в одной ветке\n\nУбедитесь, что «starting-tank» и «target-tank» находятся на той же ветке дерева танков.", "tanks_not_on_line": "# %s1 и %s2 находятся не в одной ветке\n\nУбедитесь, что «starting-tank» и «target-tank» находятся на той же ветке дерева танков.",
"unordered": "# У %s более высокий уровень чем у %s\n\nОбязательно укажите %s в качестве «starting-tank», так как это танк более низкого уровня." "unordered": "# У %s1 более высокий уровень чем у %s2\n\nОбязательно укажите %s3 в качестве «starting-tank», так как это танк более низкого уровня."
} }
}, },
"about": { "about": {
@@ -409,7 +409,7 @@
"wn8": "WN8", "wn8": "WN8",
"winrate": "Коэффициент побед", "winrate": "Коэффициент побед",
"damage": "Урон", "damage": "Урон",
"unknown_tank": "Неизвестный танк %s" "unknown_tank": "Неизвестный танк %s1"
} }
}, },
"debug": { "debug": {
@@ -445,12 +445,12 @@
"options": { "options": {
"threshold": { "threshold": {
"name": "порог", "name": "порог",
"description": "Количество дней не активности (по умолчанию: %s дней)" "description": "Количество дней не активности (по умолчанию: %s1 дней)"
} }
}, },
"body": { "body": {
"subtitle": "Неактивен %s+ дней", "subtitle": "Неактивен %s1+ дней",
"listing": "%s дней" "listing": "%s1 дней"
} }
}, },
"full_stats": { "full_stats": {
@@ -508,11 +508,11 @@
"options": { "options": {
"limit": { "limit": {
"name": "лимит", "name": "лимит",
"description": "Максимальное количество результатов клана (по умолчанию: %s)" "description": "Максимальное количество результатов клана (по умолчанию: %s1)"
} }
}, },
"body": { "body": {
"title": "Поиск клана %s в регионе %s", "title": "Поиск клана %s1 в регионе %s2",
"no_results": "Кланов не найдено" "no_results": "Кланов не найдено"
} }
}, },
@@ -522,11 +522,11 @@
"options": { "options": {
"limit": { "limit": {
"name": "лимит", "name": "лимит",
"description": "Максимальное число результатов игрока (по умолчанию: %s)" "description": "Максимальное число результатов игрока (по умолчанию: %s1)"
} }
}, },
"body": { "body": {
"title": "Поиск игрока %s в регионе %s", "title": "Поиск игрока %s1 в регионе %s2",
"no_results": "Игроков не найдено" "no_results": "Игроков не найдено"
} }
}, },
@@ -546,13 +546,13 @@
"options": { "options": {
"limit": { "limit": {
"name": "лимит", "name": "лимит",
"description": "Количество отображаемых ближайших игроков (по умолчанию: %s)" "description": "Количество отображаемых ближайших игроков (по умолчанию: %s1)"
}, },
"season": { "season": {
"name": "сезон", "name": "сезон",
"description": "Номер сезона (по умолчанию: текущий)", "description": "Номер сезона (по умолчанию: текущий)",
"choices": { "choices": {
"current": "%s (текущая)" "current": "%s1 (текущая)"
} }
}, },
"league": { "league": {
@@ -561,12 +561,12 @@
} }
}, },
"body": { "body": {
"top_players": "Топ %s игроков", "top_players": "Топ %s1 игроков",
"no_participation": "# %s не играл в рейтинг в сезоне %s\n\nЭтот игрок не участвовал в этом сезоне или не получил прошлую калибровку.", "no_participation": "# %s1 не играл в рейтинг в сезоне %s2\n\nЭтот игрок не участвовал в этом сезоне или не получил прошлую калибровку.",
"no_ongoing_season": "# Нет текущего сезона\n\nВ данный момент нет рейтингового сезона.", "no_ongoing_season": "# Нет текущего сезона\n\nВ данный момент нет рейтингового сезона.",
"deleted_player": "Удалено %s", "deleted_player": "Удалено %s1",
"neighbors": "Рейтинг ближайших игроков", "neighbors": "Рейтинг ближайших игроков",
"more": "+ ещё %s" "more": "+ ещё %s1"
} }
}, },
"today": { "today": {
@@ -581,14 +581,14 @@
"name": "cобственные-танки", "name": "cобственные-танки",
"description": "Показывает все принадлежащие танки на уровне", "description": "Показывает все принадлежащие танки на уровне",
"body": { "body": {
"title": "# %s принадлежал уровень %s танков\n\n" "title": "# %s1 принадлежал уровень %s2 танков\n\n"
} }
}, },
"permissions": { "permissions": {
"name": "разрешения", "name": "разрешения",
"description": "Проверяет, есть ли у бота необходимые разрешения для работы", "description": "Проверяет, есть ли у бота необходимые разрешения для работы",
"body": { "body": {
"title": "# Разрешения\n\n%s", "title": "# Разрешения\n\n%s1",
"view_channels": "Просмотр каналов: необходимо найти сообщение, на котором была использована кнопка обновления", "view_channels": "Просмотр каналов: необходимо найти сообщение, на котором была использована кнопка обновления",
"read_message_history": "Просмотр истории сообщений: необходимо найти сообщение для обновления", "read_message_history": "Просмотр истории сообщений: необходимо найти сообщение для обновления",
"attach_files": "Прикрепить файлы: необходимо для добавления изображений в команды на основе изображения", "attach_files": "Прикрепить файлы: необходимо для добавления изображений в команды на основе изображения",
@@ -600,7 +600,7 @@
"description": "Проверьте, что бот жив.", "description": "Проверьте, что бот жив.",
"body": { "body": {
"pong": "Понг 🏓", "pong": "Понг 🏓",
"pong_with_time": "Понг 🏓 - %sмс" "pong_with_time": "Понг 🏓 - %s1мс"
}, },
"subcommands": { "subcommands": {
"blitzkit": { "blitzkit": {
@@ -617,7 +617,7 @@
"name": "информация-об-игроке", "name": "информация-об-игроке",
"description": "Основная информация о игроке", "description": "Основная информация о игроке",
"body": { "body": {
"title": "Информация о %s", "title": "Информация о %s1",
"nickname": "Никнейм", "nickname": "Никнейм",
"battles": "Боёв", "battles": "Боёв",
"winrate": "Коэффициент побед", "winrate": "Коэффициент побед",
@@ -646,7 +646,7 @@
} }
}, },
"errors": { "errors": {
"no_participation": "# %s не получил рейтинга\n\nЭтот игрок в этом сезоне не участвовал в рейтинговом режиме.", "no_participation": "# %s1 не получил рейтинга\n\nЭтот игрок в этом сезоне не участвовал в рейтинговом режиме.",
"unrecorded_stats": "# Статистика недоступна в настоящее время\n\nВ настоящее время Wargaming, возможно, сталкивается с простоями сервера. Если сезон продолжается, статистика должна быть доступна в течение 24 часов." "unrecorded_stats": "# Статистика недоступна в настоящее время\n\nВ настоящее время Wargaming, возможно, сталкивается с простоями сервера. Если сезон продолжается, статистика должна быть доступна в течение 24 часов."
} }
} }
@@ -676,7 +676,7 @@
"recent": "Недавние", "recent": "Недавние",
"select_all": "Выбрать всё", "select_all": "Выбрать всё",
"count_singular": "1 танк", "count_singular": "1 танк",
"count_plural": "%s1 танк", "count_plural": "Танков: %s1",
"sorting_by": "Сортировать по %s1", "sorting_by": "Сортировать по %s1",
"no_tanks_found": { "no_tanks_found": {
"body": "Танков не найдено.", "body": "Танков не найдено.",
@@ -693,7 +693,7 @@
"survivability.volume": "объём", "survivability.volume": "объём",
"survivability.length": "длина", "survivability.length": "длина",
"fire.dpm": "базовый ДПМ", "fire.dpm": "базовый ДПМ",
"fire.dpmPremium": "улучшенный ДПМ", "fire.dpmPremium": "премиумный ДПМ",
"fire.reload": "перезарядка", "fire.reload": "перезарядка",
"fire.caliber": "калибр", "fire.caliber": "калибр",
"fire.standardPenetration": "базовое пробитие", "fire.standardPenetration": "базовое пробитие",
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Смешиватель",
"button": "Смешать и собрать",
"description": "Модули смешанного танка",
"seo_description": "Смешивание и сбор корпусов, башен и пушек танков из World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Угадайте танк", "name": "Угадайте танк",
"button": "Угадать", "button": "Угадать",
@@ -784,11 +790,14 @@
"button": "Поиск танков", "button": "Поиск танков",
"description": "Танковая энциклопедия Blitz", "description": "Танковая энциклопедия Blitz",
"seo_description": "Статистика, броня и многое другое по всем танкам World of Tanks Blitz", "seo_description": "Статистика, броня и многое другое по всем танкам World of Tanks Blitz",
"tank_seo_title": "%s1 - %s4 %s3 %s2 уровня | Танкопедия %s5 | World of Tanks Blitz (WoTB)", "tank_seo_title": "%s1 — %s4 %s3 %s2 уровня | Танкопедия %s5 | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 броня, статистика, оборудование, снабжение и многое другое. %s2 УВМ, %s3hp альфа, %s4mm пробитие, %s5км/ч лучшая скорость, %s6hp здоровье.", "tank_seo_description": "%s1 броня, статистика, оборудование, снабжение и многое другое. %s2 УВМ, %s3hp альфа, %s4mm пробитие, %s5км/ч лучшая скорость, %s6hp здоровье.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Пропуск"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Штурмовые снаряды"
}, },
"rotator": { "rotator": {
"hull": "Корпус", "hull": "Корпус",
@@ -810,6 +819,12 @@
"percentage": "%s1%", "percentage": "%s1%",
"ghost_factory": "Промзона", "ghost_factory": "Промзона",
"dead_rail": "Эшелон" "dead_rail": "Эшелон"
},
"flexibility": {
"yaw": "Поворот",
"pitch": "Наклон",
"min": "Вниз",
"max": "Вверх"
} }
}, },
"sandbox": { "sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "Зелёное пробитие", "green_penetration": "Зелёное пробитие",
"hide_model_under_armor": "Скрыть модель под броней", "hide_model_under_armor": "Скрыть модель под броней",
"opaque": "Прозрачность", "opaque": "Прозрачность",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Окружение",
"show_grid": "Показать сетку"
}, },
"screenshot": { "screenshot": {
"download": "Загрузить", "download": "Загрузить",
@@ -896,7 +909,7 @@
"tier": "Уровень", "tier": "Уровень",
"type": "Тип", "type": "Тип",
"dev_id": "DEV: ID", "dev_id": "DEV: ID",
"purchase": "Стоимость покупки", "purchase": "Цена покупки",
"restoration": "Цена восстановления", "restoration": "Цена восстановления",
"sale": "Цена продажи", "sale": "Цена продажи",
"research": "Необходимо опыта" "research": "Необходимо опыта"
@@ -912,7 +925,6 @@
}, },
"review": { "review": {
"title": "Видеообзоры", "title": "Видеообзоры",
"disclaimer": "BlitzKit не несёт ответственности за видеообзоры.",
"join": "Предложить ютубера для программы %s1" "join": "Предложить ютубера для программы %s1"
}, },
"configuration": { "configuration": {
@@ -973,13 +985,13 @@
"dpmEffective": "Эффективность за 60с", "dpmEffective": "Эффективность за 60с",
"shells": "Снарядов в барабане", "shells": "Снарядов в барабане",
"mostOptimalShellIndex": "Самый эффективный снаряд", "mostOptimalShellIndex": "Самый эффективный снаряд",
"shellReloads": "Перезарядка снарядов", "shellReloads": "Перезарядка",
"shellReload": "Перезарядка", "shellReload": "Перезарядка",
"intraClip": "Между снарядами", "intraClip": "Между снарядами",
"caliber": "Калибр", "caliber": "Калибр",
"penetration": "Пробитие", "penetration": "Пробитие",
"damage": "Урон", "damage": "Урон",
"damageWithoutAssault": "Damage without assault shells", "damageWithoutAssault": "Урон без штурмовых снарядов",
"clipDamage": "Урон с барабана", "clipDamage": "Урон с барабана",
"moduleDamage": "Урон по модулям", "moduleDamage": "Урон по модулям",
"explosionRadius": "Разлет осколков", "explosionRadius": "Разлет осколков",
@@ -1032,7 +1044,9 @@
"shellRange": "Дальность снаряда", "shellRange": "Дальность снаряда",
"shellCapacity": "Боезапас", "shellCapacity": "Боезапас",
"gunType": "Тип орудия", "gunType": "Тип орудия",
"penetrationAt250m": "Пробитие на 250м" "penetrationAt250m": "Пробитие на 250м",
"burstShells": "Кол-во разрывных снарядов",
"burstInterShell": "Взрыв между снарядами"
} }
}, },
"firepower": { "firepower": {
@@ -1129,7 +1143,7 @@
"hits": "Уничтожен", "hits": "Уничтожен",
"damage_ratio": "Коэф. урона", "damage_ratio": "Коэф. урона",
"damage_taken": "Получено урона", "damage_taken": "Получено урона",
"capture_points": "Ограничительные точки" "capture_points": "Точки ограничения"
} }
} }
}, },
@@ -1141,7 +1155,7 @@
"last_updated": "Последнее обновление %s1", "last_updated": "Последнее обновление %s1",
"search": { "search": {
"hint": "Поиск аватаров...", "hint": "Поиск аватаров...",
"results": "%s1 аватар" "results": "Аватаров: %s1"
} }
}, },
"session": { "session": {
@@ -1150,7 +1164,7 @@
"description": "Статистика сеанса игры", "description": "Статистика сеанса игры",
"seo_description": "Отслеживайте свою статистику в реальном времени во время игры World of Tanks Blitz", "seo_description": "Отслеживайте свою статистику в реальном времени во время игры World of Tanks Blitz",
"search": { "search": {
"lookup": "Искать игрока, чтобы начать", "lookup": "Введите ник игрока",
"hint": "Поиск игроков...", "hint": "Поиск игроков...",
"searching": "Поиск...", "searching": "Поиск...",
"no_results": "Игроков не найдено" "no_results": "Игроков не найдено"
@@ -1191,7 +1205,7 @@
"button": "Создать", "button": "Создать",
"description": "Встраиваемые элементы для стримов", "description": "Встраиваемые элементы для стримов",
"seo_description": "Создать вставки для стримов по World of Tanks Blitz с невероятной кастомизации", "seo_description": "Создать вставки для стримов по World of Tanks Blitz с невероятной кастомизации",
"coming_soon": "Скоро появятся новые встраиваемые элементы.", "coming_soon": "В скором времени появятся новые встраиваемые элементы.",
"suggest": "Предложить идеи.", "suggest": "Предложить идеи.",
"preview": { "preview": {
"title": "Предпросмотр", "title": "Предпросмотр",
+21 -7
View File
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Рівень %s2 %s3 %s4 | %s5 Танкопедія | WoT Blitz", "tank_seo_title": "%s1 - Рівень %s2 %s3 %s4 | %s5 Танкопедія | WoT Blitz",
"tank_seo_description": "%s1 броня, характеристики, обладнання, амуніція та більше. %s2 шкода на хвилину, %s3 разова шкода, %s4 мм пробиття, %s5 км/год максимальна швидкість, %s6 міцність.", "tank_seo_description": "%s1 броня, характеристики, обладнання, амуніція та більше. %s2 шкода на хвилину, %s3 разова шкода, %s4 мм пробиття, %s5 км/год максимальна швидкість, %s6 міцність.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -810,6 +819,12 @@
"percentage": "%s1%", "percentage": "%s1%",
"ghost_factory": "Ghost Factory", "ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail" "dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
} }
}, },
"sandbox": { "sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "Зелене пробиття", "green_penetration": "Зелене пробиття",
"hide_model_under_armor": "Hide model under armor", "hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque", "opaque": "Opaque",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Environment",
"show_grid": "Show grid"
}, },
"screenshot": { "screenshot": {
"download": "Завантажити", "download": "Завантажити",
@@ -912,7 +925,6 @@
}, },
"review": { "review": {
"title": "Review videos", "title": "Review videos",
"disclaimer": "BlitzKit does not endorse any review videos.",
"join": "Apply to %s1 YouTuber program" "join": "Apply to %s1 YouTuber program"
}, },
"configuration": { "configuration": {
@@ -973,8 +985,8 @@
"dpmEffective": "Effective at 60s", "dpmEffective": "Effective at 60s",
"shells": "Снаряди", "shells": "Снаряди",
"mostOptimalShellIndex": "Most optimal shell", "mostOptimalShellIndex": "Most optimal shell",
"shellReloads": "Shell reloads", "shellReloads": "Reloads",
"shellReload": "Shell reload", "shellReload": "Час перезаряджання",
"intraClip": "Intra-clip", "intraClip": "Intra-clip",
"caliber": "Калібр", "caliber": "Калібр",
"penetration": "Бронепробиття", "penetration": "Бронепробиття",
@@ -1032,7 +1044,9 @@
"shellRange": "Shell range", "shellRange": "Shell range",
"shellCapacity": "Shell capacity", "shellCapacity": "Shell capacity",
"gunType": "Тип гармати", "gunType": "Тип гармати",
"penetrationAt250m": "Penetration at 250m" "penetrationAt250m": "Penetration at 250m",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
} }
}, },
"firepower": { "firepower": {
+21 -7
View File
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "猜坦克", "name": "猜坦克",
"button": "猜猜看", "button": "猜猜看",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - %s2 阶 %s3 %s4 | %s5 战车百科 | 战车世界闪击战 (WoTB)", "tank_seo_title": "%s1 - %s2 阶 %s3 %s4 | %s5 战车百科 | 战车世界闪击战 (WoTB)",
"tank_seo_description": "%s1 装甲,统计设备,装备,供给等。 %s2 每分钟伤害, %s3生命值, %s4毫米穿透, %s5kph 最高速度, %s6hp 生命值。", "tank_seo_description": "%s1 装甲,统计设备,装备,供给等。 %s2 每分钟伤害, %s3生命值, %s4毫米穿透, %s5kph 最高速度, %s6hp 生命值。",
"visualizers": { "visualizers": {
"reload": {
"skip": "跳过"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -810,6 +819,12 @@
"percentage": "%s1%", "percentage": "%s1%",
"ghost_factory": "幽灵工厂", "ghost_factory": "幽灵工厂",
"dead_rail": "死亡铁道" "dead_rail": "死亡铁道"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
} }
}, },
"sandbox": { "sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "可击穿区域", "green_penetration": "可击穿区域",
"hide_model_under_armor": "隐藏装甲下的模型", "hide_model_under_armor": "隐藏装甲下的模型",
"opaque": "不透明", "opaque": "不透明",
"dev_wireframe": "开发选项:网格", "dev_wireframe": "开发选项:网格"
"environment": "环境",
"show_grid": "显示网格"
}, },
"screenshot": { "screenshot": {
"download": "下载", "download": "下载",
@@ -912,7 +925,6 @@
}, },
"review": { "review": {
"title": "回顾影片", "title": "回顾影片",
"disclaimer": "BlitzKit 不支援任何影片回顾。",
"join": "应用到 %s1 YouTuber程序" "join": "应用到 %s1 YouTuber程序"
}, },
"configuration": { "configuration": {
@@ -973,8 +985,8 @@
"dpmEffective": "60秒内生效", "dpmEffective": "60秒内生效",
"shells": "炮弹 ", "shells": "炮弹 ",
"mostOptimalShellIndex": "最优炮弹", "mostOptimalShellIndex": "最优炮弹",
"shellReloads": "弹匣装填", "shellReloads": "Reloads",
"shellReload": "炮弹装填", "shellReload": "装填时间",
"intraClip": "炮弹装填", "intraClip": "炮弹装填",
"caliber": "口径", "caliber": "口径",
"penetration": "穿深", "penetration": "穿深",
@@ -1032,7 +1044,9 @@
"shellRange": "射程", "shellRange": "射程",
"shellCapacity": "携弹量", "shellCapacity": "携弹量",
"gunType": "炮管类型", "gunType": "炮管类型",
"penetrationAt250m": "两百五十米处的穿透力" "penetrationAt250m": "两百五十米处的穿透力",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
} }
}, },
"firepower": { "firepower": {
+1
View File
@@ -11,6 +11,7 @@
"build:ids": "bun src/discoverIds.ts", "build:ids": "bun src/discoverIds.ts",
"build:bot": "bun src/buildBot.ts", "build:bot": "bun src/buildBot.ts",
"build:ratingleaderboard": "bun src/buildRatingLeaderboard.ts", "build:ratingleaderboard": "bun src/buildRatingLeaderboard.ts",
"build:proxyclient": "bun src/buildProxyClient.ts",
"prunestrings": "bun src/pruneStrings.ts" "prunestrings": "bun src/pruneStrings.ts"
}, },
"dependencies": { "dependencies": {
+20 -25
View File
@@ -12,7 +12,6 @@ import {
EquipmentSlot, EquipmentSlot,
GameDefinitions, GameDefinitions,
GunDefinition, GunDefinition,
GunDefinitionBase,
I18nString, I18nString,
MapDefinitions, MapDefinitions,
ModelDefinitions, ModelDefinitions,
@@ -274,6 +273,7 @@ interface VehicleDefinitions {
pumpGunMode?: boolean; pumpGunMode?: boolean;
pumpGunReloadTimes?: string; pumpGunReloadTimes?: string;
clip?: { count: number; rate: number }; clip?: { count: number; rate: number };
burst?: { count: number; rate: number };
models: { undamaged: string }; models: { undamaged: string };
extras?: { extras?: {
trayShell?: { trayShell?: {
@@ -1304,7 +1304,7 @@ export async function definitions() {
} }
}); });
const base = { tankDefinitions.tanks[tankId].turrets[turretIndex].guns.push({
id: gunId, id: gunId,
weight: gunListEntry.weight, weight: gunListEntry.weight,
rotation_speed: gunListEntry.rotationSpeed, rotation_speed: gunListEntry.rotationSpeed,
@@ -1324,41 +1324,38 @@ export async function definitions() {
unlocks: resolveUnlocks(gun.unlocks), unlocks: resolveUnlocks(gun.unlocks),
shell_capacity: gun.maxAmmo ?? gunListEntry.maxAmmo, shell_capacity: gun.maxAmmo ?? gunListEntry.maxAmmo,
assault_ranges, assault_ranges,
} satisfies GunDefinitionBase; burst:
gun.burst && gun.burst.count > 1
tankDefinitions.tanks[tankId].turrets[turretIndex].guns.push({ ? {
count: gun.burst.count,
interval: 60 / gun.burst.rate,
}
: undefined,
gun_type: gun_type:
gunType === 'regular' gunType === 'regular'
? { ? {
$case: 'regular', $case: 'regular',
value: { value: {
base, reload: gun.reloadTime,
extension: { reload: gun.reloadTime },
}, },
} }
: gunType === 'autoReloader' : gunType === 'autoReloader'
? { ? {
$case: 'auto_reloader', $case: 'auto_reloader',
value: { value: {
base, intra_clip: 60 / gun.clip!.rate,
extension: { shell_count: gunClipCount,
intra_clip: 60 / gun.clip!.rate, shell_reloads: gun
shell_count: gunClipCount, .pumpGunReloadTimes!.split(' ')
shell_reloads: gun .map(Number),
.pumpGunReloadTimes!.split(' ')
.map(Number),
},
}, },
} }
: { : {
$case: 'auto_loader', $case: 'auto_loader',
value: { value: {
base, intra_clip: 60 / gun.clip!.rate,
extension: { clip_reload: gun.reloadTime,
intra_clip: 60 / gun.clip!.rate, shell_count: gunClipCount,
clip_reload: gun.reloadTime,
shell_count: gunClipCount,
},
}, },
}, },
} satisfies GunDefinition); } satisfies GunDefinition);
@@ -1407,7 +1404,7 @@ export async function definitions() {
tankDefinitions.tanks[tankId].turrets[turretIndex].guns[ tankDefinitions.tanks[tankId].turrets[turretIndex].guns[
gunIndex gunIndex
].gun_type!.value.base!.shells.push({ ].shells.push({
id: shellId, id: shellId,
name: shellName, name: shellName,
velocity: gunShellEntry.speed, velocity: gunShellEntry.speed,
@@ -1495,9 +1492,7 @@ export async function definitions() {
turret.research_cost = turretXps.get(turret.id); turret.research_cost = turretXps.get(turret.id);
Object.values(turret.guns).forEach((gunRaw) => { Object.values(turret.guns).forEach((gunRaw) => {
gunRaw.gun_type!.value.base!.research_cost = gunXps.get( gunRaw.research_cost = gunXps.get(gunRaw.id);
gunRaw.gun_type!.value.base!.id,
);
}); });
}); });
@@ -62,6 +62,7 @@ export async function tankIcons() {
const nationVehicleId = tank.id; const nationVehicleId = tank.id;
const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1; const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1;
const parameters = await readYAMLDVPL<TankParameters>( const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, `${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`,
); );
+26 -23
View File
@@ -1,49 +1,52 @@
import { toUniqueId } from '@blitzkit/core'; import { toUniqueId } from "@blitzkit/core";
import { NodeIO } from '@gltf-transform/core'; import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { readdir, writeFile } from 'fs/promises'; import { readdir } from "fs/promises";
import { extractModel } from '../core/blitz/extractModel'; import { extractModel } from "../core/blitz/extractModel";
import { readXMLDVPL } from '../core/blitz/readXMLDVPL'; import { readXMLDVPL } from "../core/blitz/readXMLDVPL";
import { readYAMLDVPL } from '../core/blitz/readYAMLDVPL'; import { readYAMLDVPL } from "../core/blitz/readYAMLDVPL";
import { commitAssets } from '../core/github/commitAssets'; import { commitAssets } from "../core/github/commitAssets";
import { FileChange } from '../core/github/commitMultipleFiles'; import { FileChange } from "../core/github/commitMultipleFiles";
import { DATA } from './constants'; import { DATA } from "./constants";
import { VehicleDefinitionList } from './definitions'; import { VehicleDefinitionList } from "./definitions";
import { TankParameters } from './tankIcons'; import { TankParameters } from "./tankIcons";
export async function tankModels() { export async function tankModels() {
console.log('Building tank models...'); console.log("Building tank models...");
const nodeIO = new NodeIO().registerExtensions(ALL_EXTENSIONS); const nodeIO = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const nations = await readdir(`${DATA}/XML/item_defs/vehicles`).then( const nations = await readdir(`${DATA}/XML/item_defs/vehicles`).then(
(nations) => nations.filter((nation) => nation !== 'common'), (nations) => nations.filter((nation) => nation !== "common")
); );
for (const nationIndex in nations) { for (const nationIndex in nations) {
const nation = nations[nationIndex]; const nation = nations[nationIndex];
const changes: FileChange[] = []; const changes: FileChange[] = [];
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>( const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/XML/item_defs/vehicles/${nation}/list.xml`, `${DATA}/XML/item_defs/vehicles/${nation}/list.xml`
); );
const entries = Object.entries(tanks.root);
console.log(`Building models for ${nation}`); console.log(`Building models for ${nation}`);
for (const [tankKey, tank] of Object.entries(tanks.root)) { let index = 0;
if (tankKey.includes('tutorial_bot')) continue; for (const [tankKey, tank] of entries) {
console.log(` ${++index} / ${entries.length} : ${tankKey}`);
if (tankKey.includes("tutorial_bot")) continue;
const id = toUniqueId(nation, tank.id); const id = toUniqueId(nation, tank.id);
const parameters = await readYAMLDVPL<TankParameters>( const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, `${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`
); );
const model = await extractModel( const model = await extractModel(
DATA, DATA,
parameters.resourcesPath.blitzModelPath.replace(/\.sc2$/, ''), parameters.resourcesPath.blitzModelPath.replace(/\.sc2$/, "")
); );
const content = Buffer.from(await nodeIO.writeBinary(model));
changes.push({ changes.push({ path: `3d/tanks/models/${id}.glb`, content });
path: `3d/tanks/models/${id}.glb`,
content: Buffer.from(await nodeIO.writeBinary(model)),
});
} }
await commitAssets(`tank models ${nation}`, changes); await commitAssets(`tank models ${nation}`, changes);
+45
View File
@@ -0,0 +1,45 @@
import { readFile, writeFile } from "fs/promises";
const IMPORT = "@protos/auto_legacy_proxy_client";
const SOURCE =
"../../submodules/blitzkit-closed/protos/auto_legacy_proxy_client.proto";
const TARGET =
"../../submodules/blitzkit-closed/src/unreal/methodPopulatedProxyClient.ts";
const requestPattern = /message (\w+)Request {/g;
const source = await readFile(SOURCE).then((buffer) => buffer.toString());
const requests = source.matchAll(requestPattern);
const imports: string[] = [];
const methods: { name: string; method: string }[] = [];
for (const match of requests) {
const name = match[1];
// const method =
// name.charAt(0).toLowerCase() + name.slice(1).replace(/Async$/, "");
const method = name;
imports.push(`${name}Request`, `${name}Response`);
methods.push({ name, method });
}
let content = "";
content += "import {\n";
content += imports.map((i) => ` ${i},`).join("\n");
content += `\n} from "${IMPORT}";\n`;
content += 'import { BaseProxyClient } from "./baseProxyClient";\n\n';
content +=
"export abstract class MethodPopulatedProxyClient extends BaseProxyClient {\n";
content += methods
.map(({ name, method }) => {
return ` ${method}(data: ${name}Request) {
return this.request("${name}Request", ${name}Request, data, ${name}Response);
}\n`;
})
.join("");
content += "}\n";
await writeFile(TARGET, content);
@@ -1,13 +1,13 @@
import { VertexAttribute, vertexAttributeVectorSizes } from '@blitzkit/core'; import { VertexAttribute, vertexAttributeVectorSizes } from "@blitzkit/core";
export const vertexAttributeGLTFName: Partial<Record<VertexAttribute, string>> = export const vertexAttributeGLTFName: Partial<Record<VertexAttribute, string>> =
{ {
[VertexAttribute.VERTEX]: 'POSITION', [VertexAttribute.VERTEX]: "POSITION",
[VertexAttribute.NORMAL]: 'NORMAL', [VertexAttribute.NORMAL]: "NORMAL",
[VertexAttribute.TEXCOORD0]: 'TEXCOORD_0', [VertexAttribute.TEXCOORD0]: "TEXCOORD_0",
[VertexAttribute.TEXCOORD1]: 'TEXCOORD_0', [VertexAttribute.TEXCOORD1]: "TEXCOORD_1",
[VertexAttribute.TEXCOORD2]: 'TEXCOORD_0', [VertexAttribute.TEXCOORD2]: "TEXCOORD_2",
[VertexAttribute.TEXCOORD3]: 'TEXCOORD_0', [VertexAttribute.TEXCOORD3]: "TEXCOORD_3",
}; };
export const vertexAttributeGltfVectorSizes = { export const vertexAttributeGltfVectorSizes = {
...vertexAttributeVectorSizes, ...vertexAttributeVectorSizes,
@@ -4,35 +4,35 @@ import {
ScgReadStream, ScgReadStream,
Textures, Textures,
VertexAttribute, VertexAttribute,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { Document, Material, Node, Scene } from '@gltf-transform/core'; import { Document, Material, Node, Scene } from "@gltf-transform/core";
import { dedup, prune } from '@gltf-transform/functions'; import { dedup, prune } from "@gltf-transform/functions";
import { times } from 'lodash-es'; import { times } from "lodash-es";
import { dirname } from 'path'; import { dirname } from "path";
import { Matrix4, Quaternion, Vector3, Vector4Tuple } from 'three'; import { readBaseColor } from "../readBaseColor";
import { readDVPLFile } from '../readDVPLFile'; import { readDVPLFile } from "../readDVPLFile";
import { readTexture } from '../readTexture'; import { readNormal } from "../readNormal";
import { TextureMutation } from '../readTexture/constants'; import { readRoughnessMetallic } from "../readRoughnessMetallic";
import { import {
vertexAttributeGLTFName, vertexAttributeGLTFName,
vertexAttributeGltfVectorSizes, vertexAttributeGltfVectorSizes,
} from './constants'; } from "./constants";
const ERROR_ON_UNKNOWN_COMPONENT = false; const ERROR_ON_UNKNOWN_COMPONENT = false;
const omitMeshNames = { const omitMeshNames = {
start: ['chassis_chassis_', 'chassis_track_crash_', 'HP_'], start: ["chassis_chassis_", "chassis_track_crash_", "HP_"],
end: ['_POINT'], end: ["_POINT"],
}; };
export async function extractModel(data: string, path: string) { export async function extractModel(data: string, path: string) {
const sc2Path = `${data}/3d/${path}.sc2`; const sc2Path = `${data}/3d/${path}.sc2`;
const scgPath = `${data}/3d/${path}.scg`; const scgPath = `${data}/3d/${path}.scg`;
const sc2 = new Sc2ReadStream( const sc2 = new Sc2ReadStream(
(await readDVPLFile(sc2Path)).buffer as ArrayBuffer, (await readDVPLFile(sc2Path)).buffer as ArrayBuffer
).sc2(); ).sc2();
const scg = new ScgReadStream( const scg = new ScgReadStream(
(await readDVPLFile(scgPath)).buffer as ArrayBuffer, (await readDVPLFile(scgPath)).buffer as ArrayBuffer
).scg(); ).scg();
const document = new Document(); const document = new Document();
const scene = document.createScene(); const scene = document.createScene();
@@ -41,8 +41,8 @@ export async function extractModel(data: string, path: string) {
// create materials // create materials
await Promise.all( await Promise.all(
sc2['#dataNodes'].map(async (node) => { sc2["#dataNodes"].map(async (node) => {
const id = new DataView(node['#id']).getBigUint64(0, true); const id = new DataView(node["#id"]).getBigUint64(0, true);
if (node.parentMaterialKey !== undefined) { if (node.parentMaterialKey !== undefined) {
/** /**
@@ -58,7 +58,7 @@ export async function extractModel(data: string, path: string) {
if (node.textures) { if (node.textures) {
textures = node.textures; textures = node.textures;
} else if (typeof node.configCount === 'number') { } else if (typeof node.configCount === "number") {
textures = node.configArchive_0.textures; textures = node.configArchive_0.textures;
} }
@@ -66,26 +66,27 @@ export async function extractModel(data: string, path: string) {
material.setBaseColorTexture( material.setBaseColorTexture(
document document
.createTexture(node.materialName) .createTexture(node.materialName)
.setMimeType('image/jpeg') .setMimeType("image/jpeg")
.setImage( .setImage(
await readTexture( await readBaseColor(
`${data}/3d/${dirname(path)}/${textures.albedo}`, `${data}/3d/${dirname(path)}/${textures.baseColorMap ?? textures.albedo}`,
TextureMutation.Albedo, textures.miscMap
), ? `${data}/3d/${dirname(path)}/${textures.miscMap}`
), : undefined
)
)
); );
if (textures.baseRMMap) { if (textures.baseRMMap) {
material.setMetallicRoughnessTexture( material.setMetallicRoughnessTexture(
document document
.createTexture(node.materialName) .createTexture(node.materialName)
.setMimeType('image/jpeg') .setMimeType("image/jpeg")
.setImage( .setImage(
await readTexture( await readRoughnessMetallic(
`${data}/3d/${dirname(path)}/${textures.baseRMMap}`, `${data}/3d/${dirname(path)}/${textures.baseRMMap}`
TextureMutation.RoughnessMetallicness, )
), )
),
); );
} }
@@ -95,47 +96,33 @@ export async function extractModel(data: string, path: string) {
material.setNormalTexture( material.setNormalTexture(
document document
.createTexture(node.materialName) .createTexture(node.materialName)
.setMimeType('image/jpeg') .setMimeType("image/jpeg")
.setImage( .setImage(
await readTexture( await readNormal(
`${data}/3d/${dirname(path)}/${ `${data}/3d/${dirname(path)}/${
textures.baseNormalMap ?? textures.normalmap textures.baseNormalMap ?? textures.normalmap
}`, }`,
isBase ? TextureMutation.Normal : undefined, isBase
), )
), )
);
}
if (textures.miscMap) {
material.setOcclusionTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setImage(
await readTexture(
`${data}/3d/${dirname(path)}/${textures.miscMap}`,
TextureMutation.Miscellaneous,
),
),
); );
} }
materials.set( materials.set(
new DataView(node['#id']).getBigUint64(0, true), new DataView(node["#id"]).getBigUint64(0, true),
material, material
); );
} }
}), })
); );
// replace children materials with parents // replace children materials with parents
materials.forEach((material, id) => { materials.forEach((material, id) => {
if (typeof material !== 'bigint') return; if (typeof material !== "bigint") return;
let resolvedMaterial: Material | bigint | undefined = material; let resolvedMaterial: Material | bigint | undefined = material;
while (typeof resolvedMaterial === 'bigint') { while (typeof resolvedMaterial === "bigint") {
const linkedParentMaterial = materials.get(resolvedMaterial); const linkedParentMaterial = materials.get(resolvedMaterial);
resolvedMaterial = linkedParentMaterial; resolvedMaterial = linkedParentMaterial;
@@ -155,36 +142,36 @@ export async function extractModel(data: string, path: string) {
const node = document.createNode(hierarchy.name); const node = document.createNode(hierarchy.name);
const components = times( const components = times(
hierarchy.components.count, hierarchy.components.count,
(index) => hierarchy.components[index.toString().padStart(4, '0')], (index) => hierarchy.components[index.toString().padStart(4, "0")]
); );
components.forEach((component) => { components.forEach((component) => {
switch (component['comp.typename']) { switch (component["comp.typename"]) {
case 'LodComponent': case "LodComponent":
// found and used later by transform component // found and used later by transform component
// TODO: lod component always shows up before transform component; cache it before parsing transform // TODO: lod component always shows up before transform component; cache it before parsing transform
break; break;
case 'TransformComponent': { case "TransformComponent": {
node.setTranslation(component['tc.localTranslation']); node.setTranslation(component["tc.localTranslation"]);
node.setRotation(component['tc.localRotation']); node.setRotation(component["tc.localRotation"]);
node.setScale(component['tc.localScale']); node.setScale(component["tc.localScale"]);
break; break;
} }
case 'RenderComponent': { case "RenderComponent": {
const renderObject = component['rc.renderObj']; const renderObject = component["rc.renderObj"];
times(renderObject['ro.batchCount'], (batchIndex): void => { times(renderObject["ro.batchCount"], (batchIndex): void => {
const lodIndex = renderObject[`rb${batchIndex}.lodIndex`]; const lodIndex = renderObject[`rb${batchIndex}.lodIndex`];
if (lodIndex !== 0) return; if (lodIndex !== 0) return;
const batchKey = batchIndex.toString().padStart(4, '0'); const batchKey = batchIndex.toString().padStart(4, "0");
const batch = renderObject['ro.batches'][batchKey]; const batch = renderObject["ro.batches"][batchKey];
const material = materials.get(batch['rb.nmatname']); const material = materials.get(batch["rb.nmatname"]);
const polygonGroup = scg.get(batch['rb.datasource']); const polygonGroup = scg.get(batch["rb.datasource"]);
if (!(material instanceof Material)) { if (!(material instanceof Material)) {
// probably shadow material // probably shadow material
@@ -192,14 +179,14 @@ export async function extractModel(data: string, path: string) {
} }
if (polygonGroup === undefined) { if (polygonGroup === undefined) {
throw new Error( throw new Error(
`Missing polygon group ${batch['rb.datasource']}`, `Missing polygon group ${batch["rb.datasource"]}`
); );
} }
const lodNode = document.createNode(batchKey); const lodNode = document.createNode(batchKey);
const indicesAccessor = document const indicesAccessor = document
.createAccessor() .createAccessor()
.setType('SCALAR') .setType("SCALAR")
.setArray(new Uint16Array(polygonGroup.indices)) .setArray(new Uint16Array(polygonGroup.indices))
.setBuffer(buffer); .setBuffer(buffer);
const primitive = document const primitive = document
@@ -233,7 +220,7 @@ export async function extractModel(data: string, path: string) {
const vertexSize = vertexAttributeGltfVectorSizes[attribute]; const vertexSize = vertexAttributeGltfVectorSizes[attribute];
const attributeAccessor = document const attributeAccessor = document
.createAccessor(name) .createAccessor(name)
.setType(vertexSize === 1 ? 'SCALAR' : `VEC${vertexSize}`) .setType(vertexSize === 1 ? "SCALAR" : `VEC${vertexSize}`)
.setArray(new Float32Array(value.flat())) .setArray(new Float32Array(value.flat()))
.setBuffer(buffer); .setBuffer(buffer);
@@ -241,7 +228,7 @@ export async function extractModel(data: string, path: string) {
}); });
const mesh = document const mesh = document
.createMesh(batch['##name']) .createMesh(batch["##name"])
.addPrimitive(primitive); .addPrimitive(primitive);
lodNode.setMesh(mesh); lodNode.setMesh(mesh);
node.addChild(lodNode); node.addChild(lodNode);
@@ -253,24 +240,24 @@ export async function extractModel(data: string, path: string) {
default: { default: {
if (ERROR_ON_UNKNOWN_COMPONENT) { if (ERROR_ON_UNKNOWN_COMPONENT) {
throw new Error( throw new Error(
`Unhandled component type: ${component['comp.typename']}`, `Unhandled component type: ${component["comp.typename"]}`
); );
} }
} }
} }
}); });
if (hierarchy['#hierarchy']) { if (hierarchy["#hierarchy"]) {
parseHierarchies(hierarchy['#hierarchy'], node); parseHierarchies(hierarchy["#hierarchy"], node);
} }
parent.addChild(node); parent.addChild(node);
}); });
} }
parseHierarchies(sc2['#hierarchy'], scene); parseHierarchies(sc2["#hierarchy"], scene);
scene.addChild(document.createNode('test')); scene.addChild(document.createNode("test"));
await document.transform(prune({ keepAttributes: true }), dedup()); await document.transform(prune({ keepAttributes: true }), dedup());
@@ -0,0 +1,48 @@
import sharp from "sharp";
import { readTexture } from "./readTexture";
export async function readBaseColor(path: string, occlusionPath?: string) {
const baseRaw = await readTexture(path);
const occlusionRaw = occlusionPath
? await readTexture(occlusionPath)
: undefined;
const base = await sharp(baseRaw.data, { raw: baseRaw })
.removeAlpha()
.raw()
.toBuffer();
const occlusion = occlusionRaw
? await sharp(occlusionRaw.data, { raw: occlusionRaw })
.extractChannel(3)
.raw()
.toBuffer()
: undefined;
const combined = Buffer.alloc(baseRaw.width * baseRaw.height * 3);
for (let i = 0; i < baseRaw.width * baseRaw.height; i++) {
let c = 1;
if (occlusionRaw) {
const x = i % baseRaw.width;
const y = Math.floor(i / baseRaw.width);
const u = Math.floor(x * (occlusionRaw.width / baseRaw.width));
const v = Math.floor(y * (occlusionRaw.height / baseRaw.height));
const occlusionI = u + v * occlusionRaw.width;
c = occlusion![occlusionI] / 255;
}
combined[i * 3 + 0] = Math.round(base[i * 3 + 0] * c);
combined[i * 3 + 1] = Math.round(base[i * 3 + 1] * c);
combined[i * 3 + 2] = Math.round(base[i * 3 + 2] * c);
}
const image = await sharp(combined, {
raw: { width: baseRaw.width, height: baseRaw.height, channels: 3 },
})
.jpeg()
.toBuffer();
return image;
}
@@ -0,0 +1,29 @@
import sharp from "sharp";
import { readTexture } from "./readTexture";
export async function readNormal(path: string, isBase: boolean) {
const raw = await readTexture(path);
if (!isBase) {
return await sharp(raw.data, { raw }).removeAlpha().jpeg().toBuffer();
}
const bytes = 4 * raw.width * raw.height;
for (let index = 0; index < bytes; index += 4) {
/**
* Red is always 255 and blue is always 0. Only alpha and green contain any
* sort of information.
*/
let x = raw.data[index + 3] * (2 / 255) - 1;
let y = raw.data[index + 1] * (2 / 255) - 1;
let z = Math.sqrt(Math.max(0, 1 - x ** 2 - y ** 2));
raw.data[index] = Math.round((x + 1) * (255 / 2));
raw.data[index + 1] = Math.round((y + 1) * (255 / 2));
raw.data[index + 2] = Math.round((z + 1) * (255 / 2));
raw.data[index + 3] = 255;
}
return await sharp(raw.data, { raw }).jpeg().toBuffer();
}
@@ -0,0 +1,24 @@
import sharp from "sharp";
import { readTexture } from "./readTexture";
export async function readRoughnessMetallic(path: string) {
const raw = await readTexture(path);
const blitz = sharp(raw.data, { raw: raw });
const metallicness = await blitz?.extractChannel(1).raw().toBuffer();
const roughness = await blitz?.extractChannel(3).raw().toBuffer();
const combined = Buffer.alloc(raw.width * raw.height * 3);
for (let i = 0; i < raw.width * raw.height; i++) {
combined[i * 3 + 1] = roughness[i];
combined[i * 3 + 2] = metallicness[i];
}
const image = await sharp(combined, {
raw: { width: raw.width, height: raw.height, channels: 3 },
})
.jpeg()
.toBuffer();
return image;
}
@@ -1,81 +1,83 @@
import { DdsReadStream, PvrReadStream } from '@blitzkit/core'; import { DdsReadStream, PvrReadStream } from "@blitzkit/core";
import { existsSync } from 'fs'; import { existsSync } from "fs";
import sharp from 'sharp'; import sharp from "sharp";
import { DATA_COMPRESSED } from '../../../buildAssets/constants'; import { DATA_COMPRESSED } from "../../../buildAssets/constants";
import { readDVPLFile } from '../readDVPLFile'; import { readDVPLFile } from "../readDVPLFile";
import { TextureMutation } from './constants'; import { TextureMutation } from "./constants";
export async function readTexture(path: string, mutation?: TextureMutation) { export async function readTexture(path: string) {
const ddsTexturePath = path.replace('.tex', '.dx11.dds'); const ddsTexturePath = path.replace(".tex", ".dx11.dds");
const isDds = existsSync( const isDds = existsSync(
`${ddsTexturePath}${DATA_COMPRESSED ? '.dvpl' : ''}`, `${ddsTexturePath}${DATA_COMPRESSED ? ".dvpl" : ""}`
); );
const resolvedTexturePath = isDds const resolvedTexturePath = isDds
? ddsTexturePath ? ddsTexturePath
: ddsTexturePath.replace('.dds', '.pvr'); : ddsTexturePath.replace(".dds", ".pvr");
const decompressedDvpl = await readDVPLFile(`${resolvedTexturePath}.dvpl`); const decompressedDvpl = await readDVPLFile(`${resolvedTexturePath}.dvpl`);
const raw = isDds const raw = isDds
? await new DdsReadStream(decompressedDvpl.buffer as ArrayBuffer).dds() ? await new DdsReadStream(decompressedDvpl.buffer as ArrayBuffer).dds()
: new PvrReadStream(decompressedDvpl.buffer as ArrayBuffer).pvr(); : new PvrReadStream(decompressedDvpl.buffer as ArrayBuffer).pvr();
switch (mutation) { return raw;
case TextureMutation.Normal: {
const bytes = 4 * raw.width * raw.height;
for (let index = 0; index < bytes; index += 4) {
/**
* Red is always 255 and blue is always 0. Only alpha and green contain any
* sort of information.
*/
let x = raw.data[index + 3] * (2 / 255) - 1;
let y = raw.data[index + 1] * (2 / 255) - 1;
const underSqrt = 1 - x ** 2 - y ** 2;
let z = underSqrt > 0 ? Math.sqrt(underSqrt) : -Math.sqrt(-underSqrt);
raw.data[index] = Math.round((x + 1) * (255 / 2)); // switch (mutation) {
raw.data[index + 1] = Math.round((y + 1) * (255 / 2)); // case TextureMutation.Normal: {
raw.data[index + 2] = Math.round((z + 1) * (255 / 2)); // const bytes = 4 * raw.width * raw.height;
raw.data[index + 3] = 255; // for (let index = 0; index < bytes; index += 4) {
} // /**
// * Red is always 255 and blue is always 0. Only alpha and green contain any
// * sort of information.
// */
// let x = raw.data[index + 3] * (2 / 255) - 1;
// let y = raw.data[index + 1] * (2 / 255) - 1;
// const underSqrt = 1 - x ** 2 - y ** 2;
// let z = underSqrt > 0 ? Math.sqrt(underSqrt) : -Math.sqrt(-underSqrt);
return await sharp(raw.data, { raw }).jpeg().toBuffer(); // raw.data[index] = Math.round((x + 1) * (255 / 2));
} // raw.data[index + 1] = Math.round((y + 1) * (255 / 2));
// raw.data[index + 2] = Math.round((z + 1) * (255 / 2));
// raw.data[index + 3] = 255;
// }
case TextureMutation.RoughnessMetallicness: { // return await sharp(raw.data, { raw }).jpeg().toBuffer();
const newImage = sharp(Buffer.alloc(raw.width * raw.height), { // }
raw: { ...raw, channels: 1 },
});
const metallicness = sharp(raw.data, { raw })
.extractChannel('green')
.jpeg()
.toBuffer();
const roughness = sharp(raw.data, { raw })
.extractChannel('alpha')
.jpeg()
.toBuffer();
return await newImage // case TextureMutation.RoughnessMetallicness: {
.joinChannel(await Promise.all([roughness, metallicness])) // const newImage = sharp(Buffer.alloc(raw.width * raw.height), {
.jpeg() // raw: { ...raw, channels: 1 },
.toBuffer(); // });
} // const metallicness = sharp(raw.data, { raw })
// .extractChannel("green")
// .jpeg()
// .toBuffer();
// const roughness = sharp(raw.data, { raw })
// .extractChannel("alpha")
// .jpeg()
// .toBuffer();
case TextureMutation.Miscellaneous: { // return await newImage
/** // .joinChannel(await Promise.all([roughness, metallicness]))
* Alpha is ambient occlusion and green is emissive. But very few tanks // .jpeg()
* use emissive so I am ignoring it for now. // .toBuffer();
*/ // }
return await sharp(raw.data, { raw })
.extractChannel('alpha')
.jpeg()
.toBuffer();
}
case TextureMutation.Albedo: { // case TextureMutation.Miscellaneous: {
return await sharp(raw.data, { raw }).removeAlpha().jpeg().toBuffer(); // /**
} // * Alpha is ambient occlusion and green is emissive. But very few tanks
// * use emissive so I am ignoring it for now.
// */
// return await sharp(raw.data, { raw })
// .extractChannel("alpha")
// .jpeg()
// .toBuffer();
// }
default: // case TextureMutation.Albedo: {
return await sharp(raw.data, { raw }).jpeg().toBuffer(); // return await sharp(raw.data, { raw }).removeAlpha().jpeg().toBuffer();
} // }
// default:
// return await sharp(raw.data, { raw }).jpeg().toBuffer();
// }
} }
+50 -36
View File
@@ -1,47 +1,61 @@
import { exec as execSync } from 'child_process'; import { exec as execSync } from "child_process";
import { existsSync } from 'fs'; import { existsSync } from "fs";
import { readdir, rm, writeFile } from 'fs/promises'; import { readdir, rm } from "fs/promises";
import { promisify } from 'util'; import { promisify } from "util";
const ROOT = 'packages/core/src/protos'; const MAX_COMMAND_LENGTH = 2 ** 11;
const roots = [
// "packages/core/src/protos",
"submodules/blitzkit-closed/protos",
];
const exec = promisify(execSync); const exec = promisify(execSync);
let executableFileExtension: string; let executableFileExtension: string;
if (existsSync('node_modules/.bin/protoc-gen-ts_proto.exe')) { if (existsSync("node_modules/.bin/protoc-gen-ts_proto.exe")) {
executableFileExtension = '.exe'; executableFileExtension = ".exe";
} else { } else {
executableFileExtension = ''; executableFileExtension = "";
} }
const filesRaw = await readdir(`${ROOT}`); for (const root of roots) {
await Promise.all( const filesRaw = await readdir(`${root}`);
filesRaw.map(async (file) => { const files: string[] = [];
if (!file.endsWith('.proto')) {
await rm(`${ROOT}/${file}`); for (const file of filesRaw) {
if (file.endsWith(".proto")) files.push(file);
if (file.endsWith(".ts")) await rm(`${root}/${file}`);
}
while (files.length > 0) {
let command = [
"protoc",
`--plugin=./node_modules/.bin/protoc-gen-ts_proto${executableFileExtension}`,
"--ts_proto_opt=esModuleInterop=true",
"--ts_proto_opt=oneof=unions-value",
// "--ts_proto_opt=removeEnumPrefix=true",
"--ts_proto_opt=unrecognizedEnum=false",
"--ts_proto_opt=snakeToCamel=false",
`--ts_proto_out=${root}`,
`-I=${root}`,
].join(" ");
while (true) {
if (files.length === 0) break;
const file = files.at(-1);
const newLine = ` ${root}/${file}`;
if (command.length + newLine.length < MAX_COMMAND_LENGTH) {
command += newLine;
files.pop();
} else {
break;
}
} }
}),
);
const files = filesRaw.filter((file) => file.endsWith('.proto')); await exec(command);
const args = [ }
`--plugin=./node_modules/.bin/protoc-gen-ts_proto${executableFileExtension}`, }
'--ts_proto_opt=esModuleInterop=true',
'--ts_proto_opt=oneof=unions-value',
'--ts_proto_opt=removeEnumPrefix=true',
'--ts_proto_opt=unrecognizedEnum=false',
'--ts_proto_opt=snakeToCamel=false',
'--ts_proto_out=.',
...files.map((file) => `${ROOT}/${file}`),
];
await exec(`protoc ${args.join(' ')}`);
await writeFile(
`${ROOT}/index.ts`,
'// @ts-nocheck\n' +
files
.map((file) => file.replace('.proto', ''))
.map((file) => `export * from './${file}.ts';`)
.join('\n'),
);
+8
View File
@@ -0,0 +1,8 @@
{
"name": "varuna",
"type": "module",
"main": "src/index.ts",
"dependencies": {
"react": "^18.3.1"
}
}
+118
View File
@@ -0,0 +1,118 @@
import { produce } from "immer";
import { merge } from "lodash-es";
import { useEffect, useRef, useState } from "react";
export class Varuna<Type, Arguments = void> {
private listeners = new Set<(state: Type) => void>();
private initialized = false;
private _initial?: Type;
private _state?: Type;
constructor(
private creator: Type | ((...args: Arguments[]) => Type),
private persistence?: string
) {
if (typeof creator !== "function") this.initialize(creator);
}
private initialize(initial: Type) {
let data = initial;
if (this.persistence && typeof localStorage !== "undefined") {
const instance = this;
const dehydrated = localStorage.getItem(this.persistence);
if (dehydrated) {
const rehydrated = JSON.parse(dehydrated) as unknown;
data = merge(data, rehydrated);
}
window.addEventListener("beforeunload", () => {
localStorage.setItem(this.persistence!, JSON.stringify(instance.state));
});
}
this.initialized = true;
this._initial = data;
this._state = data;
}
useInitialization(...args: Arguments[]) {
const initializedThisMount = useRef(false);
if (initializedThisMount.current) return;
if (typeof this.creator !== "function") {
throw new Error("Provider must be used with a function creator");
}
this.initialize((this.creator as (...args: Arguments[]) => Type)(...args));
initializedThisMount.current = true;
}
private dispatch() {
const state = this.state;
this.listeners.forEach((callback) => callback(state));
}
on<Slice>(slicer: (state: Type) => Slice, callback: (state: Slice) => void) {
let last = slicer(this.state);
function listener(state: Type) {
const next = slicer(state);
if (next !== last) {
callback(next);
last = next;
}
}
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
mutate(mutator: (state: Type) => void) {
this.set(produce(this.state, mutator));
}
private assertInitialized() {
if (!this.initialized) {
throw new Error("Varuna store used before initialization");
}
}
get state() {
this.assertInitialized();
return this._state as Type;
}
set(state: Type) {
this.assertInitialized();
this._state = state;
this.dispatch();
}
get initial() {
this.assertInitialized();
return this._initial as Type;
}
use<Slice = Type>(
slicer: (state: Type) => Slice = (state) => state as unknown as Slice
) {
return this.useDeferred(slicer, slicer(this.state));
}
useDeferred<Slice = Type>(slicer: (state: Type) => Slice, initial: Slice) {
const instance = this;
const [slice, setSlice] = useState(initial);
useEffect(() => instance.on(slicer, setSlice), []);
return slice;
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}
+2 -2
View File
@@ -13,6 +13,7 @@
"@astrolib/seo": "^1.0.0-beta.8", "@astrolib/seo": "^1.0.0-beta.8",
"@blitzkit/core": "workspace:*", "@blitzkit/core": "workspace:*",
"@blitzkit/i18n": "workspace:*", "@blitzkit/i18n": "workspace:*",
"varuna": "workspace:*",
"@fontsource/inter": "^5.2.5", "@fontsource/inter": "^5.2.5",
"@nanostores/persistent": "^0.10.2", "@nanostores/persistent": "^0.10.2",
"@nanostores/react": "^0.7.3", "@nanostores/react": "^0.7.3",
@@ -35,8 +36,7 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-promise-suspense": "^0.3.4", "react-promise-suspense": "^0.3.4",
"three": "^0.169.0", "three": "^0.169.0",
"urlon": "^3.1.0", "urlon": "^3.1.0"
"zustand": "^4.5.5"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.4", "@astrojs/check": "^0.9.4",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

@@ -1,4 +1,4 @@
import { ReloadIcon } from '@radix-ui/react-icons'; import { ReloadIcon } from "@radix-ui/react-icons";
import { import {
Card, Card,
Code, Code,
@@ -6,26 +6,21 @@ import {
IconButton, IconButton,
Text, Text,
TextField, TextField,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { Html } from '@react-three/drei'; import { Html } from "@react-three/drei";
import { useRef } from 'react'; import { useRef } from "react";
import { radToDeg } from 'three/src/math/MathUtils.js'; import { radToDeg } from "three/src/math/MathUtils.js";
import { resolveArmorIndex } from '../../../core/blitzkit/resolveArmorIndex'; import { resolveArmorIndex } from "../../../core/blitzkit/resolveArmorIndex";
import { App } from '../../../stores/app'; import { App } from "../../../stores/app";
import { Duel } from '../../../stores/duel'; import { Duel } from "../../../stores/duel";
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../stores/tankopedia";
import { layerTypeNames } from './ShotDisplayCard'; import { layerTypeNames } from "./ShotDisplayCard";
import { ArmorType } from './SpacedArmorScene'; import { ArmorType } from "./SpacedArmorScene";
export function ArmorPlateDisplay() { export function ArmorPlateDisplay() {
const highlightArmor = TankopediaEphemeral.use( const highlightArmor = Tankopedia.use((state) => state.highlightArmor);
(state) => state.highlightArmor,
);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const developerMode = App.useDeferred((state) => state.developerMode, false); const developerMode = App.useDeferred((state) => state.developerMode, false);
const duelStore = Duel.useStore();
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
if (highlightArmor === undefined) return null; if (highlightArmor === undefined) return null;
@@ -33,20 +28,20 @@ export function ArmorPlateDisplay() {
<group position={highlightArmor.point}> <group position={highlightArmor.point}>
<Html <Html
center center
style={{ pointerEvents: highlightArmor.editingPlate ? 'auto' : 'none' }} style={{ pointerEvents: highlightArmor.editingPlate ? "auto" : "none" }}
> >
<Card <Card
variant="classic" variant="classic"
mb="4" mb="4"
style={{ style={{
whiteSpace: 'nowrap', whiteSpace: "nowrap",
color: highlightArmor.color, color: highlightArmor.color,
transform: 'translateY(-50%)', transform: "translateY(-50%)",
}} }}
> >
<Flex direction="column"> <Flex direction="column">
<Text weight="bold"> <Text weight="bold">
{layerTypeNames[highlightArmor.type]}{' '} {layerTypeNames[highlightArmor.type]}{" "}
{highlightArmor.thickness.toFixed(0)} {highlightArmor.thickness.toFixed(0)}
<Text size="1" weight="regular"> <Text size="1" weight="regular">
mm mm
@@ -92,35 +87,34 @@ export function ArmorPlateDisplay() {
if (isNaN(thickness) || thickness < 0) return; if (isNaN(thickness) || thickness < 0) return;
if (event.key === 'Enter') { if (event.key === "Enter") {
input.current?.blur(); input.current?.blur();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
const { protagonist } = duelStore.getState(); const { protagonist } = Duel.state;
const tank = draft.model; const tank = draft.model;
const turret = tank.turrets[protagonist.turret.id]; const turret = tank.turrets[protagonist.turret.id];
const gun = const gun = turret.guns[protagonist.gun.id];
turret.guns[protagonist.gun.gun_type!.value.base.id];
const track = tank.tracks[protagonist.track.id]; const track = tank.tracks[protagonist.track.id];
if (highlightArmor.name.startsWith('hull_')) { if (highlightArmor.name.startsWith("hull_")) {
tank.armor.thickness[index] = thickness; tank.armor.thickness[index] = thickness;
} else if (highlightArmor.name.startsWith('turret_')) { } else if (highlightArmor.name.startsWith("turret_")) {
turret.armor.thickness[index] = thickness; turret.armor.thickness[index] = thickness;
} else if (highlightArmor.name.startsWith('gun_')) { } else if (highlightArmor.name.startsWith("gun_")) {
if (highlightArmor.name.includes('_armor_')) { if (highlightArmor.name.includes("_armor_")) {
gun.armor.thickness[index] = thickness; gun.armor.thickness[index] = thickness;
} else { } else {
gun.thickness = thickness; gun.thickness = thickness;
} }
} else if (highlightArmor.name.startsWith('chassis_')) { } else if (highlightArmor.name.startsWith("chassis_")) {
track.thickness = thickness; track.thickness = thickness;
} }
}); });
} else if (event.key === 'Escape') { } else if (event.key === "Escape") {
input.current?.blur(); input.current?.blur();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
} }
@@ -138,7 +132,7 @@ export function ArmorPlateDisplay() {
mt="2" mt="2"
color="red" color="red"
onClick={() => { onClick={() => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
if (!highlightArmor.editingPlate) return; if (!highlightArmor.editingPlate) return;
const index = resolveArmorIndex(highlightArmor.name); const index = resolveArmorIndex(highlightArmor.name);
@@ -146,38 +140,33 @@ export function ArmorPlateDisplay() {
if (index === undefined) return; if (index === undefined) return;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
const initialState = const initialState = Tankopedia.initial;
tankopediaEphemeralStore.getInitialState(); const { protagonist } = Duel.state;
const { protagonist } = duelStore.getState();
const tank = draft.model; const tank = draft.model;
const initialTank = initialState.model; const initialTank = initialState.model;
const turret = tank.turrets[protagonist.turret.id]; const turret = tank.turrets[protagonist.turret.id];
const initialTurret = const initialTurret =
initialTank.turrets[protagonist.turret.id]; initialTank.turrets[protagonist.turret.id];
const gun = const gun = turret.guns[protagonist.gun.id];
turret.guns[protagonist.gun.gun_type!.value.base.id]; const initialGun = initialTurret.guns[protagonist.gun.id];
const initialGun =
initialTurret.guns[
protagonist.gun.gun_type!.value.base.id
];
const track = tank.tracks[protagonist.track.id]; const track = tank.tracks[protagonist.track.id];
const initialTrack = const initialTrack =
initialTank.tracks[protagonist.track.id]; initialTank.tracks[protagonist.track.id];
if (highlightArmor.name.startsWith('hull_')) { if (highlightArmor.name.startsWith("hull_")) {
tank.armor.thickness[index] = tank.armor.thickness[index] =
initialTank.armor.thickness[index]; initialTank.armor.thickness[index];
} else if (highlightArmor.name.startsWith('turret_')) { } else if (highlightArmor.name.startsWith("turret_")) {
turret.armor.thickness[index] = turret.armor.thickness[index] =
initialTurret.armor.thickness[index]; initialTurret.armor.thickness[index];
} else if (highlightArmor.name.startsWith('gun_')) { } else if (highlightArmor.name.startsWith("gun_")) {
if (highlightArmor.name.includes('_armor_')) { if (highlightArmor.name.includes("_armor_")) {
gun.armor.thickness[index] = gun.armor.thickness[index] =
initialGun.armor.thickness[index]; initialGun.armor.thickness[index];
} else { } else {
gun.thickness = initialGun.thickness; gun.thickness = initialGun.thickness;
} }
} else if (highlightArmor.name.startsWith('chassis_')) { } else if (highlightArmor.name.startsWith("chassis_")) {
track.thickness = initialTrack.thickness; track.thickness = initialTrack.thickness;
} }
}); });
@@ -14,7 +14,6 @@ export const PrimaryArmorScene = memo(() => {
const wrapper = useRef<Group>(null); const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
// const protagonist = Duel.use((draft) => draft.protagonist);
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track); const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
@@ -24,8 +23,7 @@ export const PrimaryArmorScene = memo(() => {
const tankModelDefinition = useTankModelDefinition(); const tankModelDefinition = useTankModelDefinition();
const trackModelDefinition = tankModelDefinition.tracks[track.id]; const trackModelDefinition = tankModelDefinition.tracks[track.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id]; const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = const gunModelDefinition = turretModelDefinition.guns[gun.id];
turretModelDefinition.guns[gun.gun_type!.value.base.id];
const hullOrigin = correctZYTuple(trackModelDefinition.origin); const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin); const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin);
const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin); const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin);
@@ -2,26 +2,28 @@ import {
canSplash, canSplash,
isExplosive, isExplosive,
resolvePenetrationCoefficient, resolvePenetrationCoefficient,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { invalidate, useFrame } from '@react-three/fiber'; import { invalidate, useFrame } from "@react-three/fiber";
import { useEffect } from 'react'; import { useEffect } from "react";
import { import {
MeshBasicMaterial, MeshBasicMaterial,
NormalBlending,
Object3D, Object3D,
ShaderMaterial, ShaderMaterial,
SubtractiveBlending,
UniformsLib, UniformsLib,
UniformsUtils, UniformsUtils,
Vector2, Vector2,
} from 'three'; } from "three";
import { degToRad } from 'three/src/math/MathUtils.js'; import { degToRad } from "three/src/math/MathUtils.js";
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../core/blitzkit/hasEquipment";
import { jsxTree } from '../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../core/blitzkit/jsxTree";
import { Duel, type EquipmentMatrix } from '../../../../stores/duel'; import { Duel, type EquipmentMatrix } from "../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../stores/tankopedia";
import { TankopediaPersistent } from '../../../../stores/tankopediaPersistent'; import { TankopediaPersistent } from "../../../../stores/tankopediaPersistent";
import fragmentShader from './shaders/fragment.glsl?raw'; import fragmentShader from "./shaders/fragment.glsl?raw";
import vertexShader from './shaders/vertex.glsl?raw'; import vertexShader from "./shaders/vertex.glsl?raw";
import { spacedArmorRenderTarget } from './target'; import { spacedArmorRenderTarget } from "./target";
interface PrimaryArmorSceneComponentProps { interface PrimaryArmorSceneComponentProps {
thickness: number; thickness: number;
@@ -32,13 +34,13 @@ const excludeMaterial = new MeshBasicMaterial({
colorWrite: false, colorWrite: false,
}); });
const date = new Date();
const isHalloween = date.getMonth() === 9 && date.getDate() === 31;
export function PrimaryArmorSceneComponent({ export function PrimaryArmorSceneComponent({
node, node,
thickness, thickness,
}: PrimaryArmorSceneComponentProps) { }: PrimaryArmorSceneComponentProps) {
const tankopediaPersistentStore = TankopediaPersistent.useStore();
const duelStore = Duel.useStore();
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const material = new ShaderMaterial({ const material = new ShaderMaterial({
fragmentShader, fragmentShader,
vertexShader, vertexShader,
@@ -46,6 +48,8 @@ export function PrimaryArmorSceneComponent({
fog: true, fog: true,
transparent: true, transparent: true,
blending: isHalloween ? SubtractiveBlending : NormalBlending,
uniforms: UniformsUtils.merge([ uniforms: UniformsUtils.merge([
UniformsLib.common, UniformsLib.common,
UniformsLib.fog, UniformsLib.fog,
@@ -73,30 +77,30 @@ export function PrimaryArmorSceneComponent({
}); });
useEffect(() => { useEffect(() => {
async function handleShellChange() { function handleShellChange() {
const duel = duelStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const shell =
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
material.uniforms.caliber.value = shell.caliber; material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad( material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!, isExplosive(shell.type) ? 90 : shell.ricochet!
); );
material.uniforms.normalization.value = degToRad( material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0, shell.normalization ?? 0
); );
material.uniforms.isExplosive.value = isExplosive(shell.type); material.uniforms.isExplosive.value = isExplosive(shell.type);
material.uniforms.canSplash.value = canSplash(shell.type); material.uniforms.canSplash.value = canSplash(shell.type);
material.uniforms.damage.value = shell.armor_damage; material.uniforms.damage.value = shell.armor_damage;
material.uniforms.explosionRadius.value = shell.explosion_radius; material.uniforms.explosionRadius.value = shell.explosion_radius;
await handleProtagonistEquipmentChange( handleProtagonistEquipmentChange(
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix,
true, true
); );
await handleAntagonistEquipmentChange( handleAntagonistEquipmentChange(
duel.antagonist.equipmentMatrix, Duel.state.antagonist.equipmentMatrix,
true, true
); );
invalidate(); invalidate();
@@ -114,15 +118,14 @@ export function PrimaryArmorSceneComponent({
function handleWireframeChange(wireframe: boolean) { function handleWireframeChange(wireframe: boolean) {
material.wireframe = wireframe; material.wireframe = wireframe;
} }
async function handleProtagonistEquipmentChange( function handleProtagonistEquipmentChange(
equipment: EquipmentMatrix, equipment: EquipmentMatrix,
noInvalidate = false, noInvalidate = false
) { ) {
const duel = duelStore.getState(); const hasEnhancedArmor = hasEquipment(
const hasEnhancedArmor = await hasEquipment(
110, 110,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.thickness.value = hasEnhancedArmor material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03 ? thickness * 1.03
@@ -130,18 +133,18 @@ export function PrimaryArmorSceneComponent({
if (!noInvalidate) invalidate(); if (!noInvalidate) invalidate();
} }
async function handleAntagonistEquipmentChange( function handleAntagonistEquipmentChange(
equipment: EquipmentMatrix, equipment: EquipmentMatrix,
noInvalidate = false, noInvalidate = false
) { ) {
const duel = duelStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const shell =
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
const penetration = shell.penetration.near; const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment( const hasCalibratedShells = hasEquipment(
103, 103,
duel.antagonist.tank.equipment_preset, Duel.state.antagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.penetration.value = material.uniforms.penetration.value =
@@ -151,45 +154,35 @@ export function PrimaryArmorSceneComponent({
if (!noInvalidate) invalidate(); if (!noInvalidate) invalidate();
} }
const initialState = tankopediaPersistentStore.getState();
handleShellChange(); handleShellChange();
handleGreenPenetrationChange(initialState.greenPenetration); handleGreenPenetrationChange(TankopediaPersistent.state.greenPenetration);
handleAdvancedHighlightingChange(initialState.advancedHighlighting); handleAdvancedHighlightingChange(
handleOpaqueChange(initialState.opaque); TankopediaPersistent.state.advancedHighlighting
handleWireframeChange(initialState.wireframe);
handleProtagonistEquipmentChange(
duelStore.getState().protagonist.equipmentMatrix,
);
handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix,
); );
handleOpaqueChange(TankopediaPersistent.state.opaque);
handleWireframeChange(TankopediaPersistent.state.wireframe);
handleProtagonistEquipmentChange(Duel.state.protagonist.equipmentMatrix);
handleAntagonistEquipmentChange(Duel.state.antagonist.equipmentMatrix);
const unsubscribes = [ const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange), Duel.on((state) => state.antagonist.shell, handleShellChange),
tankopediaEphemeralStore.subscribe( Tankopedia.on((state) => state.customShell, handleShellChange),
(state) => state.customShell, TankopediaPersistent.on(
handleShellChange,
),
tankopediaPersistentStore.subscribe(
(state) => state.greenPenetration, (state) => state.greenPenetration,
handleGreenPenetrationChange, handleGreenPenetrationChange
), ),
tankopediaPersistentStore.subscribe( TankopediaPersistent.on(
(state) => state.advancedHighlighting, (state) => state.advancedHighlighting,
handleAdvancedHighlightingChange, handleAdvancedHighlightingChange
), ),
tankopediaPersistentStore.subscribe( TankopediaPersistent.on((state) => state.opaque, handleOpaqueChange),
(state) => state.opaque, Duel.on(
handleOpaqueChange,
),
duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix, (state) => state.protagonist.equipmentMatrix,
(equipment) => handleProtagonistEquipmentChange(equipment), (equipment) => handleProtagonistEquipmentChange(equipment)
), ),
duelStore.subscribe( Duel.on(
(state) => state.antagonist.equipmentMatrix, (state) => state.antagonist.equipmentMatrix,
(equipment) => handleAntagonistEquipmentChange(equipment), (equipment) => handleAntagonistEquipmentChange(equipment)
), ),
]; ];
@@ -200,7 +193,7 @@ export function PrimaryArmorSceneComponent({
useFrame(({ gl, camera }) => { useFrame(({ gl, camera }) => {
gl.getSize(material.uniforms.resolution.value).multiplyScalar( gl.getSize(material.uniforms.resolution.value).multiplyScalar(
gl.getPixelRatio(), gl.getPixelRatio()
); );
material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture; material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
material.uniforms.spacedArmorDepth.value = material.uniforms.spacedArmorDepth.value =
@@ -25,7 +25,6 @@ uniform mat4 inverseProjectionMatrix;
uniform highp sampler2D spacedArmorBuffer; uniform highp sampler2D spacedArmorBuffer;
uniform highp sampler2D spacedArmorDepth; uniform highp sampler2D spacedArmorDepth;
const float HALF = 0.5;
const float EXPLOSION_DAMAGE_FACTOR = 1.1; const float EXPLOSION_DAMAGE_FACTOR = 1.1;
const float RANDOMIZATION_FACTOR = 0.05; const float RANDOMIZATION_FACTOR = 0.05;
const float GREEN_VALUE = 0.392; const float GREEN_VALUE = 0.392;
@@ -81,12 +80,12 @@ void main() {
float distFromArmor = primaryDist - spacedDist; float distFromArmor = primaryDist - spacedDist;
if (canSplash) { if (canSplash) {
float finalDamage = HALF * damage * (1.0 - distFromArmor / explosionRadius) - EXPLOSION_DAMAGE_FACTOR * (finalThickness + spacedArmorThickness); float finalDamage = 0.5 * damage * (1.0 - distFromArmor / explosionRadius) - EXPLOSION_DAMAGE_FACTOR * (finalThickness + spacedArmorThickness);
splashChance = step(0.0, finalDamage); splashChance = step(0.0, finalDamage);
penetrationChance = 0.0; penetrationChance = 0.0;
} else { } else {
remainingPen -= HALF * remainingPen * distFromArmor; remainingPen -= 0.5 * remainingPen * distFromArmor;
} }
} }
} }
@@ -98,13 +97,13 @@ void main() {
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0); penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
if (canSplash) { if (canSplash) {
float splashDamage = HALF * damage - EXPLOSION_DAMAGE_FACTOR * finalThickness; float splashDamage = 0.5 * damage - EXPLOSION_DAMAGE_FACTOR * finalThickness;
splashChance = step(0.0, splashDamage); splashChance = step(0.0, splashDamage);
} }
} }
} }
float alpha = opaque ? 1.0 : HALF; float alpha = opaque ? 1.0 : 0.5;
vec3 baseColor = vec3(1.0, splashChance * GREEN_VALUE, 0.0); vec3 baseColor = vec3(1.0, splashChance * GREEN_VALUE, 0.0);
if (advancedHighlighting && didRicochet) { if (advancedHighlighting && didRicochet) {
@@ -1,9 +1,9 @@
import { J_HAT, K_HAT } from '@blitzkit/core'; import { J_HAT, K_HAT } from "@blitzkit/core";
import { Box, Flex, Text } from '@radix-ui/themes'; import { Box, Flex, Text } from "@radix-ui/themes";
import { Html } from '@react-three/drei'; import { Html } from "@react-three/drei";
import { invalidate, useFrame } from '@react-three/fiber'; import { invalidate, useFrame } from "@react-three/fiber";
import { clamp } from 'lodash-es'; import { clamp } from "lodash-es";
import { useEffect, useRef, type ComponentProps } from 'react'; import { useEffect, useRef, type ComponentProps } from "react";
import { import {
BufferGeometry, BufferGeometry,
DoubleSide, DoubleSide,
@@ -15,23 +15,24 @@ import {
MeshBasicMaterial, MeshBasicMaterial,
Path, Path,
Quaternion, Quaternion,
} from 'three'; } from "three";
import { LocaleProvider, useLocale } from '../../../hooks/useLocale'; import { isHalloween } from "../../../core/blitzkit/isHalloween";
import { LocaleProvider, useLocale } from "../../../hooks/useLocale";
import { import {
TankopediaEphemeral, Tankopedia,
type ShotLayerNonExternal, type ShotLayerNonExternal,
type ShotStatus, type ShotStatus,
} from '../../../stores/tankopediaEphemeral'; } from "../../../stores/tankopedia";
import { ShotDisplayCard } from './ShotDisplayCard'; import { ShotDisplayCard } from "./ShotDisplayCard";
export const shotStatusColors: Record< export const shotStatusColors: Record<
ShotStatus, ShotStatus,
ComponentProps<typeof Text>['color'] ComponentProps<typeof Text>["color"]
> = { > = {
blocked: 'red', blocked: "red",
penetration: 'green', penetration: "green",
ricochet: 'yellow', ricochet: "yellow",
splash: 'orange', splash: "orange",
}; };
const SURFACE_POINT_SIZE = 12; const SURFACE_POINT_SIZE = 12;
@@ -40,7 +41,7 @@ const TRACER_THICK = 1 / 32;
const TRACER_THIN = TRACER_THICK / 2; const TRACER_THIN = TRACER_THICK / 2;
export function ShotDisplay() { export function ShotDisplay() {
const shot = TankopediaEphemeral.use((state) => state.shot); const shot = Tankopedia.use((state) => state.shot);
const inTracer = useRef<Mesh>(null); const inTracer = useRef<Mesh>(null);
const outTracer = useRef<Mesh>(null); const outTracer = useRef<Mesh>(null);
const splashRadiusWrapper = useRef<Group>(null); const splashRadiusWrapper = useRef<Group>(null);
@@ -51,16 +52,16 @@ export function ShotDisplay() {
useEffect(() => { useEffect(() => {
if (shot?.splashRadius !== undefined) { if (shot?.splashRadius !== undefined) {
const outlineGeometry = new BufferGeometry().setFromPoints( const outlineGeometry = new BufferGeometry().setFromPoints(
new Path().absarc(0, 0, 1, 0, Math.PI * 2).getSpacedPoints(50), new Path().absarc(0, 0, 1, 0, Math.PI * 2).getSpacedPoints(50)
); );
const outlineMaterial = new LineDashedMaterial({ const outlineMaterial = new LineDashedMaterial({
color: 'orange', color: "orange",
dashSize: 0.05, dashSize: 0.05,
gapSize: 0.025, gapSize: 0.025,
}); });
const outline = new Line( const outline = new Line(
outlineGeometry, outlineGeometry,
outlineMaterial, outlineMaterial
).computeLineDistances(); ).computeLineDistances();
splashRadiusWrapper.current?.add(outline); splashRadiusWrapper.current?.add(outline);
@@ -72,15 +73,12 @@ export function ShotDisplay() {
}); });
useEffect(() => { useEffect(() => {
const data = new Date();
const isHalloween = data.getMonth() === 9 && data.getDate() === 31;
if ( if (
isHalloween && isHalloween() &&
shot?.splashRadius !== undefined && shot?.splashRadius !== undefined &&
shot.in.status === 'penetration' shot.in.status === "penetration"
) { ) {
const audio = new Audio('/assets/audio/lotta-damage.mp3'); const audio = new Audio("/assets/audio/lotta-damage.mp3");
audio.currentTime = 0.7; audio.currentTime = 0.7;
audio.volume = 0.25; audio.volume = 0.25;
@@ -136,11 +134,11 @@ export function ShotDisplay() {
const outLength = const outLength =
shot.out && shot.out.layers.length > 0 shot.out && shot.out.layers.length > 0
? (shot.in.layers.at(-1) as ShotLayerNonExternal).point.distanceTo( ? (shot.in.layers.at(-1) as ShotLayerNonExternal).point.distanceTo(
(shot.out.layers.at(-1) as ShotLayerNonExternal).point, (shot.out.layers.at(-1) as ShotLayerNonExternal).point
) )
: SHOT_DISPLAY_LENGTH_INFINITY; : SHOT_DISPLAY_LENGTH_INFINITY;
const inLast = shot.in.layers.findLast( const inLast = shot.in.layers.findLast(
(layer) => layer.type !== null, (layer) => layer.type !== null
) as ShotLayerNonExternal; ) as ShotLayerNonExternal;
const outTitleColor = shot.out const outTitleColor = shot.out
? shotStatusColors[shot.out.status] ? shotStatusColors[shot.out.status]
@@ -156,7 +154,7 @@ export function ShotDisplay() {
position={inLast.point} position={inLast.point}
ref={splashRadiusWrapper} ref={splashRadiusWrapper}
rotation={new Euler().setFromQuaternion( rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(K_HAT, shot.in.surfaceNormal), new Quaternion().setFromUnitVectors(K_HAT, shot.in.surfaceNormal)
)} )}
> >
<mesh> <mesh>
@@ -174,13 +172,13 @@ export function ShotDisplay() {
<Html position={inLast.point} center> <Html position={inLast.point} center>
<Box <Box
style={{ style={{
pointerEvents: 'none', pointerEvents: "none",
transform: `translateY(${tracerGoingUp ? 50 : -50}%)`, transform: `translateY(${tracerGoingUp ? 50 : -50}%)`,
}} }}
mb={tracerGoingUp ? '0' : '9'} mb={tracerGoingUp ? "0" : "9"}
pb={tracerGoingUp ? '0' : '4'} pb={tracerGoingUp ? "0" : "4"}
mt={tracerGoingUp ? '9' : '0'} mt={tracerGoingUp ? "9" : "0"}
pt={tracerGoingUp ? '4' : '0'} pt={tracerGoingUp ? "4" : "0"}
> >
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<ShotDisplayCard shot={shot} /> <ShotDisplayCard shot={shot} />
@@ -192,7 +190,7 @@ export function ShotDisplay() {
if (layer.type === null) return null; if (layer.type === null) return null;
const shotStatusColor = const shotStatusColor =
shot.in.status === 'splash' shot.in.status === "splash"
? shotStatusColors.splash ? shotStatusColors.splash
: shotStatusColors[layer.status]; : shotStatusColors[layer.status];
@@ -201,8 +199,8 @@ export function ShotDisplay() {
position={layer.point} position={layer.point}
key={index} key={index}
style={{ style={{
pointerEvents: 'none', pointerEvents: "none",
transform: 'translateY(-50%)', transform: "translateY(-50%)",
}} }}
> >
<Flex <Flex
@@ -227,7 +225,7 @@ export function ShotDisplay() {
<group <group
position={inLast.point} position={inLast.point}
rotation={new Euler().setFromQuaternion( rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(J_HAT, shot.in.surfaceNormal), new Quaternion().setFromUnitVectors(J_HAT, shot.in.surfaceNormal)
)} )}
> >
<mesh position={[0, inLength / 2, 0]} renderOrder={2}> <mesh position={[0, inLength / 2, 0]} renderOrder={2}>
@@ -255,8 +253,8 @@ export function ShotDisplay() {
rotation={new Euler().setFromQuaternion( rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors( new Quaternion().setFromUnitVectors(
J_HAT, J_HAT,
shot.out.surfaceNormal.clone(), shot.out.surfaceNormal.clone()
), )
)} )}
> >
<mesh position={[0, outLength / 2, 0]} renderOrder={2}> <mesh position={[0, outLength / 2, 0]} renderOrder={2}>
@@ -1,4 +1,4 @@
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { import {
Card, Card,
Flex, Flex,
@@ -6,19 +6,19 @@ import {
Separator, Separator,
Text, Text,
type CardProps, type CardProps,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import type { ComponentProps } from 'react'; import type { ComponentProps } from "react";
import { radToDeg } from 'three/src/math/MathUtils.js'; import { radToDeg } from "three/src/math/MathUtils.js";
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from "../../../hooks/useLocale";
import type { Shot, ShotLayer } from '../../../stores/tankopediaEphemeral'; import type { Shot, ShotLayer } from "../../../stores/tankopedia";
import { shotStatusColors } from './ShotDisplay'; import { shotStatusColors } from "./ShotDisplay";
import { ArmorType } from './SpacedArmorScene'; import { ArmorType } from "./SpacedArmorScene";
export const layerTypeNames = { export const layerTypeNames = {
[ArmorType.Primary]: 'primary', [ArmorType.Primary]: "primary",
[ArmorType.Spaced]: 'spaced', [ArmorType.Spaced]: "spaced",
[ArmorType.External]: 'external', [ArmorType.External]: "external",
null: 'gap', null: "gap",
} as const; } as const;
function LayerEntry({ function LayerEntry({
@@ -29,7 +29,7 @@ function LayerEntry({
angle, angle,
}: { }: {
layerIndex?: string; layerIndex?: string;
shotStatusColor: ComponentProps<typeof Text>['color']; shotStatusColor: ComponentProps<typeof Text>["color"];
layerName: string; layerName: string;
layer: ShotLayer; layer: ShotLayer;
angle?: number; angle?: number;
@@ -37,44 +37,38 @@ function LayerEntry({
const { locale, strings } = useLocale(); const { locale, strings } = useLocale();
return ( return (
<Flex align="center" gap="2"> <tr>
<Text size="1" color="gray" style={{ width: 12 }}> <td>
{layerIndex} <Text size="1" color="gray">
</Text> {layerIndex}
<Text size="2" color={shotStatusColor} style={{ width: 48 }}> </Text>
{layerName} </td>
</Text>
{layer.type === null && ( <td>
<> <Text size="2" color={shotStatusColor}>
<Text size="2" color={shotStatusColor} style={{ width: 64 }}> {layerName}
</Text>
</td>
<td>
{layer.type === null && (
<Text size="2" color={shotStatusColor}>
{literals(strings.common.units.mm, [ {literals(strings.common.units.mm, [
Math.round(layer.distance * 1000).toLocaleString(locale), Math.round(layer.distance * 1000).toLocaleString(locale),
])} ])}
</Text> </Text>
)}
<Text size="2" color="gray"> {layer.type === ArmorType.External && (
{literals( <Text size="2" color={shotStatusColor}>
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats {literals(strings.common.units.mm, [
.ricochet_loss, Math.round(layer.thickness).toLocaleString(locale),
[Math.max(-100, -50 * layer.distance).toFixed(0)], ])}
)}
</Text> </Text>
</> )}
)} {(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
{layer.type === ArmorType.External && ( <Text size="2" color={shotStatusColor}>
<Text size="2" color={shotStatusColor} style={{ width: 64 }}>
{literals(strings.common.units.mm, [
Math.round(layer.thickness).toLocaleString(locale),
])}
</Text>
)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
<>
<Text size="2" color={shotStatusColor} style={{ width: 80 }}>
{literals( {literals(
angle === undefined angle === undefined
? strings.common.units.mm ? strings.common.units.mm
@@ -83,20 +77,35 @@ function LayerEntry({
[ [
Math.round(layer.thicknessAngled).toLocaleString(locale), Math.round(layer.thicknessAngled).toLocaleString(locale),
radToDeg(angle ?? 0).toFixed(0), radToDeg(angle ?? 0).toFixed(0),
], ]
)} )}
</Text> </Text>
)}
</td>
<td>
{layer.type === null && (
<Text size="2" color="gray">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
.ricochet_loss,
[Math.max(-100, -50 * layer.distance).toFixed(0)]
)}
</Text>
)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
<Text size="2" color="gray"> <Text size="2" color="gray">
{literals( {literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
.nominal, .nominal,
[Math.round(layer.thickness).toLocaleString(locale)], [Math.round(layer.thickness).toLocaleString(locale)]
)} )}
</Text> </Text>
</> )}
)} </td>
</Flex> </tr>
); );
} }
@@ -115,7 +124,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
const inTitleColor = shotStatusColors[shot.in.status]; const inTitleColor = shotStatusColors[shot.in.status];
return ( return (
<Card variant="classic" style={{ width: 300 }} {...props}> <Card variant="classic" {...props}>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Flex direction="column" gap="1"> <Flex direction="column" gap="1">
<Text color={inTitleColor} weight="bold"> <Text color={inTitleColor} weight="bold">
@@ -123,11 +132,16 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.status[ strings.website.tools.tankopedia.sandbox.dynamic.shot_card.status[
shot.in.status shot.in.status
], ],
[Math.round(shot.damage).toLocaleString(locale)], [Math.round(shot.damage).toLocaleString(locale)]
)} )}
</Text> </Text>
<Flex direction="column"> <table
style={{
whiteSpace: "nowrap",
borderSpacing: "var(--space-1) 0",
}}
>
{shot.in.layers.map((layer, index) => { {shot.in.layers.map((layer, index) => {
const layerName = const layerName =
strings.website.tools.tankopedia.sandbox.dynamic.shot_card strings.website.tools.tankopedia.sandbox.dynamic.shot_card
@@ -141,7 +155,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
? undefined ? undefined
: `${(shot.containsGaps ? index / 2 : index) + 1}.`; : `${(shot.containsGaps ? index / 2 : index) + 1}.`;
const shotStatusColor = const shotStatusColor =
shot.in.status === 'splash' shot.in.status === "splash"
? shotStatusColors.splash ? shotStatusColors.splash
: shotStatusColors[layer.status]; : shotStatusColors[layer.status];
@@ -161,7 +175,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
/> />
); );
})} })}
</Flex> </table>
</Flex> </Flex>
{shot.out && shot.out.layers.length > 0 && ( {shot.out && shot.out.layers.length > 0 && (
@@ -188,7 +202,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
{literals( {literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.status[shot.out.status], .status[shot.out.status],
[Math.round(shot.damage).toLocaleString(locale)], [Math.round(shot.damage).toLocaleString(locale)]
)} )}
</Text> </Text>
@@ -210,7 +224,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
1 1
}.`; }.`;
const shotStatusColor = const shotStatusColor =
shot.out!.status === 'splash' shot.out!.status === "splash"
? shotStatusColors.splash ? shotStatusColors.splash
: shotStatusColors[layer.status]; : shotStatusColors[layer.status];
@@ -36,8 +36,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
const tankModelDefinition = useTankModelDefinition(); const tankModelDefinition = useTankModelDefinition();
const trackModelDefinition = tankModelDefinition.tracks[track.id]; const trackModelDefinition = tankModelDefinition.tracks[track.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id]; const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = const gunModelDefinition = turretModelDefinition.guns[gun.id];
turretModelDefinition.guns[gun.gun_type!.value.base.id];
const hullOrigin = correctZYTuple(trackModelDefinition.origin); const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin); const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin);
const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin); const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin);
@@ -1,20 +1,20 @@
import { resolvePenetrationCoefficient } from '@blitzkit/core'; import { resolvePenetrationCoefficient } from "@blitzkit/core";
import { useEffect } from 'react'; import { useEffect } from "react";
import { import {
AdditiveBlending, AdditiveBlending,
MeshBasicMaterial, MeshBasicMaterial,
Object3D, Object3D,
Plane, Plane,
ShaderMaterial, ShaderMaterial,
} from 'three'; } from "three";
import type { ArmorUserData, ExternalModuleVariant } from '../..'; import type { ArmorUserData, ExternalModuleVariant } from "../..";
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../../../core/blitzkit/jsxTree";
import { Duel, type EquipmentMatrix } from '../../../../../../stores/duel'; import { Duel, type EquipmentMatrix } from "../../../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
import { ArmorType } from '../../../SpacedArmorScene'; import { ArmorType } from "../../../SpacedArmorScene";
import fragmentShader from './shaders/fragment.glsl?raw'; import fragmentShader from "./shaders/fragment.glsl?raw";
import vertexShader from './shaders/vertex.glsl?raw'; import vertexShader from "./shaders/vertex.glsl?raw";
interface SpacedArmorSubExternalProps { interface SpacedArmorSubExternalProps {
node: Object3D; node: Object3D;
@@ -29,8 +29,6 @@ export function SpacedArmorSubExternal({
variant, variant,
clip, clip,
}: SpacedArmorSubExternalProps) { }: SpacedArmorSubExternalProps) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const duelStore = Duel.useStore();
const material = new ShaderMaterial({ const material = new ShaderMaterial({
fragmentShader, fragmentShader,
vertexShader, vertexShader,
@@ -49,33 +47,30 @@ export function SpacedArmorSubExternal({
useEffect(() => { useEffect(() => {
function handleShellChange() { function handleShellChange() {
const duel = duelStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const shell =
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
material.uniforms.penetration.value = shell.penetration.near; material.uniforms.penetration.value = shell.penetration.near;
} }
async function handleProtagonistEquipmentChange( function handleProtagonistEquipmentChange(equipment: EquipmentMatrix) {
equipment: EquipmentMatrix, const hasEnhancedArmor = hasEquipment(
) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
110, 110,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.thickness.value = hasEnhancedArmor material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03 ? thickness * 1.03
: thickness; : thickness;
} }
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) { function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = duelStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const shell =
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
const penetration = shell.penetration.near; const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment( const hasCalibratedShells = hasEquipment(
103, 103,
duel.antagonist.tank.equipment_preset, Duel.state.antagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.penetration.value = material.uniforms.penetration.value =
@@ -84,26 +79,19 @@ export function SpacedArmorSubExternal({
} }
handleShellChange(); handleShellChange();
handleProtagonistEquipmentChange( handleProtagonistEquipmentChange(Duel.state.protagonist.equipmentMatrix);
duelStore.getState().protagonist.equipmentMatrix, handleAntagonistEquipmentChange(Duel.state.antagonist.equipmentMatrix);
);
handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix,
);
const unsubscribes = [ const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange), Duel.on((state) => state.antagonist.shell, handleShellChange),
tankopediaEphemeralStore.subscribe( Tankopedia.on((state) => state.customShell, handleShellChange),
(state) => state.customShell, Duel.on(
handleShellChange,
),
duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix, (state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange, handleProtagonistEquipmentChange
), ),
duelStore.subscribe( Duel.on(
(state) => state.antagonist.equipmentMatrix, (state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange, handleAntagonistEquipmentChange
), ),
]; ];
@@ -125,6 +113,7 @@ export function SpacedArmorSubExternal({
return ( return (
<mesh <mesh
{...props} {...props}
onClick={() => {}}
key={`${key}-spaced-sub-external-exclude`} key={`${key}-spaced-sub-external-exclude`}
renderOrder={3} renderOrder={3}
material={ material={
@@ -1,20 +1,20 @@
import { isExplosive, resolvePenetrationCoefficient } from '@blitzkit/core'; import { isExplosive, resolvePenetrationCoefficient } from "@blitzkit/core";
import { useEffect } from 'react'; import { useEffect } from "react";
import { import {
AdditiveBlending, AdditiveBlending,
MeshBasicMaterial, MeshBasicMaterial,
ShaderMaterial, ShaderMaterial,
type Object3D, type Object3D,
} from 'three'; } from "three";
import { degToRad } from 'three/src/math/MathUtils.js'; import { degToRad } from "three/src/math/MathUtils.js";
import type { ArmorUserData } from '../..'; import type { ArmorUserData } from "../..";
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../../../core/blitzkit/jsxTree";
import { Duel, type EquipmentMatrix } from '../../../../../../stores/duel'; import { Duel, type EquipmentMatrix } from "../../../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
import { ArmorType } from '../../../SpacedArmorScene'; import { ArmorType } from "../../../SpacedArmorScene";
import fragmentShader from './shaders/fragment.glsl?raw'; import fragmentShader from "./shaders/fragment.glsl?raw";
import vertexShader from './shaders/vertex.glsl?raw'; import vertexShader from "./shaders/vertex.glsl?raw";
interface SpacedArmorSubSpacedProps { interface SpacedArmorSubSpacedProps {
node: Object3D; node: Object3D;
@@ -30,8 +30,6 @@ export function SpacedArmorSubSpaced({
node, node,
thickness, thickness,
}: SpacedArmorSubSpacedProps) { }: SpacedArmorSubSpacedProps) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const duelStore = Duel.useStore();
const material = new ShaderMaterial({ const material = new ShaderMaterial({
fragmentShader, fragmentShader,
vertexShader, vertexShader,
@@ -51,41 +49,38 @@ export function SpacedArmorSubSpaced({
useEffect(() => { useEffect(() => {
function handleShellChange() { function handleShellChange() {
const duel = duelStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const shell =
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
material.uniforms.penetration.value = shell.penetration.near; material.uniforms.penetration.value = shell.penetration.near;
material.uniforms.caliber.value = shell.caliber; material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad( material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!, isExplosive(shell.type) ? 90 : shell.ricochet!
); );
material.uniforms.normalization.value = degToRad( material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0, shell.normalization ?? 0
); );
} }
async function handleProtagonistEquipmentChange( function handleProtagonistEquipmentChange(equipment: EquipmentMatrix) {
equipment: EquipmentMatrix, const hasEnhancedArmor = hasEquipment(
) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
110, 110,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.thickness.value = hasEnhancedArmor material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03 ? thickness * 1.03
: thickness; : thickness;
} }
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) { function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = duelStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const shell =
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
const penetration = shell.penetration.near; const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment( const hasCalibratedShells = hasEquipment(
103, 103,
duel.antagonist.tank.equipment_preset, Duel.state.antagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.penetration.value = material.uniforms.penetration.value =
@@ -94,26 +89,19 @@ export function SpacedArmorSubSpaced({
} }
handleShellChange(); handleShellChange();
handleProtagonistEquipmentChange( handleProtagonistEquipmentChange(Duel.state.protagonist.equipmentMatrix);
duelStore.getState().protagonist.equipmentMatrix, handleAntagonistEquipmentChange(Duel.state.antagonist.equipmentMatrix);
);
handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix,
);
const unsubscribes = [ const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange), Duel.on((state) => state.antagonist.shell, handleShellChange),
tankopediaEphemeralStore.subscribe( Tankopedia.on((state) => state.customShell, handleShellChange),
(state) => state.customShell, Duel.on(
handleShellChange,
),
duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix, (state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange, handleProtagonistEquipmentChange
), ),
duelStore.subscribe( Duel.on(
(state) => state.antagonist.equipmentMatrix, (state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange, handleAntagonistEquipmentChange
), ),
]; ];
@@ -2,9 +2,9 @@ import {
ShellType, ShellType,
isExplosive, isExplosive,
resolvePenetrationCoefficient, resolvePenetrationCoefficient,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { useThree } from '@react-three/fiber'; import { useThree } from "@react-three/fiber";
import { useCallback } from 'react'; import { useCallback } from "react";
import { import {
type Intersection, type Intersection,
MeshBasicMaterial, MeshBasicMaterial,
@@ -14,21 +14,21 @@ import {
Raycaster, Raycaster,
Scene, Scene,
Vector3, Vector3,
} from 'three'; } from "three";
import { degToRad } from 'three/src/math/MathUtils.js'; import { degToRad } from "three/src/math/MathUtils.js";
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../core/blitzkit/hasEquipment";
import { jsxTree } from '../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../core/blitzkit/jsxTree";
import { discardClippingPlane } from '../../../../core/three/discardClippingPlane'; import { discardClippingPlane } from "../../../../core/three/discardClippingPlane";
import { Duel } from '../../../../stores/duel'; import { Duel } from "../../../../stores/duel";
import { import {
type Shot, type Shot,
type ShotLayerBase, type ShotLayerBase,
type ShotLayerNonExternal, type ShotLayerNonExternal,
TankopediaEphemeral, Tankopedia,
} from '../../../../stores/tankopediaEphemeral'; } from "../../../../stores/tankopedia";
import { ArmorType } from '../SpacedArmorScene'; import { ArmorType } from "../SpacedArmorScene";
import { SpacedArmorSubExternal } from './components/SpacedArmorSubExternal'; import { SpacedArmorSubExternal } from "./components/SpacedArmorSubExternal";
import { SpacedArmorSubSpaced } from './components/SpacedArmorSubSpaced'; import { SpacedArmorSubSpaced } from "./components/SpacedArmorSubSpaced";
type SpacedArmorSceneComponentProps = { type SpacedArmorSceneComponentProps = {
node: Object3D; node: Object3D;
@@ -45,7 +45,7 @@ type SpacedArmorSceneComponentProps = {
} }
); );
export type ExternalModuleVariant = 'gun' | 'track'; export type ExternalModuleVariant = "gun" | "track";
export type ArmorUserData = { export type ArmorUserData = {
thickness: number; thickness: number;
} & ( } & (
@@ -76,20 +76,17 @@ export function SpacedArmorSceneComponent({
clip, clip,
...props ...props
}: SpacedArmorSceneComponentProps) { }: SpacedArmorSceneComponentProps) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const duelStore = Duel.useStore();
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const shoot = useCallback( const shoot = useCallback(
async ( (
point: Vector3, point: Vector3,
intersections: Intersection[], intersections: Intersection[],
allowRicochet: boolean, allowRicochet: boolean,
remainingPenetrationInput?: number, remainingPenetrationInput?: number
) => { ) => {
const { antagonist, protagonist } = duelStore.getState(); const { customShell } = Tankopedia.state;
const { customShell } = tankopediaEphemeralStore.getState(); const shell = customShell ?? Duel.state.antagonist.shell;
const shell = customShell ?? antagonist.shell;
const cameraNormal = camera.position.clone().sub(point).normalize(); const cameraNormal = camera.position.clone().sub(point).normalize();
const shot: Shot = { const shot: Shot = {
splashRadius: splashRadius:
@@ -99,19 +96,19 @@ export function SpacedArmorSceneComponent({
in: { in: {
surfaceNormal: cameraNormal, surfaceNormal: cameraNormal,
layers: [], layers: [],
status: 'blocked', status: "blocked",
}, },
}; };
const hasCalibratedShells = await hasEquipment( const hasCalibratedShells = hasEquipment(
103, 103,
antagonist.tank.equipment_preset, Duel.state.antagonist.tank.equipment_preset,
antagonist.equipmentMatrix, Duel.state.antagonist.equipmentMatrix
); );
const hasEnhancedArmor = await hasEquipment( const hasEnhancedArmor = hasEquipment(
110, 110,
protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const penetration = const penetration =
shell.penetration.near * shell.penetration.near *
@@ -119,8 +116,8 @@ export function SpacedArmorSceneComponent({
const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1; const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1;
const filteredIntersections = intersections.filter( const filteredIntersections = intersections.filter(
(intersection) => (intersection) =>
'type' in intersection.object.userData && "type" in intersection.object.userData &&
!discardClippingPlane(intersection.object, intersection.point), !discardClippingPlane(intersection.object, intersection.point)
) as unknown as Intersection<Object3D & { userData: ArmorUserData }>[]; ) as unknown as Intersection<Object3D & { userData: ArmorUserData }>[];
const encounteredExternalModuleVariants: ExternalModuleVariant[] = []; const encounteredExternalModuleVariants: ExternalModuleVariant[] = [];
const noDuplicateIntersections: typeof filteredIntersections = []; const noDuplicateIntersections: typeof filteredIntersections = [];
@@ -129,12 +126,12 @@ export function SpacedArmorSceneComponent({
if (intersection.object.userData.type === ArmorType.External) { if (intersection.object.userData.type === ArmorType.External) {
if ( if (
!encounteredExternalModuleVariants.includes( !encounteredExternalModuleVariants.includes(
intersection.object.userData.variant, intersection.object.userData.variant
) )
) { ) {
noDuplicateIntersections.push(intersection); noDuplicateIntersections.push(intersection);
encounteredExternalModuleVariants.push( encounteredExternalModuleVariants.push(
intersection.object.userData.variant, intersection.object.userData.variant
); );
} }
} else { } else {
@@ -162,7 +159,7 @@ export function SpacedArmorSceneComponent({
const surfaceNormal = intersection const surfaceNormal = intersection
.normal!.clone() .normal!.clone()
.applyQuaternion( .applyQuaternion(
intersection.object.getWorldQuaternion(new Quaternion()), intersection.object.getWorldQuaternion(new Quaternion())
); );
if (shell.type === ShellType.HEAT && index > 0) { if (shell.type === ShellType.HEAT && index > 0) {
@@ -174,7 +171,7 @@ export function SpacedArmorSceneComponent({
shot.in.layers.push({ shot.in.layers.push({
type: null, type: null,
distance, distance,
status: blocked ? 'blocked' : 'penetration', status: blocked ? "blocked" : "penetration",
}); });
if (blocked) break; if (blocked) break;
@@ -194,16 +191,16 @@ export function SpacedArmorSceneComponent({
surfaceNormal, surfaceNormal,
status: status:
shell.type === ShellType.HE shell.type === ShellType.HE
? 'blocked' ? "blocked"
: blocked : blocked
? 'blocked' ? "blocked"
: 'penetration', : "penetration",
variant: layer.variant, variant: layer.variant,
}); });
} else { } else {
const thickness = layer.thickness * thicknessCoefficient; const thickness = layer.thickness * thicknessCoefficient;
const ricochet = degToRad( const ricochet = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!, isExplosive(shell.type) ? 90 : shell.ricochet!
); );
const angle = surfaceNormal.angleTo(cameraNormal); const angle = surfaceNormal.angleTo(cameraNormal);
@@ -227,7 +224,7 @@ export function SpacedArmorSceneComponent({
thicknessAngled: finalThickness, thicknessAngled: finalThickness,
point: intersection.point, point: intersection.point,
surfaceNormal, surfaceNormal,
status: 'ricochet', status: "ricochet",
angle, angle,
}); });
@@ -245,10 +242,10 @@ export function SpacedArmorSceneComponent({
surfaceNormal: intersection.face!.normal, surfaceNormal: intersection.face!.normal,
status: status:
shell.type === ShellType.HE && layer.type !== ArmorType.Primary shell.type === ShellType.HE && layer.type !== ArmorType.Primary
? 'blocked' ? "blocked"
: blocked : blocked
? 'blocked' ? "blocked"
: 'penetration', : "penetration",
angle, angle,
}); });
} }
@@ -282,10 +279,10 @@ export function SpacedArmorSceneComponent({
return acc + layer.thicknessAngled; return acc + layer.thicknessAngled;
}, },
0, 0
); );
const distanceFromSpacedArmor = firstLayer.point.distanceTo( const distanceFromSpacedArmor = firstLayer.point.distanceTo(
lastLayer.point, lastLayer.point
); );
const finalDamage = Math.max( const finalDamage = Math.max(
0, 0,
@@ -294,7 +291,7 @@ export function SpacedArmorSceneComponent({
(1 - distanceFromSpacedArmor / shell.explosion_radius!) - (1 - distanceFromSpacedArmor / shell.explosion_radius!) -
1.1 * 1.1 *
(lastLayer.thicknessAngled + (lastLayer.thicknessAngled +
Math.min(penetration, totalSpacedArmorThickness)), Math.min(penetration, totalSpacedArmorThickness))
); );
/** /**
@@ -308,19 +305,19 @@ export function SpacedArmorSceneComponent({
* for parity's sake, I am capping the value here. * for parity's sake, I am capping the value here.
*/ */
if (shot.in.layers.length > 1 || lastLayer.status === 'blocked') { if (shot.in.layers.length > 1 || lastLayer.status === "blocked") {
shot.in.status = finalDamage > 0 ? 'splash' : 'blocked'; shot.in.status = finalDamage > 0 ? "splash" : "blocked";
shot.damage = finalDamage; shot.damage = finalDamage;
} else { } else {
shot.in.status = 'penetration'; shot.in.status = "penetration";
shot.damage = shell.armor_damage; shot.damage = shell.armor_damage;
} }
} else { } else {
if (lastLayer.status === 'blocked') { if (lastLayer.status === "blocked") {
shot.in.status = 'blocked'; shot.in.status = "blocked";
shot.damage = 0; shot.damage = 0;
} else if (lastLayer.status === 'penetration') { } else if (lastLayer.status === "penetration") {
shot.in.status = 'penetration'; shot.in.status = "penetration";
shot.damage = shell.armor_damage; shot.damage = shell.armor_damage;
} else { } else {
const caster = new Raycaster(); const caster = new Raycaster();
@@ -331,29 +328,29 @@ export function SpacedArmorSceneComponent({
.sub( .sub(
lastLayer.surfaceNormal lastLayer.surfaceNormal
.clone() .clone()
.multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal)), .multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal))
); );
caster.set(lastLayer.point, ricochetNormal); caster.set(lastLayer.point, ricochetNormal);
const ricochetIntersections = caster.intersectObjects( const ricochetIntersections = caster.intersectObjects(
props.scene.children, props.scene.children,
true, true
); );
const outShot = await shoot( const outShot = shoot(
lastLayer.point, lastLayer.point,
ricochetIntersections, ricochetIntersections,
false, false,
remainingPenetration, remainingPenetration
); );
shot.in.status = 'ricochet'; shot.in.status = "ricochet";
if (outShot === null) { if (outShot === null) {
shot.damage = 0; shot.damage = 0;
shot.out = { shot.out = {
surfaceNormal: ricochetNormal, surfaceNormal: ricochetNormal,
layers: [], layers: [],
status: 'ricochet', status: "ricochet",
}; };
} else { } else {
shot.damage = outShot.damage; shot.damage = outShot.damage;
@@ -366,7 +363,7 @@ export function SpacedArmorSceneComponent({
return shot; return shot;
}, },
[camera], [camera]
); );
return ( return (
@@ -386,14 +383,14 @@ export function SpacedArmorSceneComponent({
thickness, thickness,
} satisfies ArmorUserData } satisfies ArmorUserData
} }
onClick={async (event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
const shot = (await shoot(
event.point, const shot = shoot(event.point, event.intersections, true)!;
event.intersections,
true, Tankopedia.mutate((draft) => {
))!; draft.shot = shot;
tankopediaEphemeralStore.setState({ shot }); });
}} }}
/> />
); );
@@ -1,23 +1,22 @@
import { normalizeAngleRad } from '@blitzkit/core'; import { useThree, type ThreeEvent } from "@react-three/fiber";
import { useThree, type ThreeEvent } from '@react-three/fiber'; import { memo, useRef } from "react";
import { memo, useRef } from 'react'; import { Group, Plane, Vector2, Vector3 } from "three";
import { Group, Plane, Vector2, Vector3 } from 'three'; import { applyPitchYawLimits } from "../../../core/blitz/applyPitchYawLimits";
import { applyPitchYawLimits } from '../../../core/blitz/applyPitchYawLimits'; import { correctZYTuple } from "../../../core/blitz/correctZYTuple";
import { correctZYTuple } from '../../../core/blitz/correctZYTuple'; import { hasEquipment } from "../../../core/blitzkit/hasEquipment";
import { hasEquipment } from '../../../core/blitzkit/hasEquipment'; import { modelTransformEvent } from "../../../core/blitzkit/modelTransform";
import { modelTransformEvent } from '../../../core/blitzkit/modelTransform'; import { nameToArmorId } from "../../../core/blitzkit/nameToArmorId";
import { nameToArmorId } from '../../../core/blitzkit/nameToArmorId'; import { resolveArmor } from "../../../core/blitzkit/resolveThickness";
import { resolveArmor } from '../../../core/blitzkit/resolveThickness'; import { useArmor } from "../../../hooks/useArmor";
import { useArmor } from '../../../hooks/useArmor'; import { useModel } from "../../../hooks/useModel";
import { useModel } from '../../../hooks/useModel'; import { useTankModelDefinition } from "../../../hooks/useTankModelDefinition";
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition'; import { useTankTransform } from "../../../hooks/useTankTransform";
import { useTankTransform } from '../../../hooks/useTankTransform'; import { Duel } from "../../../stores/duel";
import { Duel } from '../../../stores/duel'; import { Tankopedia } from "../../../stores/tankopedia";
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import { TankopediaPersistent } from "../../../stores/tankopediaPersistent";
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent'; import { ModelTankWrapper } from "./ModelTankWrapper";
import { ModelTankWrapper } from './ModelTankWrapper'; import { ArmorType } from "./SpacedArmorScene";
import { ArmorType } from './SpacedArmorScene'; import { StaticArmorSceneComponent } from "./StaticArmorSceneComponent";
import { StaticArmorSceneComponent } from './StaticArmorSceneComponent';
interface ArmorSceneProps { interface ArmorSceneProps {
thicknessRange: ThicknessRange; thicknessRange: ThicknessRange;
@@ -31,7 +30,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const wrapper = useRef<Group>(null); const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
const protagonist = Duel.use((draft) => draft.protagonist);
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track); const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
@@ -43,30 +41,27 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const tankModelDefinition = useTankModelDefinition(); const tankModelDefinition = useTankModelDefinition();
const trackModelDefinition = tankModelDefinition.tracks[track.id]; const trackModelDefinition = tankModelDefinition.tracks[track.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id]; const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = const gunModelDefinition = turretModelDefinition.guns[gun.id];
turretModelDefinition.guns[gun.gun_type!.value.base.id];
const hullOrigin = correctZYTuple(trackModelDefinition.origin); const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin); const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin);
const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin); const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin);
const mutateDuel = Duel.useMutation();
const canvas = useThree((state) => state.gl.domElement); const canvas = useThree((state) => state.gl.domElement);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const duelStore = Duel.useStore();
const maskOrigin = const maskOrigin =
gunModelDefinition.mask === undefined gunModelDefinition.mask === undefined
? undefined ? undefined
: gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y; : gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y;
const showPrimaryArmor = TankopediaPersistent.use( const showPrimaryArmor = TankopediaPersistent.use(
(state) => state.showPrimaryArmor, (state) => state.showPrimaryArmor
); );
const showSpacedArmor = TankopediaPersistent.use( const showSpacedArmor = TankopediaPersistent.use(
(state) => state.showSpacedArmor, (state) => state.showSpacedArmor
); );
const showExternalModules = TankopediaPersistent.use( const showExternalModules = TankopediaPersistent.use(
(state) => state.showExternalModules, (state) => state.showExternalModules
); );
const isDynamicArmorActive = Duel.use((state) => const isDynamicArmorActive = Duel.use((state) =>
state.protagonist.consumables.includes(73), state.protagonist.consumables.includes(73)
); );
useTankTransform(track, turret, turretContainer, gunContainer); useTankTransform(track, turret, turretContainer, gunContainer);
@@ -75,12 +70,12 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
<ModelTankWrapper ref={wrapper}> <ModelTankWrapper ref={wrapper}>
<group position={hullOrigin}> <group position={hullOrigin}>
{armorNodes.map((node) => { {armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_'); const isHull = node.name.startsWith("hull_");
const isVisible = isHull; const isVisible = isHull;
const armorId = nameToArmorId(node.name); const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor( const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor, tankModelDefinition.armor,
armorId, armorId
); );
if ( if (
@@ -88,8 +83,8 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
thickness === undefined || thickness === undefined ||
(!showPrimaryArmor && !spaced) || (!showPrimaryArmor && !spaced) ||
(!showSpacedArmor && spaced) || (!showSpacedArmor && spaced) ||
(isDynamicArmorActive && node.name.includes('state_00')) || (isDynamicArmorActive && node.name.includes("state_00")) ||
(!isDynamicArmorActive && node.name.includes('state_01')) (!isDynamicArmorActive && node.name.includes("state_01"))
) )
return null; return null;
@@ -108,8 +103,8 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
</group> </group>
{modelNodes.map((node) => { {modelNodes.map((node) => {
const isWheel = node.name.startsWith('chassis_wheel_'); const isWheel = node.name.startsWith("chassis_wheel_");
const isTrack = node.name.startsWith('chassis_track_'); const isTrack = node.name.startsWith("chassis_track_");
const isVisible = isWheel || isTrack; const isVisible = isWheel || isTrack;
if (!isVisible || !showExternalModules) return null; if (!isVisible || !showExternalModules) return null;
@@ -131,13 +126,13 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
<group ref={turretContainer}> <group ref={turretContainer}>
{armorNodes.map((node) => { {armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith( const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model_id.toString().padStart(2, '0')}`, `turret_${turretModelDefinition.model_id.toString().padStart(2, "0")}`
); );
const isVisible = isCurrentTurret; const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name); const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor( const { spaced, thickness } = resolveArmor(
turretModelDefinition.armor, turretModelDefinition.armor,
armorId, armorId
); );
if ( if (
@@ -145,71 +140,63 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
thickness === undefined || thickness === undefined ||
(!showPrimaryArmor && !spaced) || (!showPrimaryArmor && !spaced) ||
(!showSpacedArmor && spaced) || (!showSpacedArmor && spaced) ||
(isDynamicArmorActive && node.name.includes('state_00')) || (isDynamicArmorActive && node.name.includes("state_00")) ||
(!isDynamicArmorActive && node.name.includes('state_01')) (!isDynamicArmorActive && node.name.includes("state_01"))
) { ) {
return null; return null;
} }
const position = new Vector2(); const position = new Vector2();
const delta = new Vector2(); const delta = new Vector2();
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation(); event.stopPropagation();
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
yaw = protagonist.yaw;
pitch = protagonist.pitch;
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
async function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState(); const hasImprovedVerticalStabilizer = hasEquipment(
const hasImprovedVerticalStabilizer = await hasEquipment(
122, 122,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const hasDownImprovedVerticalStabilizer = await hasEquipment( const hasDownImprovedVerticalStabilizer = hasEquipment(
124, 124,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const boundingRect = canvas.getBoundingClientRect(); const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position); delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits( const [pitch, yaw] = applyPitchYawLimits(
pitch, modelTransformEvent.last!.pitch,
yaw + delta.x * (Math.PI / boundingRect.width), modelTransformEvent.last!.yaw +
delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
modelTransformEvent.emit({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateDuel((draft) => { Tankopedia.mutate((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
return ( return (
@@ -218,7 +205,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
<StaticArmorSceneComponent <StaticArmorSceneComponent
name={node.name} name={node.name}
thicknessRange={thicknessRange} thicknessRange={thicknessRange}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Primary} type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness} thickness={thickness}
node={node} node={node}
@@ -232,13 +218,13 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
<group ref={gunContainer}> <group ref={gunContainer}>
{armorNodes.map((node) => { {armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith( const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model_id.toString().padStart(2, '0')}`, `gun_${gunModelDefinition.model_id.toString().padStart(2, "0")}`
); );
const isVisible = isCurrentGun; const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name); const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor( const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor, gunModelDefinition.armor,
armorId, armorId
); );
if ( if (
@@ -246,70 +232,63 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
thickness === undefined || thickness === undefined ||
(!showPrimaryArmor && !spaced) || (!showPrimaryArmor && !spaced) ||
(!showSpacedArmor && spaced) || (!showSpacedArmor && spaced) ||
(isDynamicArmorActive && node.name.includes('state_00')) || (isDynamicArmorActive && node.name.includes("state_00")) ||
(!isDynamicArmorActive && node.name.includes('state_01')) (!isDynamicArmorActive && node.name.includes("state_01"))
) )
return null; return null;
const position = new Vector2(); const position = new Vector2();
const delta = new Vector2(); const delta = new Vector2();
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation(); event.stopPropagation();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
pitch = protagonist.pitch;
yaw = protagonist.yaw;
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
async function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState(); const hasImprovedVerticalStabilizer = hasEquipment(
const hasImprovedVerticalStabilizer = await hasEquipment(
122, 122,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const hasDownImprovedVerticalStabilizer = await hasEquipment( const hasDownImprovedVerticalStabilizer = hasEquipment(
124, 124,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const boundingRect = canvas.getBoundingClientRect(); const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position); delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits( const [pitch, yaw] = applyPitchYawLimits(
pitch - delta.y * (Math.PI / boundingRect.height), modelTransformEvent.last!.pitch -
yaw + delta.x * (Math.PI / boundingRect.width), delta.y * (Math.PI / boundingRect.height),
modelTransformEvent.last!.yaw +
delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
modelTransformEvent.emit({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
mutateDuel((draft) => { window.removeEventListener("pointermove", handlePointerMove);
draft.protagonist.pitch = normalizeAngleRad(pitch); window.removeEventListener("pointerup", handlePointerUp);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
} }
return ( return (
@@ -331,7 +310,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
{modelNodes.map((node) => { {modelNodes.map((node) => {
const gunString = `gun_${gunModelDefinition.model_id const gunString = `gun_${gunModelDefinition.model_id
.toString() .toString()
.padStart(2, '0')}`; .padStart(2, "0")}`;
const isCurrentGun = gunModelDefinition.mask const isCurrentGun = gunModelDefinition.mask
? node.name.startsWith(gunString) ? node.name.startsWith(gunString)
: node.name === gunString; : node.name === gunString;
@@ -341,63 +320,56 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const position = new Vector2(); const position = new Vector2();
const delta = new Vector2(); const delta = new Vector2();
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation(); event.stopPropagation();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
pitch = protagonist.pitch; window.addEventListener("pointermove", handlePointerMove);
yaw = protagonist.yaw; window.addEventListener("pointerup", handlePointerUp);
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
} }
async function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState(); const hasImprovedVerticalStabilizer = hasEquipment(
const hasImprovedVerticalStabilizer = await hasEquipment(
122, 122,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const hasDownImprovedVerticalStabilizer = await hasEquipment( const hasDownImprovedVerticalStabilizer = hasEquipment(
124, 124,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const boundingRect = canvas.getBoundingClientRect(); const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position); delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits( const [pitch, yaw] = applyPitchYawLimits(
pitch - delta.y * (Math.PI / boundingRect.height), modelTransformEvent.last!.pitch -
yaw + delta.x * (Math.PI / boundingRect.width), delta.y * (Math.PI / boundingRect.height),
modelTransformEvent.last!.yaw +
delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
modelTransformEvent.emit({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch); window.removeEventListener("pointermove", handlePointerMove);
draft.protagonist.yaw = normalizeAngleRad(yaw); window.removeEventListener("pointerup", handlePointerUp);
});
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
} }
return ( return (
@@ -1,7 +1,8 @@
import { I_HAT, J_HAT } from '@blitzkit/core'; import { I_HAT, J_HAT } from "@blitzkit/core";
import { type MeshProps, useThree } from '@react-three/fiber'; import { type MeshProps, useThree } from "@react-three/fiber";
import { clamp } from 'lodash-es'; import { clamp } from "lodash-es";
import { useEffect, useMemo } from 'react'; import type { QuicklimeEvent } from "quicklime";
import { useEffect, useMemo } from "react";
import { import {
Box3, Box3,
Color, Color,
@@ -9,33 +10,33 @@ import {
FrontSide, FrontSide,
LineBasicMaterial, LineBasicMaterial,
Mesh, Mesh,
MeshStandardMaterial, MeshBasicMaterial,
Object3D, Object3D,
Plane, Plane,
Quaternion, Quaternion,
Vector3, Vector3,
} from 'three'; } from "three";
import { unrotateDavaVector } from '../../../core/blitz/unrotateDavaVector'; import { unrotateDavaVector } from "../../../core/blitz/unrotateDavaVector";
import { jsxTree } from '../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../core/blitzkit/jsxTree";
import { import {
type ModelTransformEventData, type ModelTransformEventData,
modelTransformEvent, modelTransformEvent,
} from '../../../core/blitzkit/modelTransform'; } from "../../../core/blitzkit/modelTransform";
import { discardClippingPlane } from '../../../core/three/discardClippingPlane'; import { discardClippingPlane } from "../../../core/three/discardClippingPlane";
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../stores/tankopedia";
import { ArmorType } from './SpacedArmorScene'; import { ArmorType } from "./SpacedArmorScene";
import type { import type {
ArmorUserData, ArmorUserData,
ExternalModuleVariant, ExternalModuleVariant,
} from './SpacedArmorSceneComponent'; } from "./SpacedArmorSceneComponent";
import type { ThicknessRange } from './StaticArmor'; import type { ThicknessRange } from "./StaticArmor";
type StaticArmorSceneComponentProps = { type StaticArmorSceneComponentProps = {
name: string; name: string;
thickness: number; thickness: number;
thicknessRange: ThicknessRange; thicknessRange: ThicknessRange;
node: Object3D; node: Object3D;
onPointerDown?: MeshProps['onPointerDown']; onPointerDown?: MeshProps["onPointerDown"];
} & ( } & (
| { | {
type: Exclude<ArmorType, ArmorType.External>; type: Exclude<ArmorType, ArmorType.External>;
@@ -68,8 +69,6 @@ export function StaticArmorSceneComponent({
onPointerDown, onPointerDown,
...props ...props
}: StaticArmorSceneComponentProps) { }: StaticArmorSceneComponentProps) {
const mutateTankopediaEphemeralStore = TankopediaEphemeral.useMutation();
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const x = thickness / thicknessRange.value; const x = thickness / thicknessRange.value;
const xClamped = clamp(x, 0, 1); const xClamped = clamp(x, 0, 1);
@@ -91,7 +90,7 @@ export function StaticArmorSceneComponent({
color = new Color( color = new Color(
clamp(1 - (7 / 8) * xClamped, 0, 1), clamp(1 - (7 / 8) * xClamped, 0, 1),
0, 0,
clamp(1 - (1 / 8) * xClamped, 0, 1), clamp(1 - (1 / 8) * xClamped, 0, 1)
); );
opacity = clamp(x + 1 / 2, 0, 1); opacity = clamp(x + 1 / 2, 0, 1);
break; break;
@@ -107,14 +106,14 @@ export function StaticArmorSceneComponent({
const surfaceMaterial = useMemo( const surfaceMaterial = useMemo(
() => () =>
new MeshStandardMaterial({ new MeshBasicMaterial({
color, color,
opacity, opacity,
transparent: opacity < 1, transparent: opacity < 1,
depthWrite, depthWrite,
...(props.clip ? { clippingPlanes: [props.clip] } : {}), ...(props.clip ? { clippingPlanes: [props.clip] } : {}),
}), }),
[thickness], [thickness]
); );
const outlineMaterial = useMemo( const outlineMaterial = useMemo(
() => () =>
@@ -123,7 +122,7 @@ export function StaticArmorSceneComponent({
.clone() .clone()
.multiplyScalar(props.type === ArmorType.Spaced ? 2 ** 2 : 2 ** -1), .multiplyScalar(props.type === ArmorType.Spaced ? 2 ** 2 : 2 ** -1),
}), }),
[thickness], [thickness]
); );
useEffect(() => { useEffect(() => {
@@ -138,11 +137,11 @@ export function StaticArmorSceneComponent({
outlineMaterial.visible = true; outlineMaterial.visible = true;
} else if ( } else if (
selectedName === name || selectedName === name ||
(name.startsWith('chassis_') && selectedName.startsWith('chassis_')) || (name.startsWith("chassis_") && selectedName.startsWith("chassis_")) ||
(name.startsWith('gun_') && (name.startsWith("gun_") &&
selectedName.startsWith('gun_') && selectedName.startsWith("gun_") &&
!name.includes('_armor_') && !name.includes("_armor_") &&
!selectedName.includes('_armor_')) !selectedName.includes("_armor_"))
) { ) {
// this selected, stand out! // this selected, stand out!
surfaceMaterial.opacity = 1; surfaceMaterial.opacity = 1;
@@ -165,9 +164,9 @@ export function StaticArmorSceneComponent({
} }
const unsubscribes = [ const unsubscribes = [
tankopediaEphemeralStore.subscribe( Tankopedia.on(
(state) => state.highlightArmor?.name, (state) => state.highlightArmor?.name,
handleHighlightArmor, handleHighlightArmor
), ),
]; ];
@@ -180,22 +179,24 @@ export function StaticArmorSceneComponent({
const { clip } = props; const { clip } = props;
const gunOrigin = unrotateDavaVector(props.gunOrigin.clone()); const gunOrigin = unrotateDavaVector(props.gunOrigin.clone());
const neckOrigin = unrotateDavaVector(props.hullOrigin.clone()).add( const neckOrigin = unrotateDavaVector(props.hullOrigin.clone()).add(
unrotateDavaVector(props.turretOrigin.clone()), unrotateDavaVector(props.turretOrigin.clone())
); );
const barrelOrigin = neckOrigin.clone().add(gunOrigin); const barrelOrigin = neckOrigin.clone().add(gunOrigin);
const distanceToBarrel = clip.distanceToPoint(barrelOrigin); const distanceToBarrel = clip.distanceToPoint(barrelOrigin);
const point = new Vector3(); const point = new Vector3();
function handleModelTransform(data: ModelTransformEventData) { function handleModelTransform(
event: QuicklimeEvent<ModelTransformEventData>
) {
clip.normal clip.normal
.set(0, 0, -1) .set(0, 0, -1)
.applyAxisAngle(I_HAT, data.pitch) .applyAxisAngle(I_HAT, event.data.pitch)
.applyAxisAngle(J_HAT, data.yaw ?? 0); .applyAxisAngle(J_HAT, event.data.yaw);
clip.constant = 0; clip.constant = 0;
point point
.copy(gunOrigin) .copy(gunOrigin)
.applyAxisAngle(J_HAT, data.yaw ?? 0) .applyAxisAngle(J_HAT, event.data.yaw)
.add(clip.normal.multiplyScalar(-distanceToBarrel)) .add(clip.normal.multiplyScalar(-distanceToBarrel))
.add(neckOrigin); .add(neckOrigin);
clip.normal.normalize(); clip.normal.normalize();
@@ -226,7 +227,7 @@ export function StaticArmorSceneComponent({
{ {
type: props.type, type: props.type,
variant: variant:
props.type === ArmorType.External ? props.variant : 'gun', props.type === ArmorType.External ? props.variant : "gun",
thickness, thickness,
} satisfies ArmorUserData } satisfies ArmorUserData
} }
@@ -235,7 +236,7 @@ export function StaticArmorSceneComponent({
event.stopPropagation(); event.stopPropagation();
const { editStatic } = tankopediaEphemeralStore.getState(); const { editStatic } = Tankopedia.state;
const bounds = new Box3().setFromObject(event.object); const bounds = new Box3().setFromObject(event.object);
const point = bounds.min const point = bounds.min
@@ -250,13 +251,13 @@ export function StaticArmorSceneComponent({
const surfaceNormal = event const surfaceNormal = event
.normal!.clone() .normal!.clone()
.applyQuaternion( .applyQuaternion(
event.object.getWorldQuaternion(new Quaternion()), event.object.getWorldQuaternion(new Quaternion())
); );
const angle = surfaceNormal.angleTo(cameraNormal); const angle = surfaceNormal.angleTo(cameraNormal);
const thicknessAngled = const thicknessAngled =
thickness / Math.sin(Math.PI / 2 - angle); thickness / Math.sin(Math.PI / 2 - angle);
mutateTankopediaEphemeralStore((draft) => { Tankopedia.mutate((draft) => {
draft.highlightArmor = { draft.highlightArmor = {
type: props.type, type: props.type,
name, name,
@@ -1,19 +1,19 @@
import { assertSecret, idToRegion } from '@blitzkit/core'; import { assertSecret, idToRegion } from "@blitzkit/core";
import { Button, Flex, Heading, Text } from '@radix-ui/themes'; import { Button, Flex, Heading, Text } from "@radix-ui/themes";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { import {
LocaleProvider, LocaleProvider,
useLocale, useLocale,
type LocaleAcceptorProps, type LocaleAcceptorProps,
} from '../../hooks/useLocale'; } from "../../hooks/useLocale";
import { App } from '../../stores/app'; import { App } from "../../stores/app";
import { CURRENT_POLICIES_AGREEMENT_INDEX } from '../../stores/app/constants'; import { CURRENT_POLICIES_AGREEMENT_INDEX } from "../../stores/app/constants";
import { LinkI18n } from '../LinkI18n'; import { LinkI18n } from "../LinkI18n";
import './index.css'; import "./index.css";
type Extension = type Extension =
| { | {
status: 'ok'; status: "ok";
data: { data: {
access_token: string; access_token: string;
account_id: number; account_id: number;
@@ -21,15 +21,13 @@ type Extension =
}; };
} }
| { | {
status: 'error'; status: "error";
}; };
export function Checks({ locale }: LocaleAcceptorProps) { export function Checks({ locale }: LocaleAcceptorProps) {
return ( return (
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<App.Provider> <Content />
<Content />
</App.Provider>
</LocaleProvider> </LocaleProvider>
); );
} }
@@ -40,19 +38,17 @@ export function Checks({ locale }: LocaleAcceptorProps) {
function Content() { function Content() {
const { locale } = useLocale(); const { locale } = useLocale();
const logins = App.use((state) => state.logins); const logins = App.use((state) => state.logins);
const mutateApp = App.useMutation();
const appStore = App.useStore();
const policiesAgreementIndex = App.use( const policiesAgreementIndex = App.use(
(state) => state.policiesAgreementIndex, (state) => state.policiesAgreementIndex
); );
const isLegal = window.location.pathname.startsWith('/legal'); const isLegal = window.location.pathname.startsWith("/legal");
const [showPoliciesAgreement, setShowPoliciesAgreement] = useState(false); const [showPoliciesAgreement, setShowPoliciesAgreement] = useState(false);
useEffect(() => { useEffect(() => {
function unsubscribe() { function unsubscribe() {
window.removeEventListener('scroll', show); window.removeEventListener("scroll", show);
window.removeEventListener('pointermove', show); window.removeEventListener("pointermove", show);
window.removeEventListener('pointerdown', show); window.removeEventListener("pointerdown", show);
} }
function show() { function show() {
@@ -64,9 +60,9 @@ function Content() {
policiesAgreementIndex !== CURRENT_POLICIES_AGREEMENT_INDEX && policiesAgreementIndex !== CURRENT_POLICIES_AGREEMENT_INDEX &&
!isLegal !isLegal
) { ) {
window.addEventListener('scroll', show); window.addEventListener("scroll", show);
window.addEventListener('pointermove', show); window.addEventListener("pointermove", show);
window.addEventListener('pointerdown', show); window.addEventListener("pointerdown", show);
} }
return unsubscribe; return unsubscribe;
@@ -78,25 +74,25 @@ function Content() {
const expiresInDays = expiresIn / 1000 / 60 / 60 / 24; const expiresInDays = expiresIn / 1000 / 60 / 60 / 24;
if (expiresInDays < 0) { if (expiresInDays < 0) {
mutateApp((draft) => { App.mutate((draft) => {
draft.logins.wargaming = undefined; draft.logins.wargaming = undefined;
}); });
} else if (expiresInDays < 7) { } else if (expiresInDays < 7) {
const { wargaming } = appStore.getState().logins; const { wargaming } = App.state.logins;
if (!wargaming) return; if (!wargaming) return;
fetch( fetch(
`https://api.worldoftanks.${idToRegion( `https://api.worldoftanks.${idToRegion(
wargaming.id, wargaming.id
)}/wot/auth/prolongate/?application_id=${assertSecret( )}/wot/auth/prolongate/?application_id=${assertSecret(
import.meta.env.PUBLIC_WARGAMING_APPLICATION_ID, import.meta.env.PUBLIC_WARGAMING_APPLICATION_ID
)}&access_token=${wargaming.token}`, )}&access_token=${wargaming.token}`
) )
.then((response) => response.json() as Promise<Extension>) .then((response) => response.json() as Promise<Extension>)
.then((json) => { .then((json) => {
mutateApp((draft) => { App.mutate((draft) => {
if (json.status === 'error') { if (json.status === "error") {
draft.logins.wargaming = undefined; draft.logins.wargaming = undefined;
} else { } else {
draft.logins.wargaming = { draft.logins.wargaming = {
@@ -126,11 +122,11 @@ function Content() {
<Text> <Text>
This website utilizes cookies to perform analytics and personalize This website utilizes cookies to perform analytics and personalize
your experience. You can learn more through{' '} your experience. You can learn more through{" "}
<LinkI18n locale={locale} href="/docs/legal/privacy-policy"> <LinkI18n locale={locale} href="/docs/legal/privacy-policy">
our privacy policy our privacy policy
</LinkI18n> </LinkI18n>
. By using BlitzKit, you also agree to our{' '} . By using BlitzKit, you also agree to our{" "}
<LinkI18n locale={locale} href="/docs/legal/terms-of-service"> <LinkI18n locale={locale} href="/docs/legal/terms-of-service">
terms of service terms of service
</LinkI18n> </LinkI18n>
@@ -141,8 +137,8 @@ function Content() {
<Button <Button
mt="2" mt="2"
onClick={() => { onClick={() => {
appStore.setState({ App.mutate((draft) => {
policiesAgreementIndex: CURRENT_POLICIES_AGREEMENT_INDEX, draft.policiesAgreementIndex = CURRENT_POLICIES_AGREEMENT_INDEX;
}); });
setShowPoliciesAgreement(false); setShowPoliciesAgreement(false);
}} }}
@@ -1,11 +1,11 @@
import { asset } from '@blitzkit/core'; import { asset } from "@blitzkit/core";
import { Flex, Heading, IconButton, Table } from '@radix-ui/themes'; import { Flex, Heading, IconButton, Table } from "@radix-ui/themes";
import type { TankCharacteristics } from '../../core/blitzkit/tankCharacteristics'; import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell'; import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
import { CompareRow } from './CompareRow'; import { CompareRow } from "./CompareRow";
import { CompareSectionTitle } from './CompareSectionTitle'; import { CompareSectionTitle } from "./CompareSectionTitle";
interface BodyProps { interface BodyProps {
stats: TankCharacteristics[]; stats: TankCharacteristics[];
@@ -14,10 +14,9 @@ interface BodyProps {
export function Body({ stats }: BodyProps) { export function Body({ stats }: BodyProps) {
const members = CompareEphemeral.use((state) => state.members); const members = CompareEphemeral.use((state) => state.members);
const hasNonRegularGun = members.some( const hasNonRegularGun = members.some(
({ gun }) => gun.gun_type!.$case !== 'regular', ({ gun }) => gun.gun_type!.$case !== "regular"
); );
const { unwrap, strings } = useLocale(); const { unwrap, strings } = useLocale();
const mutateCompareEphemeral = CompareEphemeral.useMutation();
return ( return (
<> <>
@@ -33,43 +32,35 @@ export function Body({ stats }: BodyProps) {
<StickyColumnHeaderCell key={key} top={137}> <StickyColumnHeaderCell key={key} top={137}>
<Flex justify="center"> <Flex justify="center">
<Flex> <Flex>
{gun.gun_type!.value.base.shells.map( {gun.shells.map((thisShell, shellIndex) => (
(thisShell, shellIndex) => ( <IconButton
<IconButton color={thisShell.id === shell.id ? undefined : "gray"}
color={thisShell.id === shell.id ? undefined : 'gray'} variant="soft"
variant="soft" key={thisShell.id}
key={thisShell.id} style={{
style={{ borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
borderTopLeftRadius: shellIndex === 0 ? undefined : 0, borderBottomLeftRadius:
borderBottomLeftRadius: shellIndex === 0 ? undefined : 0,
shellIndex === 0 ? undefined : 0, borderTopRightRadius:
borderTopRightRadius: shellIndex === gun.shells.length - 1 ? undefined : 0,
shellIndex === borderBottomRightRadius:
gun.gun_type!.value.base.shells.length - 1 shellIndex === gun.shells.length - 1 ? undefined : 0,
? undefined marginLeft: shellIndex === 0 ? 0 : -1,
: 0, }}
borderBottomRightRadius: onClick={() => {
shellIndex === CompareEphemeral.mutate((draft) => {
gun.gun_type!.value.base.shells.length - 1 draft.members[index].shell = thisShell;
? undefined });
: 0, }}
marginLeft: shellIndex === 0 ? 0 : -1, >
}} <img
onClick={() => { alt={unwrap(thisShell.name)}
mutateCompareEphemeral((draft) => { width={16}
draft.members[index].shell = thisShell; height={16}
}); src={asset(`icons/shells/${thisShell.icon}.webp`)}
}} />
> </IconButton>
<img ))}
alt={unwrap(thisShell.name)}
width={16}
height={16}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
/>
</IconButton>
),
)}
</Flex> </Flex>
</Flex> </Flex>
</StickyColumnHeaderCell> </StickyColumnHeaderCell>
@@ -96,7 +87,7 @@ export function Body({ stats }: BodyProps) {
} }
display={(stats) => display={(stats) =>
stats.shellReload?.toFixed(2) ?? stats.shellReload?.toFixed(2) ??
stats.shellReloads!.map((reload) => reload.toFixed(2)).join(', ') stats.shellReloads!.map((reload) => reload.toFixed(2)).join(", ")
} }
deltaNominalDisplay={(delta) => delta.toFixed(2)} deltaNominalDisplay={(delta) => delta.toFixed(2)}
/> />
@@ -2,21 +2,21 @@ import {
CaretLeftIcon, CaretLeftIcon,
CaretRightIcon, CaretRightIcon,
CaretSortIcon, CaretSortIcon,
} from '@radix-ui/react-icons'; } from "@radix-ui/react-icons";
import { Flex, IconButton, Table, Text } from '@radix-ui/themes'; import { Flex, IconButton, Table, Text } from "@radix-ui/themes";
import { useRef } from 'react'; import { useRef } from "react";
import type { TankCharacteristics } from '../../core/blitzkit/tankCharacteristics'; import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
import { Var } from '../../core/radix/var'; import { Var } from "../../core/radix/var";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { ComparePersistent } from '../../stores/comparePersistent'; import { ComparePersistent } from "../../stores/comparePersistent";
import { StickyRowHeaderCell } from '../StickyRowHeaderCell'; import { StickyRowHeaderCell } from "../StickyRowHeaderCell";
type CompareRowProps = { type CompareRowProps = {
display?: ( display?: (
member: Awaited<TankCharacteristics>, member: Awaited<TankCharacteristics>
) => number | string | undefined; ) => number | string | undefined;
deltaType?: 'higherIsBetter' | 'lowerIsBetter'; deltaType?: "higherIsBetter" | "lowerIsBetter";
decimals?: number; decimals?: number;
deltaNominalDisplay?: (delta: number) => string | number; deltaNominalDisplay?: (delta: number) => string | number;
indent?: boolean; indent?: boolean;
@@ -33,7 +33,7 @@ type CompareRowProps = {
export function CompareRow({ export function CompareRow({
display, display,
deltaType = 'higherIsBetter', deltaType = "higherIsBetter",
decimals, decimals,
deltaNominalDisplay, deltaNominalDisplay,
indent, indent,
@@ -41,17 +41,16 @@ export function CompareRow({
...props ...props
}: CompareRowProps) { }: CompareRowProps) {
const { strings } = useLocale(); const { strings } = useLocale();
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const sorting = CompareEphemeral.use((state) => state.sorting); const sorting = CompareEphemeral.use((state) => state.sorting);
const deltaMode = ComparePersistent.use((state) => state.deltaMode); const deltaMode = ComparePersistent.use((state) => state.deltaMode);
const values = stats.map((stat) => const values = stats.map((stat) =>
typeof props.value === 'function' typeof props.value === "function"
? props.value(stat)! ? props.value(stat)!
: (stat[props.value] as number), : (stat[props.value] as number)
); );
const id = useRef(Math.random()); const id = useRef(Math.random());
const name = const name =
typeof props.value === 'string' typeof props.value === "string"
? strings.website.tools.tankopedia.characteristics.values[props.value] ? strings.website.tools.tankopedia.characteristics.values[props.value]
: props.name; : props.name;
@@ -60,21 +59,21 @@ export function CompareRow({
<StickyRowHeaderCell> <StickyRowHeaderCell>
<Flex <Flex
align="center" align="center"
style={{ whiteSpace: 'nowrap' }} style={{ whiteSpace: "nowrap" }}
gap={indent ? { initial: '4', sm: '6' } : { initial: '1', sm: '2' }} gap={indent ? { initial: "4", sm: "6" } : { initial: "1", sm: "2" }}
> >
<IconButton <IconButton
color={sorting?.by === id.current ? undefined : 'gray'} color={sorting?.by === id.current ? undefined : "gray"}
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members.sort((memberA, memberB) => { draft.members.sort((memberA, memberB) => {
const indexA = draft.members.indexOf(memberA); const indexA = draft.members.indexOf(memberA);
const indexB = draft.members.indexOf(memberB); const indexB = draft.members.indexOf(memberB);
const valueA = values[indexA]; const valueA = values[indexA];
const valueB = values[indexB]; const valueB = values[indexB];
return draft.sorting?.direction === 'ascending' && return draft.sorting?.direction === "ascending" &&
draft.sorting.by === id.current draft.sorting.by === id.current
? valueA - valueB ? valueA - valueB
: valueB - valueA; : valueB - valueA;
@@ -83,26 +82,26 @@ export function CompareRow({
draft.sorting = { draft.sorting = {
by: id.current, by: id.current,
direction: direction:
draft.sorting?.direction === 'ascending' && draft.sorting?.direction === "ascending" &&
draft.sorting.by === id.current draft.sorting.by === id.current
? 'descending' ? "descending"
: 'ascending', : "ascending",
}; };
}); });
}} }}
> >
{(sorting === undefined || sorting.by !== id.current) && ( {(sorting === undefined || sorting.by !== id.current) && (
<CaretSortIcon style={{ transform: 'rotate(90deg)' }} /> <CaretSortIcon style={{ transform: "rotate(90deg)" }} />
)} )}
{sorting?.by === id.current && ( {sorting?.by === id.current && (
<> <>
{sorting.direction === 'ascending' && <CaretRightIcon />} {sorting.direction === "ascending" && <CaretRightIcon />}
{sorting.direction === 'descending' && <CaretLeftIcon />} {sorting.direction === "descending" && <CaretLeftIcon />}
</> </>
)} )}
</IconButton> </IconButton>
<Text size={{ initial: '1', sm: '2' }}>{name}</Text> <Text size={{ initial: "1", sm: "2" }}>{name}</Text>
</Flex> </Flex>
</StickyRowHeaderCell> </StickyRowHeaderCell>
@@ -110,7 +109,7 @@ export function CompareRow({
const delta = value - values[0]; const delta = value - values[0];
const deltaPercentage = value / values[0] - 1; const deltaPercentage = value / values[0] - 1;
const normalizedDeltaPercentage = Math.round( const normalizedDeltaPercentage = Math.round(
Math.min(100, Math.abs(deltaPercentage) * 2 * 100 + 25), Math.min(100, Math.abs(deltaPercentage) * 2 * 100 + 25)
); );
const resolvedDisplayValue = display const resolvedDisplayValue = display
? display(stats[index]) ? display(stats[index])
@@ -129,12 +128,12 @@ export function CompareRow({
value === values[0] value === values[0]
? undefined ? undefined
: ( : (
deltaType === 'higherIsBetter' deltaType === "higherIsBetter"
? value > values[0] ? value > values[0]
: value < values[0] : value < values[0]
) )
? `color-mix(in srgb, ${Var('green-7')} ${normalizedDeltaPercentage}%, ${Var('green-3')})` ? `color-mix(in srgb, ${Var("green-7")} ${normalizedDeltaPercentage}%, ${Var("green-3")})`
: `color-mix(in srgb, ${Var('red-7')} ${normalizedDeltaPercentage}%, ${Var('red-3')})`, : `color-mix(in srgb, ${Var("red-7")} ${normalizedDeltaPercentage}%, ${Var("red-3")})`,
}} }}
> >
<Flex <Flex
@@ -142,11 +141,11 @@ export function CompareRow({
justify="center" justify="center"
gap="2" gap="2"
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "100%",
}} }}
> >
<Text style={{ textAlign: 'center' }} wrap="nowrap"> <Text style={{ textAlign: "center" }} wrap="nowrap">
{resolvedDisplayValue} {resolvedDisplayValue}
</Text> </Text>
@@ -154,10 +153,10 @@ export function CompareRow({
resolvedDisplayValue !== undefined && resolvedDisplayValue !== undefined &&
values[0] !== undefined && ( values[0] !== undefined && (
<> <>
{deltaMode === 'absolute' && ( {deltaMode === "absolute" && (
<Text color="gray"> <Text color="gray">
( (
{`${delta > 0 ? '+' : ''}${ {`${delta > 0 ? "+" : ""}${
deltaNominalDisplay deltaNominalDisplay
? deltaNominalDisplay(delta) ? deltaNominalDisplay(delta)
: decimals === undefined : decimals === undefined
@@ -168,11 +167,11 @@ export function CompareRow({
</Text> </Text>
)} )}
{deltaMode === 'percentage' && ( {deltaMode === "percentage" && (
<Text color="gray"> <Text color="gray">
( (
{`${deltaPercentage > 0 ? '+' : ''}${Math.round( {`${deltaPercentage > 0 ? "+" : ""}${Math.round(
deltaPercentage * 100, deltaPercentage * 100
)}%`} )}%`}
) )
</Text> </Text>
@@ -1,28 +1,28 @@
import { asset, availableProvisions, permanentSkills } from '@blitzkit/core'; import { asset, availableProvisions, permanentSkills } from "@blitzkit/core";
import { checkConsumableProvisionInclusivity } from '@blitzkit/core/src/blitzkit/checkConsumableProvisionInclusivity'; import { checkConsumableProvisionInclusivity } from "@blitzkit/core/src/blitzkit/checkConsumableProvisionInclusivity";
import { ComponentPlaceholderIcon } from '@radix-ui/react-icons'; import { ComponentPlaceholderIcon } from "@radix-ui/react-icons";
import { Button, Flex, IconButton, Popover, Table } from '@radix-ui/themes'; import { Button, Flex, IconButton, Popover, Table } from "@radix-ui/themes";
import { times } from 'lodash-es'; import { times } from "lodash-es";
import { awaitableConsumableDefinitions } from '../../core/awaitables/consumableDefinitions'; import { awaitableConsumableDefinitions } from "../../core/awaitables/consumableDefinitions";
import { awaitableEquipmentDefinitions } from '../../core/awaitables/equipmentDefinitions'; import { awaitableEquipmentDefinitions } from "../../core/awaitables/equipmentDefinitions";
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions'; import { awaitableProvisionDefinitions } from "../../core/awaitables/provisionDefinitions";
import { awaitableSkillDefinitions } from '../../core/awaitables/skillDefinitions'; import { awaitableSkillDefinitions } from "../../core/awaitables/skillDefinitions";
import type { TankCharacteristics } from '../../core/blitzkit/tankCharacteristics'; import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
import { Var } from '../../core/radix/var'; import { Var } from "../../core/radix/var";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import type { EquipmentMatrix } from '../../stores/duel'; import type { EquipmentMatrix } from "../../stores/duel";
import { BlitzkitButtonGrayIcon } from '../BlitzkitButtonGrayIcon'; import { BlitzkitButtonGrayIcon } from "../BlitzkitButtonGrayIcon";
import { ConsumablesManager } from '../ConsumablesManager'; import { ConsumablesManager } from "../ConsumablesManager";
import { CrewSkillManager } from '../CrewSkillManager'; import { CrewSkillManager } from "../CrewSkillManager";
import { EquipmentManager } from '../EquipmentManager'; import { EquipmentManager } from "../EquipmentManager";
import { ModuleManager } from '../ModuleManager'; import { ModuleManager } from "../ModuleManager";
import { ProvisionsManager } from '../ProvisionsManager'; import { ProvisionsManager } from "../ProvisionsManager";
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell'; import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
import { StickyTableRoot } from '../StickyTableRoot'; import { StickyTableRoot } from "../StickyTableRoot";
import { Body } from './Body'; import { Body } from "./Body";
import { InsertionMarker } from './IntersectionMarker'; import { InsertionMarker } from "./IntersectionMarker";
import { TankCard } from './TankCard'; import { TankCard } from "./TankCard";
interface CompareTableProps { interface CompareTableProps {
stats: TankCharacteristics[]; stats: TankCharacteristics[];
@@ -43,7 +43,6 @@ const [
export function CompareTable({ stats }: CompareTableProps) { export function CompareTable({ stats }: CompareTableProps) {
const crewSkills = CompareEphemeral.use((state) => state.crewSkills); const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
const members = CompareEphemeral.use((state) => state.members); const members = CompareEphemeral.use((state) => state.members);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const { unwrap, strings } = useLocale(); const { unwrap, strings } = useLocale();
return ( return (
@@ -51,20 +50,20 @@ export function CompareTable({ stats }: CompareTableProps) {
size="1" size="1"
variant="surface" variant="surface"
style={{ style={{
position: 'absolute', position: "absolute",
maxWidth: '100%', maxWidth: "100%",
maxHeight: '100%', maxHeight: "100%",
height: '100%', height: "100%",
// height: 'calc(100vh - 128px)' // height: 'calc(100vh - 128px)'
}} }}
> >
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<StickyColumnHeaderCell width="0" style={{ height: '100%' }}> <StickyColumnHeaderCell width="0" style={{ height: "100%" }}>
<Flex <Flex
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "100%",
}} }}
align="center" align="center"
justify="center" justify="center"
@@ -82,11 +81,11 @@ export function CompareTable({ stats }: CompareTableProps) {
<Table.Body> <Table.Body>
<Table.Row> <Table.Row>
<Table.Cell style={{ height: '100%' }}> <Table.Cell style={{ height: "100%" }}>
<Flex <Flex
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "100%",
}} }}
align="center" align="center"
justify="center" justify="center"
@@ -107,15 +106,15 @@ export function CompareTable({ stats }: CompareTableProps) {
borderRadius: 2, borderRadius: 2,
backgroundColor: backgroundColor:
crewSkills[skill] === 0 crewSkills[skill] === 0
? Var('gray-11') ? Var("gray-11")
: permanentSkills.includes(skill) : permanentSkills.includes(skill)
? Var('crimson-11') ? Var("crimson-11")
: Var('amber-11'), : Var("amber-11"),
}} }}
/> />
))} ))}
</Flex> </Flex>
), )
)} )}
</Flex> </Flex>
</Button> </Button>
@@ -125,7 +124,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<CrewSkillManager <CrewSkillManager
skillLevels={crewSkills} skillLevels={crewSkills}
onChange={(skills) => { onChange={(skills) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.crewSkills = skills; draft.crewSkills = skills;
}); });
}} }}
@@ -135,7 +134,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
Object.keys(draft.crewSkills).forEach((skill) => { Object.keys(draft.crewSkills).forEach((skill) => {
draft.crewSkills[skill] = 0; draft.crewSkills[skill] = 0;
}); });
@@ -147,7 +146,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
Object.keys(draft.crewSkills).forEach((skill) => { Object.keys(draft.crewSkills).forEach((skill) => {
draft.crewSkills[skill] = 7; draft.crewSkills[skill] = 7;
}); });
@@ -177,19 +176,19 @@ export function CompareTable({ stats }: CompareTableProps) {
consumables, consumables,
camouflage, camouflage,
}, },
index, index
) => { ) => {
const equipmentPreset = const equipmentPreset =
equipmentDefinitions.presets[tank.equipment_preset]; equipmentDefinitions.presets[tank.equipment_preset];
const provisionsList = availableProvisions( const provisionsList = availableProvisions(
tank, tank,
gun, gun,
provisionDefinitions, provisionDefinitions
); );
const consumablesList = Object.values( const consumablesList = Object.values(
consumableDefinitions.consumables, consumableDefinitions.consumables
).filter((consumable) => ).filter((consumable) =>
checkConsumableProvisionInclusivity(consumable, tank, gun), checkConsumableProvisionInclusivity(consumable, tank, gun)
); );
return ( return (
@@ -205,7 +204,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button variant="ghost"> <Button variant="ghost">
<Flex align="center"> <Flex align="center">
{( {(
['turret', 'gun', 'engine', 'chassis'] as const ["turret", "gun", "engine", "chassis"] as const
).map((module, index) => ( ).map((module, index) => (
<img <img
key={module} key={module}
@@ -215,7 +214,7 @@ export function CompareTable({ stats }: CompareTableProps) {
src={asset(`icons/modules/${module}.webp`)} src={asset(`icons/modules/${module}.webp`)}
style={{ style={{
marginLeft: index > 0 ? -8 : 0, marginLeft: index > 0 ? -8 : 0,
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
))} ))}
@@ -232,7 +231,7 @@ export function CompareTable({ stats }: CompareTableProps) {
engine={engine} engine={engine}
track={track} track={track}
onChange={(modules) => { onChange={(modules) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index] = { draft.members[index] = {
...draft.members[index], ...draft.members[index],
...modules, ...modules,
@@ -245,13 +244,12 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
const member = draft.members[index]; const member = draft.members[index];
member.turret = member.tank.turrets[0]; member.turret = member.tank.turrets[0];
member.gun = member.turret.guns[0]; member.gun = member.turret.guns[0];
member.shell = member.shell = member.gun.shells[0];
member.gun.gun_type!.value.base.shells[0];
member.engine = member.tank.engines[0]; member.engine = member.tank.engines[0];
member.track = member.tank.tracks[0]; member.track = member.tank.tracks[0];
}); });
@@ -262,13 +260,12 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
const member = draft.members[index]; const member = draft.members[index];
member.turret = member.tank.turrets.at(-1)!; member.turret = member.tank.turrets.at(-1)!;
member.gun = member.turret.guns.at(-1)!; member.gun = member.turret.guns.at(-1)!;
member.shell = member.shell = member.gun.shells[0];
member.gun.gun_type!.value.base.shells[0];
member.engine = member.tank.engines.at(-1)!; member.engine = member.tank.engines.at(-1)!;
member.track = member.tank.tracks.at(-1)!; member.track = member.tank.tracks.at(-1)!;
}); });
@@ -290,9 +287,9 @@ export function CompareTable({ stats }: CompareTableProps) {
variant="ghost" variant="ghost"
radius="large" radius="large"
style={{ style={{
height: '100%', height: "100%",
width: 12, width: 12,
position: 'relative', position: "relative",
}} }}
> >
<Flex direction="column" justify="center"> <Flex direction="column" justify="center">
@@ -300,14 +297,14 @@ export function CompareTable({ stats }: CompareTableProps) {
<img <img
alt={unwrap( alt={unwrap(
provisionDefinitions.provisions[provision] provisionDefinitions.provisions[provision]
.name, .name
)} )}
key={provision} key={provision}
src={asset( src={asset(
`/icons/provisions/${provision}.webp`, `/icons/provisions/${provision}.webp`
)} )}
style={{ style={{
left: '50%', left: "50%",
/** /**
* max = 100% - 16px - 4px * max = 100% - 16px - 4px
* min = 4px * min = 4px
@@ -322,11 +319,11 @@ export function CompareTable({ stats }: CompareTableProps) {
? 0.5 ? 0.5
: index / (provisions.length - 1) : index / (provisions.length - 1)
} * (100% - 24px))`, } * (100% - 24px))`,
transform: 'translateX(-50%)', transform: "translateX(-50%)",
position: 'absolute', position: "absolute",
width: 16, width: 16,
height: 16, height: 16,
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
))} ))}
@@ -341,12 +338,12 @@ export function CompareTable({ stats }: CompareTableProps) {
<Popover.Content> <Popover.Content>
<ProvisionsManager <ProvisionsManager
provisions={provisionsList.map( provisions={provisionsList.map(
(provision) => provision.id, (provision) => provision.id
)} )}
selected={provisions} selected={provisions}
disabled={tank.max_provisions === provisions.length} disabled={tank.max_provisions === provisions.length}
onChange={(provisions) => { onChange={(provisions) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].provisions = provisions; draft.members[index].provisions = provisions;
}); });
}} }}
@@ -355,7 +352,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].provisions = []; draft.members[index].provisions = [];
}); });
}} }}
@@ -396,8 +393,8 @@ export function CompareTable({ stats }: CompareTableProps) {
height: 8, height: 8,
backgroundColor: backgroundColor:
equipment === -1 equipment === -1
? Var('crimson-11') ? Var("crimson-11")
: Var('gray-11'), : Var("gray-11"),
borderRadius: 2, borderRadius: 2,
}} }}
/> />
@@ -407,8 +404,8 @@ export function CompareTable({ stats }: CompareTableProps) {
height: 8, height: 8,
backgroundColor: backgroundColor:
equipment === 1 equipment === 1
? Var('crimson-11') ? Var("crimson-11")
: Var('gray-11'), : Var("gray-11"),
borderRadius: 2, borderRadius: 2,
}} }}
/> />
@@ -426,7 +423,7 @@ export function CompareTable({ stats }: CompareTableProps) {
matrix={equipmentMatrix} matrix={equipmentMatrix}
preset={equipmentPreset} preset={equipmentPreset}
onChange={(matrix) => { onChange={(matrix) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].equipmentMatrix = matrix; draft.members[index].equipmentMatrix = matrix;
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -438,10 +435,10 @@ export function CompareTable({ stats }: CompareTableProps) {
variant="ghost" variant="ghost"
color="red" color="red"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].equipmentMatrix = times( draft.members[index].equipmentMatrix = times(
3, 3,
() => times(3, () => 0), () => times(3, () => 0)
) as EquipmentMatrix; ) as EquipmentMatrix;
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -455,7 +452,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members.forEach((member) => { draft.members.forEach((member) => {
member.equipmentMatrix = member.equipmentMatrix =
draft.members[index].equipmentMatrix; draft.members[index].equipmentMatrix;
@@ -479,9 +476,9 @@ export function CompareTable({ stats }: CompareTableProps) {
variant="ghost" variant="ghost"
radius="large" radius="large"
style={{ style={{
height: '100%', height: "100%",
width: 12, width: 12,
position: 'relative', position: "relative",
}} }}
> >
<Flex direction="column" justify="center"> <Flex direction="column" justify="center">
@@ -490,14 +487,14 @@ export function CompareTable({ stats }: CompareTableProps) {
alt={unwrap( alt={unwrap(
consumableDefinitions.consumables[ consumableDefinitions.consumables[
consumable consumable
].name, ].name
)} )}
key={consumable} key={consumable}
src={asset( src={asset(
`/icons/consumables/${consumable}.webp`, `/icons/consumables/${consumable}.webp`
)} )}
style={{ style={{
left: '50%', left: "50%",
/** /**
* max = 100% - 16px - 4px * max = 100% - 16px - 4px
* min = 4px * min = 4px
@@ -512,11 +509,11 @@ export function CompareTable({ stats }: CompareTableProps) {
? 0.5 ? 0.5
: index / (consumables.length - 1) : index / (consumables.length - 1)
} * (100% - 24px))`, } * (100% - 24px))`,
transform: 'translateX(-50%)', transform: "translateX(-50%)",
position: 'absolute', position: "absolute",
width: 16, width: 16,
height: 16, height: 16,
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
))} ))}
@@ -533,7 +530,7 @@ export function CompareTable({ stats }: CompareTableProps) {
consumables={consumablesList} consumables={consumablesList}
selected={consumables} selected={consumables}
onConsumablesChange={(consumables) => { onConsumablesChange={(consumables) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].consumables = consumables; draft.members[index].consumables = consumables;
}); });
}} }}
@@ -546,7 +543,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].consumables = []; draft.members[index].consumables = [];
}); });
}} }}
@@ -564,18 +561,18 @@ export function CompareTable({ stats }: CompareTableProps) {
{!tank.fixed_camouflage && ( {!tank.fixed_camouflage && (
<IconButton <IconButton
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].camouflage = !camouflage; draft.members[index].camouflage = !camouflage;
}); });
}} }}
variant={camouflage ? 'solid' : 'outline'} variant={camouflage ? "solid" : "outline"}
> >
<img <img
src={asset('icons/camo.webp')} src={asset("icons/camo.webp")}
style={{ style={{
width: '1em', width: "1em",
height: '1em', height: "1em",
transform: 'scale(2) translate(15%, 15%)', transform: "scale(2) translate(15%, 15%)",
}} }}
/> />
</IconButton> </IconButton>
@@ -583,7 +580,7 @@ export function CompareTable({ stats }: CompareTableProps) {
</Flex> </Flex>
</Table.Cell> </Table.Cell>
); );
}, }
)} )}
</Table.Row> </Table.Row>
</Table.Body> </Table.Body>
@@ -1,33 +1,27 @@
import { PlusIcon, TrashIcon } from '@radix-ui/react-icons'; import { PlusIcon, TrashIcon } from "@radix-ui/react-icons";
import { Button, Dialog, Flex, SegmentedControl } from '@radix-ui/themes'; import { Button, Dialog, Flex, SegmentedControl } from "@radix-ui/themes";
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions'; import { awaitableProvisionDefinitions } from "../../core/awaitables/provisionDefinitions";
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions'; import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
import { tankToCompareMember } from '../../core/blitzkit/tankToCompareMember'; import { useLocale } from "../../hooks/useLocale";
import { useLocale } from '../../hooks/useLocale'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { CompareEphemeral } from '../../stores/compareEphemeral';
import { import {
ComparePersistent, ComparePersistent,
type DeltaMode, type DeltaMode,
} from '../../stores/comparePersistent'; } from "../../stores/comparePersistent";
import { TankSearch } from '../TankSearch'; import { TankSearch } from "../TankSearch";
interface ControlsProps { interface ControlsProps {
addTankDialogOpen: boolean; addTankDialogOpen: boolean;
onAddTankDialogOpenChange: (open: boolean) => void; onAddTankDialogOpenChange: (open: boolean) => void;
} }
const [provisionDefinitions, modelDefinitions] = await Promise.all([ const provisionDefinitions = await awaitableProvisionDefinitions;
awaitableProvisionDefinitions,
awaitableModelDefinitions,
]);
export function Controls({ export function Controls({
addTankDialogOpen, addTankDialogOpen,
onAddTankDialogOpenChange, onAddTankDialogOpenChange,
}: ControlsProps) { }: ControlsProps) {
const deltaMode = ComparePersistent.use((state) => state.deltaMode); const deltaMode = ComparePersistent.use((state) => state.deltaMode);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const mutateComparePersistent = ComparePersistent.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -60,28 +54,20 @@ export function Controls({
<TankSearch <TankSearch
compact compact
onSelect={(tank) => { onSelect={(tank) => {
const model = modelDefinitions.models[tank.id]; CompareEphemeral.mutate((draft) => {
mutateCompareEphemeral((draft) => {
draft.members.push( draft.members.push(
tankToCompareMember(tank, model, provisionDefinitions), tankToCompareMember(tank, provisionDefinitions)
); );
draft.sorting = undefined; draft.sorting = undefined;
}); });
onAddTankDialogOpenChange(false); onAddTankDialogOpenChange(false);
}} }}
onSelectAll={(tanks) => { onSelectAll={(tanks) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members.push( draft.members.push(
...tanks.map((tank) => { ...tanks.map((tank) => {
const model = modelDefinitions.models[tank.id]; return tankToCompareMember(tank, provisionDefinitions);
})
return tankToCompareMember(
tank,
model,
provisionDefinitions,
);
}),
); );
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -97,7 +83,7 @@ export function Controls({
variant="soft" variant="soft"
color="red" color="red"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members = []; draft.members = [];
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -110,18 +96,18 @@ export function Controls({
variant="classic" variant="classic"
value={deltaMode} value={deltaMode}
onValueChange={(value) => { onValueChange={(value) => {
mutateComparePersistent((draft) => { ComparePersistent.mutate((draft) => {
draft.deltaMode = value as DeltaMode; draft.deltaMode = value as DeltaMode;
}); });
}} }}
> >
<SegmentedControl.Item value={'none' satisfies DeltaMode}> <SegmentedControl.Item value={"none" satisfies DeltaMode}>
{strings.website.tools.compare.actions.deltas.none} {strings.website.tools.compare.actions.deltas.none}
</SegmentedControl.Item> </SegmentedControl.Item>
<SegmentedControl.Item value={'percentage' satisfies DeltaMode}> <SegmentedControl.Item value={"percentage" satisfies DeltaMode}>
{strings.website.tools.compare.actions.deltas.percentage} {strings.website.tools.compare.actions.deltas.percentage}
</SegmentedControl.Item> </SegmentedControl.Item>
<SegmentedControl.Item value={'absolute' satisfies DeltaMode}> <SegmentedControl.Item value={"absolute" satisfies DeltaMode}>
{strings.website.tools.compare.actions.deltas.absolute} {strings.website.tools.compare.actions.deltas.absolute}
</SegmentedControl.Item> </SegmentedControl.Item>
</SegmentedControl.Root> </SegmentedControl.Root>
@@ -1,13 +1,13 @@
import { type TankDefinition, tankIcon } from '@blitzkit/core'; import { type TankDefinition, tankIcon } from "@blitzkit/core";
import { Flex, Text } from '@radix-ui/themes'; import { Flex, Text } from "@radix-ui/themes";
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from "react";
import { Vector2 } from 'three'; import { Vector2 } from "three";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell'; import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
import { InsertionMarker } from './IntersectionMarker'; import { InsertionMarker } from "./IntersectionMarker";
import { insertionMarkers } from './IntersectionMarker/constants'; import { insertionMarkers } from "./IntersectionMarker/constants";
import { TankControl } from './TankControl'; import { TankControl } from "./TankControl";
interface TankCardProps { interface TankCardProps {
index: number; index: number;
@@ -16,7 +16,6 @@ interface TankCardProps {
export function TankCard({ index, tank }: TankCardProps) { export function TankCard({ index, tank }: TankCardProps) {
const draggable = useRef<HTMLDivElement>(null); const draggable = useRef<HTMLDivElement>(null);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const { unwrap } = useLocale(); const { unwrap } = useLocale();
useEffect(() => { useEffect(() => {
@@ -34,8 +33,8 @@ export function TankCard({ index, tank }: TankCardProps) {
delta.set(0, 0); delta.set(0, 0);
current.copy(initial.set(event.clientX, event.clientY)); current.copy(initial.set(event.clientX, event.clientY));
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
event.preventDefault(); event.preventDefault();
@@ -43,7 +42,7 @@ export function TankCard({ index, tank }: TankCardProps) {
current.set(event.clientX, event.clientY); current.set(event.clientX, event.clientY);
delta.copy(current).sub(initial); delta.copy(current).sub(initial);
insertionMarkers.forEach(({ element }) => { insertionMarkers.forEach(({ element }) => {
element.style.opacity = '0'; element.style.opacity = "0";
}); });
const distances = insertionMarkers.map((insertionMarker) => { const distances = insertionMarkers.map((insertionMarker) => {
@@ -56,34 +55,34 @@ export function TankCard({ index, tank }: TankCardProps) {
}; };
}); });
const closest = distances.reduce((a, b) => const closest = distances.reduce((a, b) =>
a.distance < b.distance ? a : b, a.distance < b.distance ? a : b
); );
dropIndex = closest.index; dropIndex = closest.index;
closest.element.style.opacity = '1'; closest.element.style.opacity = "1";
draggable.current!.style.position = 'fixed'; draggable.current!.style.position = "fixed";
draggable.current!.style.cursor = 'grabbing'; draggable.current!.style.cursor = "grabbing";
draggable.current!.style.zIndex = '1'; draggable.current!.style.zIndex = "1";
draggable.current!.style.left = `${initialRect.left + delta.x}px`; draggable.current!.style.left = `${initialRect.left + delta.x}px`;
draggable.current!.style.top = `${initialRect.top + delta.y}px`; draggable.current!.style.top = `${initialRect.top + delta.y}px`;
} }
function handlePointerUp(event: PointerEvent) { function handlePointerUp(event: PointerEvent) {
event.preventDefault(); event.preventDefault();
draggable.current!.style.position = 'static'; draggable.current!.style.position = "static";
draggable.current!.style.zIndex = 'unset'; draggable.current!.style.zIndex = "unset";
draggable.current!.style.cursor = 'grab'; draggable.current!.style.cursor = "grab";
insertionMarkers.forEach(({ element }) => { insertionMarkers.forEach(({ element }) => {
element.style.opacity = '0'; element.style.opacity = "0";
}); });
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
if (dropIndex === -1) return; if (dropIndex === -1) return;
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
const fromIndex = index; const fromIndex = index;
const toIndex = dropIndex > index ? dropIndex - 1 : dropIndex; const toIndex = dropIndex > index ? dropIndex - 1 : dropIndex;
@@ -95,10 +94,10 @@ export function TankCard({ index, tank }: TankCardProps) {
dropIndex = -1; dropIndex = -1;
} }
draggable.current?.addEventListener('pointerdown', handlePointerDown); draggable.current?.addEventListener("pointerdown", handlePointerDown);
return () => { return () => {
draggable.current?.removeEventListener('pointerdown', handlePointerDown); draggable.current?.removeEventListener("pointerdown", handlePointerDown);
}; };
}, []); }, []);
@@ -114,9 +113,9 @@ export function TankCard({ index, tank }: TankCardProps) {
justify="between" justify="between"
gap="2" gap="2"
style={{ style={{
userSelect: 'none', userSelect: "none",
touchAction: 'none', touchAction: "none",
cursor: 'grab', cursor: "grab",
}} }}
> >
<img <img
@@ -126,16 +125,16 @@ export function TankCard({ index, tank }: TankCardProps) {
height={64} height={64}
draggable={false} draggable={false}
style={{ style={{
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
<Text <Text
style={{ style={{
whiteSpace: 'nowrap', whiteSpace: "nowrap",
maxWidth: 128, maxWidth: 128,
overflow: 'hidden', overflow: "hidden",
textOverflow: 'ellipsis', textOverflow: "ellipsis",
}} }}
> >
{unwrap(tank.name)} {unwrap(tank.name)}
@@ -1,35 +1,30 @@
import { ExternalLinkIcon, LoopIcon, TrashIcon } from '@radix-ui/react-icons'; import { ExternalLinkIcon, LoopIcon, TrashIcon } from "@radix-ui/react-icons";
import { Dialog, Flex, IconButton } from '@radix-ui/themes'; import { Dialog, Flex, IconButton } from "@radix-ui/themes";
import { useState } from 'react'; import { useState } from "react";
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions'; import { awaitableProvisionDefinitions } from "../../core/awaitables/provisionDefinitions";
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions'; import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
import { tankToCompareMember } from '../../core/blitzkit/tankToCompareMember'; import { useLocale } from "../../hooks/useLocale";
import { useLocale } from '../../hooks/useLocale'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { LinkI18n } from "../LinkI18n";
import { LinkI18n } from '../LinkI18n'; import { TankSearch } from "../TankSearch";
import { TankSearch } from '../TankSearch';
interface TankControlProps { interface TankControlProps {
index: number; index: number;
slug: string; slug: string;
} }
const [provisionDefinitions, modelDefinitions] = await Promise.all([ const provisionDefinitions = await awaitableProvisionDefinitions;
awaitableProvisionDefinitions,
awaitableModelDefinitions,
]);
export function TankControl({ index, slug }: TankControlProps) { export function TankControl({ index, slug }: TankControlProps) {
const { locale } = useLocale(); const { locale } = useLocale();
const [switchTankDialogOpen, setSwitchTankDialogOpen] = useState(false); const [switchTankDialogOpen, setSwitchTankDialogOpen] = useState(false);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
return ( return (
<Flex gap="3" justify="center" style={{ width: '100%' }}> <Flex gap="3" justify="center" style={{ width: "100%" }}>
<IconButton <IconButton
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members.splice(index, 1); draft.members.splice(index, 1);
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -61,13 +56,10 @@ export function TankControl({ index, slug }: TankControlProps) {
<TankSearch <TankSearch
compact compact
onSelect={(tank) => { onSelect={(tank) => {
const model = modelDefinitions.models[tank.id]; CompareEphemeral.mutate((draft) => {
mutateCompareEphemeral((draft) => {
draft.members[index] = tankToCompareMember( draft.members[index] = tankToCompareMember(
tank, tank,
model, provisionDefinitions
provisionDefinitions,
); );
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -1,16 +1,14 @@
import { Switch } from '@radix-ui/themes'; import { Switch } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState } from '../../stores/embedState'; import { EmbedState } from "../../stores/embedState";
export function Boolean({ configKey }: EmbedPreviewControllerProps) { export function Boolean({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return ( return (
<Switch <Switch
variant="classic" variant="classic"
checked={EmbedState.use((state) => state[configKey] as boolean)} checked={EmbedState.use((state) => state[configKey] as boolean)}
onCheckedChange={() => { onCheckedChange={() => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = !draft[configKey]; draft[configKey] = !draft[configKey];
}); });
}} }}
@@ -1,16 +1,15 @@
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState } from '../../stores/embedState'; import { EmbedState } from "../../stores/embedState";
import { ColorControllerRaw } from './ColorControllerRaw'; import { ColorControllerRaw } from "./ColorControllerRaw";
export function Color({ configKey }: EmbedPreviewControllerProps) { export function Color({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
const color = EmbedState.use((state) => state[configKey] as string); const color = EmbedState.use((state) => state[configKey] as string);
return ( return (
<ColorControllerRaw <ColorControllerRaw
value={color} value={color}
onValueChange={(value) => { onValueChange={(value) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = value; draft[configKey] = value;
}); });
}} }}
@@ -1,11 +1,11 @@
import { Select } from '@radix-ui/themes'; import { Select } from "@radix-ui/themes";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState } from '../../stores/embedState'; import { EmbedState } from "../../stores/embedState";
import type { import type {
EmbedConfigItemType, EmbedConfigItemType,
EmbedItemType, EmbedItemType,
} from '../../stores/embedState/constants'; } from "../../stores/embedState/constants";
export function Enum({ export function Enum({
configKey, configKey,
@@ -13,14 +13,13 @@ export function Enum({
}: EmbedPreviewControllerProps & { }: EmbedPreviewControllerProps & {
config: EmbedConfigItemType<EmbedItemType.Enum>; config: EmbedConfigItemType<EmbedItemType.Enum>;
}) { }) {
const mutateEmbedState = EmbedState.useMutation();
const { locale } = useLocale(); const { locale } = useLocale();
return ( return (
<Select.Root <Select.Root
value={EmbedState.use((state) => state[configKey] as string)} value={EmbedState.use((state) => state[configKey] as string)}
onValueChange={(value) => { onValueChange={(value) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = value; draft[configKey] = value;
}); });
}} }}
@@ -2,9 +2,9 @@ import {
searchPlayersAcrossRegions, searchPlayersAcrossRegions,
type AccountListItem, type AccountListItem,
type AccountListWithServer, type AccountListWithServer,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { CaretLeftIcon, Link2Icon, PersonIcon } from '@radix-ui/react-icons'; import { CaretLeftIcon, Link2Icon, PersonIcon } from "@radix-ui/react-icons";
import { import {
Button, Button,
Dialog, Dialog,
@@ -13,15 +13,15 @@ import {
Spinner, Spinner,
Text, Text,
TextField, TextField,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { debounce } from 'lodash-es'; import { debounce } from "lodash-es";
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from "react";
import { stringify } from 'urlon'; import { stringify } from "urlon";
import type { embedConfigurations } from '../../constants/embeds'; import type { embedConfigurations } from "../../constants/embeds";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { EmbedState, type EmbedStateStore } from '../../stores/embedState'; import { EmbedState, type EmbedStateStore } from "../../stores/embedState";
import { CopyButton } from '../CopyButton'; import { CopyButton } from "../CopyButton";
import { SearchResults } from '../SearchResults'; import { SearchResults } from "../SearchResults";
interface GenerateURLProps { interface GenerateURLProps {
embed: keyof typeof embedConfigurations; embed: keyof typeof embedConfigurations;
@@ -31,7 +31,6 @@ export function GenerateURL({ embed }: GenerateURLProps) {
const [searchDialogOpen, setSearchDialogOpen] = useState(false); const [searchDialogOpen, setSearchDialogOpen] = useState(false);
const [copyDialogOpen, setCopyDialogOpen] = useState(false); const [copyDialogOpen, setCopyDialogOpen] = useState(false);
const [copyUser, setCopyUser] = useState<AccountListItem | null>(null); const [copyUser, setCopyUser] = useState<AccountListItem | null>(null);
const embedStateStore = EmbedState.useStore();
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [results, setResults] = useState<AccountListWithServer>([]); const [results, setResults] = useState<AccountListWithServer>([]);
@@ -52,7 +51,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
setSearching(false); setSearching(false);
setResults(results); setResults(results);
}, 500), }, 500),
[], []
); );
const handleChange = useCallback(() => { const handleChange = useCallback(() => {
if (!input.current) return; if (!input.current) return;
@@ -94,7 +93,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
<TextField.Root <TextField.Root
variant="classic" variant="classic"
mt="4" mt="4"
style={{ width: '20rem' }} style={{ width: "20rem" }}
placeholder={strings.website.common.player_search.hint} placeholder={strings.website.common.player_search.hint}
ref={input} ref={input}
onChange={handleChange} onChange={handleChange}
@@ -136,7 +135,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
</Flex> </Flex>
<Dialog.Close> <Dialog.Close>
<Button color="red" variant="outline" style={{ width: '100%' }}> <Button color="red" variant="outline" style={{ width: "100%" }}>
{strings.website.tools.embed.configuration.export.generate.cancel} {strings.website.tools.embed.configuration.export.generate.cancel}
</Button> </Button>
</Dialog.Close> </Dialog.Close>
@@ -144,7 +143,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
</Dialog.Root> </Dialog.Root>
<Dialog.Root open={copyDialogOpen} onOpenChange={setCopyDialogOpen}> <Dialog.Root open={copyDialogOpen} onOpenChange={setCopyDialogOpen}>
<Dialog.Content style={{ width: 'fit-content' }}> <Dialog.Content style={{ width: "fit-content" }}>
<Flex direction="column" gap="1"> <Flex direction="column" gap="1">
<Heading size="5"> <Heading size="5">
{ {
@@ -156,7 +155,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
{literals( {literals(
strings.website.tools.embed.configuration.export.generate strings.website.tools.embed.configuration.export.generate
.success.info, .success.info,
[`${copyUser?.nickname}`], [`${copyUser?.nickname}`]
)} )}
</Text> </Text>
</Flex> </Flex>
@@ -170,7 +169,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
setSearchDialogOpen(true); setSearchDialogOpen(true);
}} }}
> >
<CaretLeftIcon />{' '} <CaretLeftIcon />{" "}
{ {
strings.website.tools.embed.configuration.export.generate strings.website.tools.embed.configuration.export.generate
.success.back .success.back
@@ -181,12 +180,10 @@ export function GenerateURL({ embed }: GenerateURLProps) {
copy={() => { copy={() => {
if (!copyUser) return; if (!copyUser) return;
const state = embedStateStore.getState();
const initial = embedStateStore.getInitialState();
const shallowState: EmbedStateStore = {}; const shallowState: EmbedStateStore = {};
Object.entries(state).forEach(([key, value]) => { Object.entries(EmbedState.state).forEach(([key, value]) => {
if (value !== initial[key]) { if (value !== EmbedState.initial[key]) {
shallowState[key] = value; shallowState[key] = value;
} }
}); });
@@ -202,7 +199,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
return `${location.origin}/embed/${embed}/host?${searchParams.toString()}`; return `${location.origin}/embed/${embed}/host?${searchParams.toString()}`;
}} }}
> >
<Link2Icon />{' '} <Link2Icon />{" "}
{ {
strings.website.tools.embed.configuration.export.generate strings.website.tools.embed.configuration.export.generate
.success.copy .success.copy
@@ -6,17 +6,16 @@ import {
Text, Text,
TextField, TextField,
Tooltip, Tooltip,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { useRef, useState } from 'react'; import { useRef, useState } from "react";
import { parse } from 'urlon'; import { parse } from "urlon";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { EmbedState } from '../../stores/embedState'; import { EmbedState } from "../../stores/embedState";
export function Import() { export function Import() {
const importInput = useRef<HTMLInputElement>(null); const importInput = useRef<HTMLInputElement>(null);
const [showInvalidURLPrompt, setShowInvalidURLPrompt] = useState(false); const [showInvalidURLPrompt, setShowInvalidURLPrompt] = useState(false);
const [showConfirmation, setConfirmation] = useState(false); const [showConfirmation, setConfirmation] = useState(false);
const mutateEmbedState = EmbedState.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -64,15 +63,15 @@ export function Import() {
try { try {
const url = new URL(importInput.current.value); const url = new URL(importInput.current.value);
const state = url.searchParams.get('state'); const state = url.searchParams.get("state");
if (state === null) throw undefined; if (state === null) throw undefined;
const json = parse(state); const json = parse(state);
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
Object.assign(draft, json); Object.assign(draft, json);
}); });
importInput.current.value = ''; importInput.current.value = "";
setConfirmation(true); setConfirmation(true);
setTimeout(() => setConfirmation(false), 2000); setTimeout(() => setConfirmation(false), 2000);
} catch { } catch {
@@ -1,10 +1,8 @@
import { Slider } from '@radix-ui/themes'; import { Slider } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState, type RadixRadius } from '../../stores/embedState'; import { EmbedState, type RadixRadius } from "../../stores/embedState";
export function Radius({ configKey }: EmbedPreviewControllerProps) { export function Radius({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return ( return (
<Slider <Slider
variant="classic" variant="classic"
@@ -13,12 +11,12 @@ export function Radius({ configKey }: EmbedPreviewControllerProps) {
value={[ value={[
EmbedState.use((state) => { EmbedState.use((state) => {
const radius = state[configKey] as RadixRadius; const radius = state[configKey] as RadixRadius;
return radius === 'full' ? 5 : parseInt(radius); return radius === "full" ? 5 : parseInt(radius);
}), }),
]} ]}
onValueChange={([value]) => { onValueChange={([value]) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = value === 5 ? 'full' : `${value}`; draft[configKey] = value === 5 ? "full" : `${value}`;
}); });
}} }}
/> />
@@ -1,21 +1,18 @@
import { FontBoldIcon, FontSizeIcon } from '@radix-ui/react-icons'; import { FontBoldIcon, FontSizeIcon } from "@radix-ui/react-icons";
import { Flex, Slider, Text } from '@radix-ui/themes'; import { Flex, Slider, Text } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState, type RadixSizeWithout0 } from '../../stores/embedState'; import { EmbedState, type RadixSizeWithout0 } from "../../stores/embedState";
import { import {
radixTextWeights, radixTextWeights,
type EmbedConfigItemType, type EmbedConfigItemType,
type EmbedItemType, type EmbedItemType,
} from '../../stores/embedState/constants'; } from "../../stores/embedState/constants";
import { ColorControllerRaw } from './ColorControllerRaw'; import { ColorControllerRaw } from "./ColorControllerRaw";
export function RichText({ configKey }: EmbedPreviewControllerProps) { export function RichText({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
const state = EmbedState.use( const state = EmbedState.use(
(state) => (state) =>
state[ state[configKey] as EmbedConfigItemType<EmbedItemType.RichText>["default"]
configKey
] as EmbedConfigItemType<EmbedItemType.RichText>['default'],
); );
return ( return (
@@ -24,11 +21,11 @@ export function RichText({ configKey }: EmbedPreviewControllerProps) {
<ColorControllerRaw <ColorControllerRaw
value={state.color} value={state.color}
onValueChange={(value) => { onValueChange={(value) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
( (
draft[ draft[
configKey configKey
] as EmbedConfigItemType<EmbedItemType.RichText>['default'] ] as EmbedConfigItemType<EmbedItemType.RichText>["default"]
).color = value; ).color = value;
}); });
}} }}
@@ -45,11 +42,11 @@ export function RichText({ configKey }: EmbedPreviewControllerProps) {
max={9} max={9}
value={[Number(state.size)]} value={[Number(state.size)]}
onValueChange={([value]) => { onValueChange={([value]) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
( (
draft[ draft[
configKey configKey
] as EmbedConfigItemType<EmbedItemType.RichText>['default'] ] as EmbedConfigItemType<EmbedItemType.RichText>["default"]
).size = `${value}` as RadixSizeWithout0; ).size = `${value}` as RadixSizeWithout0;
}); });
}} }}
@@ -66,11 +63,11 @@ export function RichText({ configKey }: EmbedPreviewControllerProps) {
max={radixTextWeights.length - 1} max={radixTextWeights.length - 1}
value={[radixTextWeights.indexOf(state.weight)]} value={[radixTextWeights.indexOf(state.weight)]}
onValueChange={([value]) => { onValueChange={([value]) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
( (
draft[ draft[
configKey configKey
] as EmbedConfigItemType<EmbedItemType.RichText>['default'] ] as EmbedConfigItemType<EmbedItemType.RichText>["default"]
).weight = radixTextWeights[value]; ).weight = radixTextWeights[value];
}); });
}} }}
@@ -1,10 +1,8 @@
import { Slider } from '@radix-ui/themes'; import { Slider } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState, type RadixSize } from '../../stores/embedState'; import { EmbedState, type RadixSize } from "../../stores/embedState";
export function Size({ configKey }: EmbedPreviewControllerProps) { export function Size({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return ( return (
<Slider <Slider
variant="classic" variant="classic"
@@ -14,7 +12,7 @@ export function Size({ configKey }: EmbedPreviewControllerProps) {
EmbedState.use((state) => parseInt(state[configKey] as RadixSize)), EmbedState.use((state) => parseInt(state[configKey] as RadixSize)),
]} ]}
onValueChange={([value]) => { onValueChange={([value]) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = `${value}`; draft[configKey] = `${value}`;
}); });
}} }}
@@ -1,10 +1,8 @@
import { Slider } from '@radix-ui/themes'; import { Slider } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState, type RadixSizeWithout0 } from '../../stores/embedState'; import { EmbedState, type RadixSizeWithout0 } from "../../stores/embedState";
export function SizeWithout0({ configKey }: EmbedPreviewControllerProps) { export function SizeWithout0({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return ( return (
<Slider <Slider
variant="classic" variant="classic"
@@ -12,11 +10,11 @@ export function SizeWithout0({ configKey }: EmbedPreviewControllerProps) {
max={9} max={9}
value={[ value={[
EmbedState.use((state) => EmbedState.use((state) =>
parseInt(state[configKey] as RadixSizeWithout0), parseInt(state[configKey] as RadixSizeWithout0)
), ),
]} ]}
onValueChange={([value]) => { onValueChange={([value]) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = `${value}`; draft[configKey] = `${value}`;
}); });
}} }}
@@ -1,10 +1,10 @@
import { Slider as RadixSlider } from '@radix-ui/themes'; import { Slider as RadixSlider } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState, type RadixSize } from '../../stores/embedState'; import { EmbedState, type RadixSize } from "../../stores/embedState";
import type { import type {
EmbedConfigItemType, EmbedConfigItemType,
EmbedItemType, EmbedItemType,
} from '../../stores/embedState/constants'; } from "../../stores/embedState/constants";
export function Slider({ export function Slider({
configKey, configKey,
@@ -12,8 +12,6 @@ export function Slider({
}: EmbedPreviewControllerProps & { }: EmbedPreviewControllerProps & {
config: EmbedConfigItemType<EmbedItemType.Slider>; config: EmbedConfigItemType<EmbedItemType.Slider>;
}) { }) {
const mutateEmbedState = EmbedState.useMutation();
return ( return (
<RadixSlider <RadixSlider
variant="classic" variant="classic"
@@ -23,7 +21,7 @@ export function Slider({
EmbedState.use((state) => parseInt(state[configKey] as RadixSize)), EmbedState.use((state) => parseInt(state[configKey] as RadixSize)),
]} ]}
onValueChange={([value]) => { onValueChange={([value]) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = value; draft[configKey] = value;
}); });
}} }}
@@ -1,17 +1,15 @@
import { TextField } from '@radix-ui/themes'; import { TextField } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState } from '../../stores/embedState'; import { EmbedState } from "../../stores/embedState";
export function TextController({ configKey }: EmbedPreviewControllerProps) { export function TextController({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return ( return (
<TextField.Root <TextField.Root
variant="classic" variant="classic"
placeholder="Empty" placeholder="Empty"
value={EmbedState.use((state) => state[configKey] as string)} value={EmbedState.use((state) => state[configKey] as string)}
onChange={(event) => { onChange={(event) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = event.target.value; draft[configKey] = event.target.value;
}); });
}} }}
@@ -7,17 +7,17 @@ import {
idToRegion, idToRegion,
sumCompositeStats, sumCompositeStats,
type IndividualTankStats, type IndividualTankStats,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { STRINGS } from '@blitzkit/i18n'; import { STRINGS } from "@blitzkit/i18n";
import { ReloadIcon } from '@radix-ui/react-icons'; import { ReloadIcon } from "@radix-ui/react-icons";
import { ContextMenu } from '@radix-ui/themes'; import { ContextMenu } from "@radix-ui/themes";
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from "react";
import { breakdownConfig } from '../../constants/embeds'; import { breakdownConfig } from "../../constants/embeds";
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions'; import { awaitableAverageDefinitions } from "../../core/awaitables/averageDefinitions";
import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../../core/awaitables/tankDefinitions";
import { EmbedBreakdownPersistent } from '../../stores/embedBreakdownPersistent'; import { EmbedBreakdown } from "../../stores/embedBreakdown";
import { useEmbedStateCurry } from '../../stores/embedState/utilities'; import { useEmbedStateCurry } from "../../stores/embedState/utilities";
import { BreakdownEmbedCard, BreakdownEmbedWrapper } from '../TanksEmbed'; import { BreakdownEmbedCard, BreakdownEmbedWrapper } from "../TanksEmbed";
export const compositeStatsKeysOptions = compositeStatsKeys.map((value) => ({ export const compositeStatsKeysOptions = compositeStatsKeys.map((value) => ({
value, value,
@@ -55,7 +55,7 @@ export const fakeCompositeStats = compositeStats(
wins: 30, wins: 30,
xp: 1230 * 50, xp: 1230 * 50,
}, },
averageDefinitions.averages[7297].mu, averageDefinitions.averages[7297].mu
); );
export function BreakdownPreview() { export function BreakdownPreview() {
@@ -64,11 +64,11 @@ export function BreakdownPreview() {
return ( return (
<BreakdownEmbedWrapper> <BreakdownEmbedWrapper>
{useEmbedState('showTotal') && ( {useEmbedState("showTotal") && (
<BreakdownEmbedCard composite={fakeCompositeStats} tank={null} /> <BreakdownEmbedCard composite={fakeCompositeStats} tank={null} />
)} )}
{tanks.slice(0, useEmbedState('listMaxTanks')).map((tank) => ( {tanks.slice(0, useEmbedState("listMaxTanks")).map((tank) => (
<BreakdownEmbedCard <BreakdownEmbedCard
composite={fakeCompositeStats} composite={fakeCompositeStats}
key={tank.id} key={tank.id}
@@ -80,26 +80,17 @@ export function BreakdownPreview() {
} }
export function BreakdownRenderer() { export function BreakdownRenderer() {
return (
<EmbedBreakdownPersistent.Provider>
<BreakdownRendererContent />
</EmbedBreakdownPersistent.Provider>
);
}
function BreakdownRendererContent() {
const { useEmbedState } = useEmbedStateCurry<typeof breakdownConfig>(); const { useEmbedState } = useEmbedStateCurry<typeof breakdownConfig>();
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const mutateEmbedBreakdownPersistent = EmbedBreakdownPersistent.useMutation(); const id = Number(params.get("id"));
const id = Number(params.get('id')); const tankStatsA = EmbedBreakdown.use((state) => state[id] ?? []);
const tankStatsA = EmbedBreakdownPersistent.use((state) => state[id] ?? []);
const [tankStatsB, setTankStatsB] = useState<IndividualTankStats[]>([]); const [tankStatsB, setTankStatsB] = useState<IndividualTankStats[]>([]);
const diff = useMemo( const diff = useMemo(
() => () =>
deltaTankStats(tankStatsA, tankStatsB).sort( deltaTankStats(tankStatsA, tankStatsB).sort(
(a, b) => b.last_battle_time - a.last_battle_time, (a, b) => b.last_battle_time - a.last_battle_time
), ),
[tankStatsB], [tankStatsB]
); );
const compositeStats = diff const compositeStats = diff
.map((diff) => { .map((diff) => {
@@ -109,14 +100,14 @@ function BreakdownRendererContent() {
const compositeStats = calculateCompositeStats( const compositeStats = calculateCompositeStats(
{ battle_life_time: diff.battle_life_time, ...diff.all }, { battle_life_time: diff.battle_life_time, ...diff.all },
average.mu, average.mu
); );
return { id: diff.tank_id, compositeStats }; return { id: diff.tank_id, compositeStats };
}) })
.filter((stats) => stats !== null); .filter((stats) => stats !== null);
const sum = sumCompositeStats( const sum = sumCompositeStats(
compositeStats.map(({ compositeStats }) => compositeStats), compositeStats.map(({ compositeStats }) => compositeStats)
); );
useEffect(() => { useEffect(() => {
@@ -135,12 +126,12 @@ function BreakdownRendererContent() {
<ContextMenu.Root> <ContextMenu.Root>
<ContextMenu.Trigger> <ContextMenu.Trigger>
<BreakdownEmbedWrapper> <BreakdownEmbedWrapper>
{useEmbedState('showTotal') && ( {useEmbedState("showTotal") && (
<BreakdownEmbedCard composite={sum} tank={null} /> <BreakdownEmbedCard composite={sum} tank={null} />
)} )}
{compositeStats {compositeStats
.slice(0, useEmbedState('listMaxTanks')) .slice(0, useEmbedState("listMaxTanks"))
.map(({ id, compositeStats }) => { .map(({ id, compositeStats }) => {
const tank = tankDefinitions.tanks[id]; const tank = tankDefinitions.tanks[id];
@@ -163,7 +154,7 @@ function BreakdownRendererContent() {
if (newA === null) return; if (newA === null) return;
mutateEmbedBreakdownPersistent((draft) => { EmbedBreakdown.mutate((draft) => {
draft[id] = newA; draft[id] = newA;
}); });
}} }}
@@ -1,15 +1,15 @@
import locales from '@blitzkit/i18n/locales.json'; import locales from "@blitzkit/i18n/locales.json";
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { Flex, Text } from '@radix-ui/themes'; import { Flex, Text } from "@radix-ui/themes";
import fuzzysort from 'fuzzysort'; import fuzzysort from "fuzzysort";
import { times } from 'lodash-es'; import { times } from "lodash-es";
import { useMemo, useState } from 'react'; import { useMemo, useState } from "react";
import { awaitableGallery } from '../../core/awaitables/gallery'; import { awaitableGallery } from "../../core/awaitables/gallery";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { GalleryEphemeral } from '../../stores/galleryEphemeral'; import { Gallery } from "../../stores/gallery";
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
import { InlineSkeleton } from '../InlineSkeleton'; import { InlineSkeleton } from "../InlineSkeleton";
import { GalleryCard } from './Card'; import { GalleryCard } from "./Card";
export interface Avatar { export interface Avatar {
name: string; name: string;
@@ -22,7 +22,7 @@ const DEFAULT_LOADED = 42;
const PREVIEW_COUNT = 28; const PREVIEW_COUNT = 28;
export function GalleryList({ skeleton }: MaybeSkeletonComponentProps) { export function GalleryList({ skeleton }: MaybeSkeletonComponentProps) {
const search = GalleryEphemeral.use((state) => state.search); const search = Gallery.use((state) => state.search);
const [loadedCards, setLoadedCards] = useState(DEFAULT_LOADED); const [loadedCards, setLoadedCards] = useState(DEFAULT_LOADED);
const { locale, strings } = useLocale(); const { locale, strings } = useLocale();
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -35,9 +35,9 @@ export function GalleryList({ skeleton }: MaybeSkeletonComponentProps) {
.go(search, gallery.avatars, { .go(search, gallery.avatars, {
keys: [ keys: [
...locales.supported.map( ...locales.supported.map(
(supported) => `name.locales.${supported.locale}`, (supported) => `name.locales.${supported.locale}`
), ),
'id', "id",
], ],
}) })
.map((result) => result.obj); .map((result) => result.obj);
@@ -72,7 +72,7 @@ export function GalleryList({ skeleton }: MaybeSkeletonComponentProps) {
skeleton skeleton
onIntersection={() => setLoadedCards((state) => state + 2)} onIntersection={() => setLoadedCards((state) => state + 2)}
/> />
), )
)} )}
</Flex> </Flex>
</> </>
@@ -1,13 +1,12 @@
import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { TextField } from '@radix-ui/themes'; import { TextField } from "@radix-ui/themes";
import { useRef } from 'react'; import { useRef } from "react";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { GalleryEphemeral } from '../../stores/galleryEphemeral'; import { Gallery } from "../../stores/gallery";
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
export function GallerySearch({ skeleton }: MaybeSkeletonComponentProps) { export function GallerySearch({ skeleton }: MaybeSkeletonComponentProps) {
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const mutateGalleryEphemeral = GalleryEphemeral.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -17,7 +16,7 @@ export function GallerySearch({ skeleton }: MaybeSkeletonComponentProps) {
placeholder={strings.website.tools.gallery.search.hint} placeholder={strings.website.tools.gallery.search.hint}
ref={input} ref={input}
onChange={(event) => { onChange={(event) => {
mutateGalleryEphemeral((draft) => { Gallery.mutate((draft) => {
const trimmed = event.target.value.trim(); const trimmed = event.target.value.trim();
draft.search = trimmed.length === 0 ? undefined : trimmed; draft.search = trimmed.length === 0 ? undefined : trimmed;
}); });
@@ -1,13 +1,13 @@
import { Box } from '@radix-ui/themes'; import { Box } from "@radix-ui/themes";
import { useEffect } from 'react'; import { useEffect } from "react";
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions'; import { awaitableModelDefinitions } from "../../core/awaitables/modelDefinitions";
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions'; import { awaitableProvisionDefinitions } from "../../core/awaitables/provisionDefinitions";
import { tankToDuelMember } from '../../core/blitzkit/tankToDuelMember'; import { tankToDuelMember } from "../../core/blitzkit/tankToDuelMember";
import { Var } from '../../core/radix/var'; import { Var } from "../../core/radix/var";
import { Duel } from '../../stores/duel'; import { Duel } from "../../stores/duel";
import { GuessEphemeral } from '../../stores/guessEphemeral'; import { Guess, GuessState } from "../../stores/guess";
import { TankopediaEphemeral } from '../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../stores/tankopedia";
import './index.css'; import "./index.css";
const [modelDefinitions, provisionDefinitions] = await Promise.all([ const [modelDefinitions, provisionDefinitions] = await Promise.all([
awaitableModelDefinitions, awaitableModelDefinitions,
@@ -15,17 +15,14 @@ const [modelDefinitions, provisionDefinitions] = await Promise.all([
]); ]);
export function GuessBackground() { export function GuessBackground() {
const guessState = GuessEphemeral.use((state) => state.guessState); const guessState = Guess.use((state) => state.guessState);
const tank = GuessEphemeral.use((state) => state.tank); const tank = Guess.use((state) => state.tank);
const mutateDuel = Duel.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
useEffect(() => { useEffect(() => {
mutateDuel((state) => { Duel.mutate((state) => {
const model = modelDefinitions.models[tank.id]; state.protagonist = tankToDuelMember(tank, provisionDefinitions);
state.protagonist = tankToDuelMember(tank, model, provisionDefinitions);
}); });
mutateTankopediaEphemeral((state) => { Tankopedia.mutate((state) => {
state.model = modelDefinitions.models[tank.id]; state.model = modelDefinitions.models[tank.id];
}); });
}, [tank]); }, [tank]);
@@ -39,13 +36,13 @@ export function GuessBackground() {
top="0" top="0"
left="0" left="0"
style={{ style={{
transitionDuration: '2s', transitionDuration: "2s",
backgroundColor: Var( backgroundColor: Var(
guessState === null guessState === GuessState.NotGuessed
? 'accent-2' ? "accent-2"
: guessState : guessState === GuessState.Correct
? 'jade-3' ? "jade-3"
: 'tomato-3', : "tomato-3"
), ),
}} }}
> >
@@ -53,7 +50,7 @@ export function GuessBackground() {
width="100%" width="100%"
height="100%" height="100%"
style={{ style={{
background: `radial-gradient(circle, ${Var('color-panel-translucent')}, ${Var('color-panel-solid')})`, background: `radial-gradient(circle, ${Var("color-panel-translucent")}, ${Var("color-panel-solid")})`,
}} }}
/> />
</Box> </Box>
@@ -67,13 +64,13 @@ export function GuessBackground() {
left="0" left="0"
style={{ style={{
backgroundImage: ` backgroundImage: `
radial-gradient(circle, ${Var('white-a1')} 20%, transparent 20%), radial-gradient(circle, ${Var("white-a1")} 20%, transparent 20%),
radial-gradient(circle at 0 0, ${Var('white-a1')} 10%, transparent 10%), radial-gradient(circle at 0 0, ${Var("white-a1")} 10%, transparent 10%),
radial-gradient(circle at 100% 0, ${Var('white-a1')} 10%, transparent 10%), radial-gradient(circle at 100% 0, ${Var("white-a1")} 10%, transparent 10%),
radial-gradient(circle at 0 100%, ${Var('white-a1')} 10%, transparent 10%), radial-gradient(circle at 0 100%, ${Var("white-a1")} 10%, transparent 10%),
radial-gradient(circle at 100% 100%, ${Var('white-a1')} 10%, transparent 10%) radial-gradient(circle at 100% 100%, ${Var("white-a1")} 10%, transparent 10%)
`, `,
backgroundSize: '4rem 4rem', backgroundSize: "4rem 4rem",
}} }}
/> />
</> </>
@@ -1,30 +1,28 @@
import { Canvas } from '@react-three/fiber'; import { Guess, GuessState } from "../stores/guess";
import { GuessEphemeral } from '../stores/guessEphemeral'; import { SmartCanvas } from "./SmartCanvas";
import { TankopediaDisplay } from '../stores/tankopediaPersistent/constants'; import { Controls } from "./Tankopedia/HeroSection/components/TankSandbox/components/Control";
import { Controls } from './Tankopedia/HeroSection/components/TankSandbox/components/Control'; import { Lighting } from "./Tankopedia/HeroSection/components/TankSandbox/components/Lighting";
import { Lighting } from './Tankopedia/HeroSection/components/TankSandbox/components/Lighting'; import { TankModel } from "./Tankopedia/HeroSection/components/TankSandbox/components/TankModel";
import { TankModel } from './Tankopedia/HeroSection/components/TankSandbox/components/TankModel';
const BRIGHTNESS = 2 ** -5; const BRIGHTNESS = 2 ** -5.5;
const CONTRAST = 2 ** 2; const CONTRAST = 2 ** 2;
export function GuessRenderer() { export function GuessRenderer() {
const guessState = GuessEphemeral.use((state) => state.guessState); const guessState = Guess.use((state) => state.guessState);
const isRevealed = guessState !== null; const isRevealed = guessState !== GuessState.NotGuessed;
return ( return (
<Canvas <SmartCanvas
style={{ style={{
filter: `grayscale(${isRevealed ? 0 : 1}) contrast(${isRevealed ? 1 : CONTRAST}) brightness(${isRevealed ? 1 : BRIGHTNESS})`, filter: `grayscale(${isRevealed ? 0 : 1}) contrast(${isRevealed ? 1 : CONTRAST}) brightness(${isRevealed ? 1 : BRIGHTNESS})`,
// filter: `drop-shadow(0 0 1rem ${Var('black-a11')})`, transitionDuration: isRevealed ? "2s" : undefined,
transitionDuration: isRevealed ? '2s' : undefined, transitionProperty: "filter",
transitionProperty: 'filter',
}} }}
camera={{ position: [-5, 5, -5] }} camera={{ position: [-5, 5, -5] }}
> >
<Controls distanceScale={2} zoomable={false} /> <Controls zoomable={false} />
<Lighting display={TankopediaDisplay.Model} /> <Lighting />
<TankModel /> <TankModel />
</Canvas> </SmartCanvas>
); );
} }
+39 -33
View File
@@ -2,13 +2,13 @@ import {
SEARCH_KEYS, SEARCH_KEYS,
TankDefinition, TankDefinition,
TIER_ROMAN_NUMERALS, TIER_ROMAN_NUMERALS,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n'; import { literals } from "@blitzkit/i18n";
import { import {
ArrowRightIcon, ArrowRightIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
PaperPlaneIcon, PaperPlaneIcon,
} from '@radix-ui/react-icons'; } from "@radix-ui/react-icons";
import { import {
Box, Box,
Button, Button,
@@ -17,16 +17,16 @@ import {
Spinner, Spinner,
Text, Text,
TextField, TextField,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import fuzzysort from 'fuzzysort'; import fuzzysort from "fuzzysort";
import { debounce } from 'lodash-es'; import { debounce } from "lodash-es";
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from "react";
import { awaitableTankDefinitions } from '../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../core/awaitables/tankDefinitions";
import { awaitableTankNames } from '../core/awaitables/tankNames'; import { awaitableTankNames } from "../core/awaitables/tankNames";
import { useLocale } from '../hooks/useLocale'; import { useLocale } from "../hooks/useLocale";
import { GuessEphemeral } from '../stores/guessEphemeral'; import { Guess, GuessState } from "../stores/guess";
import { classIcons } from './ClassIcon'; import { classIcons } from "./ClassIcon";
import { SearchResults } from './SearchResults'; import { SearchResults } from "./SearchResults";
const { go } = fuzzysort; const { go } = fuzzysort;
@@ -38,16 +38,15 @@ const [tankNames, tankDefinitions] = await Promise.all([
const ids = Object.keys(tankDefinitions.tanks); const ids = Object.keys(tankDefinitions.tanks);
export function Guesser() { export function Guesser() {
const tank = GuessEphemeral.use((state) => state.tank); const tank = Guess.use((state) => state.tank);
const guessState = GuessEphemeral.use((state) => state.guessState); const guessState = Guess.use((state) => state.guessState);
const correctGuesses = GuessEphemeral.use((state) => state.correctGuesses); const correctGuesses = Guess.use((state) => state.correctGuesses);
const totalGuesses = GuessEphemeral.use((state) => state.totalGuesses); const totalGuesses = Guess.use((state) => state.totalGuesses);
const { strings, unwrap } = useLocale(); const { strings, unwrap } = useLocale();
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [results, setResults] = useState<TankDefinition[] | null>(null); const [results, setResults] = useState<TankDefinition[] | null>(null);
const [selected, setSelected] = useState<TankDefinition | null>(null); const [selected, setSelected] = useState<TankDefinition | null>(null);
const mutateGuessEphemeral = GuessEphemeral.useMutation();
const requestSearch = useCallback(() => { const requestSearch = useCallback(() => {
setSelected(null); setSelected(null);
@@ -72,14 +71,14 @@ export function Guesser() {
}); });
setResults( setResults(
searchResults.map((result) => tankDefinitions.tanks[result.obj.id]), searchResults.map((result) => tankDefinitions.tanks[result.obj.id])
); );
}, 500), }, 500),
[], []
); );
useEffect(() => { useEffect(() => {
if (guessState !== null) setSelected(null); if (guessState !== GuessState.NotGuessed) setSelected(null);
}, [guessState]); }, [guessState]);
return ( return (
@@ -88,7 +87,7 @@ export function Guesser() {
position="absolute" position="absolute"
bottom="0" bottom="0"
left="50%" left="50%"
style={{ transform: 'translateX(-50%)' }} style={{ transform: "translateX(-50%)" }}
width="100%" width="100%"
p="4" p="4"
maxWidth="25rem" maxWidth="25rem"
@@ -138,7 +137,7 @@ export function Guesser() {
</Card> </Card>
)} )}
<Flex justify="center"> <Flex justify="center" style={{ userSelect: "none" }}>
<Text> <Text>
{literals(strings.website.tools.guess.stats, [ {literals(strings.website.tools.guess.stats, [
`${correctGuesses}`, `${correctGuesses}`,
@@ -149,7 +148,7 @@ export function Guesser() {
<Flex gap="3"> <Flex gap="3">
<TextField.Root <TextField.Root
disabled={guessState !== null} disabled={guessState !== GuessState.NotGuessed}
ref={input} ref={input}
onChange={requestSearch} onChange={requestSearch}
style={{ flex: 1 }} style={{ flex: 1 }}
@@ -164,13 +163,19 @@ export function Guesser() {
<Button <Button
size="3" size="3"
color={guessState === null && selected === null ? 'red' : undefined} color={
guessState === GuessState.NotGuessed && selected === null
? "red"
: undefined
}
onClick={() => { onClick={() => {
if (guessState === null) { if (guessState === GuessState.NotGuessed) {
const correct = selected !== null && selected.id === tank.id; const correct = selected !== null && selected.id === tank.id;
mutateGuessEphemeral((draft) => { Guess.mutate((draft) => {
draft.guessState = correct; draft.guessState = correct
? GuessState.Correct
: GuessState.Incorrect;
draft.totalGuesses++; draft.totalGuesses++;
draft.correctGuesses += correct ? 1 : 0; draft.correctGuesses += correct ? 1 : 0;
}); });
@@ -178,21 +183,22 @@ export function Guesser() {
const id = Number(ids[Math.floor(Math.random() * ids.length)]); const id = Number(ids[Math.floor(Math.random() * ids.length)]);
const tank = tankDefinitions.tanks[id]; const tank = tankDefinitions.tanks[id];
mutateGuessEphemeral((draft) => { Guess.mutate((draft) => {
if (!input.current) return; if (!input.current) return;
draft.tank = tank; draft.tank = tank;
draft.guessState = null; draft.guessState = GuessState.NotGuessed;
input.current.value = '';
input.current.value = "";
}); });
} }
}} }}
> >
{guessState === null ? ( {guessState === GuessState.NotGuessed ? (
<> <>
{ {
strings.website.tools.guess.search[ strings.website.tools.guess.search[
selected === null ? 'skip' : 'guess' selected === null ? "skip" : "guess"
] ]
} }
{selected === null ? <ArrowRightIcon /> : <PaperPlaneIcon />} {selected === null ? <ArrowRightIcon /> : <PaperPlaneIcon />}
@@ -0,0 +1,21 @@
import type { SpotLightProps } from '@react-three/fiber';
import { memo } from 'react';
interface Props extends SpotLightProps {
debug?: boolean;
}
export const HelpingSpotLight = memo<Props>(({ debug, ...props }) => {
return (
<>
{debug && (
<mesh position={props.position}>
<icosahedronGeometry args={[0.1, 2]} />
<meshBasicMaterial color={props.color} />
</mesh>
)}
<spotLight {...props} />
</>
);
});
@@ -1,15 +1,14 @@
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { EyeOpenIcon } from '@radix-ui/react-icons'; import { EyeOpenIcon } from "@radix-ui/react-icons";
import { Box, Flex, Heading, Text } from '@radix-ui/themes'; import { Box, Flex, Heading, Text } from "@radix-ui/themes";
import { awaitablePopularTanks } from '../core/awaitables/popularTanks'; import { awaitablePopularTanks } from "../core/awaitables/popularTanks";
import { awaitableTankDefinitions } from '../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../core/awaitables/tankDefinitions";
import { import {
LocaleProvider, LocaleProvider,
useLocale, useLocale,
type LocaleAcceptorProps, type LocaleAcceptorProps,
} from '../hooks/useLocale'; } from "../hooks/useLocale";
import { TankopediaPersistent } from '../stores/tankopediaPersistent'; import { TankCard } from "./TankCard";
import { TankCard } from './TankCard';
const [tankDefinitions, popularTanks] = await Promise.all([ const [tankDefinitions, popularTanks] = await Promise.all([
awaitableTankDefinitions, awaitableTankDefinitions,
@@ -19,9 +18,7 @@ const [tankDefinitions, popularTanks] = await Promise.all([
export function HomePageHotTanks({ locale }: LocaleAcceptorProps) { export function HomePageHotTanks({ locale }: LocaleAcceptorProps) {
return ( return (
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<TankopediaPersistent.Provider> <Content />
<Content />
</TankopediaPersistent.Provider>
</LocaleProvider> </LocaleProvider>
); );
} }
@@ -35,7 +32,7 @@ function Content() {
{strings.website.home.hot_tanks.title} {strings.website.home.hot_tanks.title}
</Heading> </Heading>
<Text color="gray" align="center" mb="5" size="2"> <Text color="gray" align="center" mb="5" size="2">
{literals(strings.website.home.hot_tanks.subtitle, ['7'])} {literals(strings.website.home.hot_tanks.subtitle, ["7"])}
</Text> </Text>
<Flex justify="center" gap="4" wrap="wrap"> <Flex justify="center" gap="4" wrap="wrap">
@@ -1,10 +1,15 @@
import { Link, Table } from '@radix-ui/themes'; import { Link, Table } from '@radix-ui/themes';
import MarkdownToJSX from 'markdown-to-jsx'; import MarkdownToJSX from 'markdown-to-jsx';
import type { ComponentProps } from 'react';
interface MarkdownProps { interface MarkdownProps {
children: string; children: string;
} }
function Image(props: ComponentProps<'img'>) {
return <img style={{ maxWidth: '100%' }} {...props} />;
}
export function Markdown({ children }: MarkdownProps) { export function Markdown({ children }: MarkdownProps) {
return ( return (
<MarkdownToJSX <MarkdownToJSX
@@ -17,6 +22,7 @@ export function Markdown({ children }: MarkdownProps) {
tr: { component: Table.Row }, tr: { component: Table.Row },
th: { component: Table.ColumnHeaderCell }, th: { component: Table.ColumnHeaderCell },
td: { component: Table.Cell }, td: { component: Table.Cell },
img: { component: Image },
}, },
}} }}
> >
@@ -0,0 +1,13 @@
import { Progress } from "@radix-ui/themes";
import { Html } from "@react-three/drei";
import { useStableProgress } from "../../../hooks/useStableProgress";
export function MixerFallback() {
const progress = useStableProgress();
return (
<Html center>
<Progress value={progress} style={{ width: "10rem" }} />
</Html>
);
}
@@ -0,0 +1,149 @@
import { Html } from "@react-three/drei";
import { awaitableModelDefinitions } from "../../../core/awaitables/modelDefinitions";
import { jsxTree } from "../../../core/blitzkit/jsxTree";
import { useModel } from "../../../hooks/useModel";
import { Mixer } from "../../../stores/mixer";
import { Selector } from "./Selector";
const modelDefinitions = await awaitableModelDefinitions;
export function Model() {
const hull = Mixer.use((state) => state.hull);
const turret = Mixer.use((state) => state.turret);
const gun = Mixer.use((state) => state.gun);
const hullGltf = useModel(hull.id);
const turretGltf = useModel(turret.tank.id);
const gunGltf = useModel(gun.tank.id);
const hullNodes = Object.values(hullGltf.gltf.nodes);
const turretNodes = Object.values(turretGltf.gltf.nodes);
const gunNodes = Object.values(gunGltf.gltf.nodes);
const tankModel = modelDefinitions.models[hull.id];
const trackModel = tankModel.tracks[hull.tracks.at(-1)!.id];
const turretTankModel = modelDefinitions.models[turret.tank.id];
const turretTrackModel =
turretTankModel.tracks[turret.tank.tracks.at(-1)!.id];
const turretModel = turretTankModel.turrets[turret.turret.id];
const gunTankModel = modelDefinitions.models[gun.tank.id];
const gunTrackModel = gunTankModel.tracks[gun.tank.tracks.at(-1)!.id];
const gunTurretModel = gunTankModel.turrets[gun.turret.id];
const gunModel = gunTurretModel.guns[gun.gun.id];
return (
<group rotation={[-Math.PI / 2, 0, 0]}>
{hullNodes.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;
return jsxTree(node, {
mesh(_, props, key) {
return <mesh {...props} key={key} castShadow receiveShadow />;
},
});
})}
<Html
position={[
0,
trackModel.origin.z + tankModel.turret_origin.z,
(trackModel.origin.y + tankModel.turret_origin.y) / 2,
]}
>
<Selector>test hull</Selector>
</Html>
<Html
position={[
0,
trackModel.origin.z + tankModel.turret_origin.z,
trackModel.origin.y +
tankModel.turret_origin.y +
turretModel.gun_origin.y,
]}
>
<Selector>test turret</Selector>
</Html>
<group
position={[
tankModel.turret_origin.x +
trackModel.origin.x -
turretTankModel.turret_origin.x -
turretTrackModel.origin.x,
tankModel.turret_origin.z +
trackModel.origin.z -
turretTankModel.turret_origin.z -
turretTrackModel.origin.z,
tankModel.turret_origin.y +
trackModel.origin.y -
turretTankModel.turret_origin.y -
turretTrackModel.origin.y,
]}
>
{turretNodes.map((node) => {
const isCurrentTurret =
node.name ===
`turret_${turretModel.model_id.toString().padStart(2, "0")}`;
const isVisible = isCurrentTurret;
if (!isVisible) return null;
return jsxTree(node, {
mesh(_, props, key) {
return <mesh {...props} key={key} castShadow receiveShadow />;
},
});
})}
<group
position={[
turretTankModel.turret_origin.x +
turretTrackModel.origin.x +
turretModel.gun_origin.x -
gunTankModel.turret_origin.x -
gunTrackModel.origin.x -
gunTurretModel.gun_origin.x,
turretTankModel.turret_origin.z +
turretTrackModel.origin.z +
turretModel.gun_origin.z -
gunTankModel.turret_origin.z -
gunTrackModel.origin.z -
gunTurretModel.gun_origin.z,
turretTankModel.turret_origin.y +
turretTrackModel.origin.y +
turretModel.gun_origin.y -
gunTankModel.turret_origin.y -
gunTrackModel.origin.y -
gunTurretModel.gun_origin.y,
]}
>
{gunNodes.map((node) => {
const isCurrentMantlet =
node.name ===
`gun_${gunModel.model_id.toString().padStart(2, "0")}_mask`;
const isCurrentGun =
node.name ===
`gun_${gunModel.model_id.toString().padStart(2, "0")}`;
const isVisible = isCurrentGun || isCurrentMantlet;
if (!isVisible) return null;
return jsxTree(node, {
mesh(_, props, key) {
return <mesh {...props} key={key} castShadow receiveShadow />;
},
});
})}
</group>
</group>
</group>
);
}
@@ -0,0 +1,28 @@
import { Box, Flex, Text } from "@radix-ui/themes";
import type { ReactNode } from "react";
interface Props {
children: ReactNode;
}
export function Selector({ children }: Props) {
return (
<Flex align="center">
<Box
width="16rem"
height="1px"
style={{ backgroundColor: "var(--gray-12)" }}
/>
<Box
style={{
padding: "var(--space-3)",
backgroundColor: "var(--gray-3)",
borderRadius: "var(--radius-3)",
boxShadow: "var(--shadow-3)",
}}
>
<Text style={{ whiteSpace: "nowrap" }}>{children}</Text>
</Box>
</Flex>
);
}
@@ -0,0 +1,34 @@
import { OrbitControls } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { Suspense } from "react";
import { Lighting } from "../Tankopedia/HeroSection/components/TankSandbox/components/Lighting";
import { SceneProps } from "../Tankopedia/HeroSection/components/TankSandbox/components/SceneProps";
import { MixerFallback } from "./components/MixerFallback";
import { Model } from "./components/Model";
export function MixerScene() {
return (
<Canvas
shadows="soft"
style={{ background: "black" }}
camera={{
fov: 45,
position: [-4, 4, -7],
}}
>
<OrbitControls
maxDistance={20}
minDistance={5}
enableDamping={false}
target={[0, 0.5, 0]}
/>
<Lighting />
<SceneProps />
<Suspense fallback={<MixerFallback />}>
<Model />
</Suspense>
</Canvas>
);
}
@@ -62,7 +62,7 @@ export function ModuleManager({
const draft = { ...modules }; const draft = { ...modules };
draft.turret = turret; draft.turret = turret;
draft.gun = draft.turret.guns.at(-1)!; draft.gun = draft.turret.guns.at(-1)!;
draft.shell = draft.gun.gun_type!.value.base.shells[0]; draft.shell = draft.gun.shells[0];
onChange?.(draft); onChange?.(draft);
setTurretMenuOpen(false); setTurretMenuOpen(false);
@@ -83,9 +83,7 @@ export function ModuleManager({
<ModuleButton <ModuleButton
module="gun" module="gun"
discriminator={ discriminator={TIER_ROMAN_NUMERALS[modules.gun.tier]}
TIER_ROMAN_NUMERALS[modules.gun.gun_type!.value.base.tier]
}
onClick={() => setGunMenuOpen(true)} onClick={() => setGunMenuOpen(true)}
/> />
@@ -95,18 +93,16 @@ export function ModuleManager({
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.RadioGroup <DropdownMenu.RadioGroup value={`${modules.gun.id}`}>
value={`${modules.gun.gun_type!.value.base.id}`}
>
{[...modules.turret.guns].reverse().map((gun) => { {[...modules.turret.guns].reverse().map((gun) => {
return ( return (
<DropdownMenu.RadioItem <DropdownMenu.RadioItem
key={gun.gun_type!.value.base.id} key={gun.id}
value={`${gun.gun_type!.value.base.id}`} value={`${gun.id}`}
onClick={() => { onClick={() => {
const draft = { ...modules }; const draft = { ...modules };
draft.gun = gun; draft.gun = gun;
draft.shell = draft.gun.gun_type!.value.base.shells[0]; draft.shell = draft.gun.shells[0];
onChange?.(draft); onChange?.(draft);
setGunMenuOpen(false); setGunMenuOpen(false);
@@ -115,11 +111,9 @@ export function ModuleManager({
<ModuleButton <ModuleButton
variant="ghost" variant="ghost"
module="gun" module="gun"
discriminator={ discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
TIER_ROMAN_NUMERALS[gun.gun_type!.value.base.tier]
}
/>{' '} />{' '}
{unwrap(gun.gun_type!.value.base.name)} {unwrap(gun.name)}
</DropdownMenu.RadioItem> </DropdownMenu.RadioItem>
); );
})} })}
+45 -62
View File
@@ -1,24 +1,25 @@
--- ---
import { assertSecret } from '@blitzkit/core';
import { import {
Cross1Icon, Cross1Icon,
DiscordLogoIcon, DiscordLogoIcon,
GearIcon, GearIcon,
HamburgerMenuIcon, HamburgerMenuIcon,
} from '@radix-ui/react-icons'; } from "@radix-ui/react-icons";
import { Box, Code, Flex, IconButton, Link } from '@radix-ui/themes'; import { Box, Code, Flex, IconButton, Link } from "@radix-ui/themes";
import { TOOLS } from '../constants/tools'; import { resolveBranchName } from "../core/blitzkit/branches";
import { resolveBranchName } from '../core/blitzkit/branches'; import { getStrings } from "../core/i18n/getStrings";
import { getStrings } from '../core/i18n/getStrings'; import { Var } from "../core/radix/var";
import { Var } from '../core/radix/var'; import { LinkI18n } from "./LinkI18n";
import { LinkI18n } from './LinkI18n'; import { tools } from "../constants/tools";
export interface Props { export interface Props {
integrated?: boolean; integrated?: boolean;
} }
const { integrated = false } = Astro.props; const { integrated = false } = Astro.props;
const toolsFiltered = TOOLS.filter((tool) => !('href' in tool)); const toolsFiltered = Object.values(tools).filter(
(tool) => !tool.href && !tool.disabled
);
const { locale } = Astro.params; const { locale } = Astro.params;
const branchName = resolveBranchName(locale); const branchName = resolveBranchName(locale);
const isBranchNamed = branchName !== undefined; const isBranchNamed = branchName !== undefined;
@@ -39,49 +40,45 @@ const strings = getStrings(locale);
height: 0; height: 0;
} }
.navbar[data-expanded='false'] *[data-expanded] { .navbar[data-expanded="false"] *[data-expanded] {
display: none; display: none;
} }
.navbar[data-expanded='true'] { .navbar[data-expanded="true"] {
height: 100%; height: 100%;
} }
.navbar[data-expanded='false'][data-integrated='false'] .content { .navbar[data-expanded="false"][data-integrated="false"] .content {
background-color: var(--black-a8); background-color: var(--black-a8);
backdrop-filter: blur(4rem); backdrop-filter: blur(4rem);
box-shadow: var(--shadow-3); box-shadow: var(--shadow-3);
} }
.navbar[data-expanded='true'] .content { .navbar[data-expanded="true"] .content {
background-color: var(--gray-1); background-color: var(--gray-1);
} }
.navbar[data-expanded='true'] *[data-closed] { .navbar[data-expanded="true"] *[data-closed] {
display: none; display: none;
} }
</style> </style>
<script> <script>
const navbar = document.querySelector<HTMLDivElement>('.navbar'); const navbar = document.querySelector<HTMLDivElement>(".navbar");
const hamburger = document.querySelector<HTMLButtonElement>('.hamburger'); const hamburger = document.querySelector<HTMLButtonElement>(".hamburger");
navbar?.addEventListener('click', () => { navbar?.addEventListener("click", () => {
navbar.dataset.expanded = 'false'; navbar.dataset.expanded = "false";
}); });
hamburger?.addEventListener('click', (event) => { hamburger?.addEventListener("click", (event) => {
if (!navbar) return; if (!navbar) return;
event.stopPropagation(); event.stopPropagation();
navbar.dataset.expanded = `${navbar.dataset.expanded === 'false'}`; navbar.dataset.expanded = `${navbar.dataset.expanded === "false"}`;
}); });
</script> </script>
{ <Box className="padder" height={{ initial: "3.5rem", sm: "3rem" }} />
!integrated && (
<Box className="padder" height={{ initial: '3.5rem', sm: '3rem' }} />
)
}
<Flex <Flex
direction="column" direction="column"
@@ -94,16 +91,16 @@ const strings = getStrings(locale);
position="fixed" position="fixed"
style={{ style={{
zIndex: 1, zIndex: 1,
backdropFilter: 'blur(4rem)', backdropFilter: "blur(4rem)",
}} }}
> >
<Flex <Flex
className="content" className="content"
width="100%" width="100%"
p={{ initial: '4', sm: '3' }} p={{ initial: "4", sm: "3" }}
direction="column" direction="column"
align="center" align="center"
style={{ backgroundColor: integrated ? undefined : Var('gray-1') }} style={{ backgroundColor: integrated ? undefined : Var("gray-1") }}
> >
<Flex gap="4" direction="column" maxWidth="80rem" width="100%"> <Flex gap="4" direction="column" maxWidth="80rem" width="100%">
<Flex align="center"> <Flex align="center">
@@ -131,33 +128,27 @@ const strings = getStrings(locale);
<Flex <Flex
align="center" align="center"
gap={{ initial: '4', md: '6' }} gap={{ initial: "3", md: "6" }}
display={{ initial: 'none', sm: 'flex' }} display={{ initial: "none", sm: "flex" }}
justify="center" justify="center"
className="tools" className="tools"
> >
{ {
toolsFiltered.map((tool) => { toolsFiltered.map((tool) => {
const unavailableOnBranch = tool.branches?.every(
(branch) =>
branch !== assertSecret(import.meta.env.PUBLIC_BRANCH),
);
const selected = Astro.url.pathname.includes(`/${tool.id}`); const selected = Astro.url.pathname.includes(`/${tool.id}`);
const toolStrings = const toolStrings =
strings.website.tools[ strings.website.tools[
(tool.stringsId ?? (tool.strings ??
tool.id) as keyof typeof strings.website.tools tool.id) as keyof typeof strings.website.tools
]; ];
if (unavailableOnBranch) return null;
return ( return (
<LinkI18n <LinkI18n
{locale} {locale}
weight={selected ? 'medium' : 'regular'} weight={selected ? "medium" : "regular"}
color="gray" color="gray"
highContrast={selected} highContrast={selected}
size="2" size={{ initial: "1", md: "2" }}
href={`/${tool.id}`} href={`/${tool.id}`}
underline="hover" underline="hover"
> >
@@ -172,7 +163,7 @@ const strings = getStrings(locale);
<Flex align="center" gap="4"> <Flex align="center" gap="4">
<Link <Link
style={{ display: 'flex', alignContent: 'center' }} style={{ display: "flex", alignContent: "center" }}
color="gray" color="gray"
href="https://discord.gg/nDt7AjGJQH" href="https://discord.gg/nDt7AjGJQH"
underline="none" underline="none"
@@ -183,7 +174,7 @@ const strings = getStrings(locale);
<LinkI18n <LinkI18n
{locale} {locale}
style={{ display: 'flex', alignContent: 'center' }} style={{ display: "flex", alignContent: "center" }}
color="gray" color="gray"
href="/settings" href="/settings"
underline="none" underline="none"
@@ -193,7 +184,7 @@ const strings = getStrings(locale);
<!-- <NavbarAccount client:idle /> --> <!-- <NavbarAccount client:idle /> -->
<Flex align="center" display={{ initial: 'flex', sm: 'none' }}> <Flex align="center" display={{ initial: "flex", sm: "none" }}>
<IconButton variant="ghost" color="gray" className="hamburger"> <IconButton variant="ghost" color="gray" className="hamburger">
<Cross1Icon data-expanded /> <Cross1Icon data-expanded />
<HamburgerMenuIcon data-closed /> <HamburgerMenuIcon data-closed />
@@ -206,36 +197,28 @@ const strings = getStrings(locale);
direction="column" direction="column"
data-expanded data-expanded
gap="3" gap="3"
display={{ initial: 'flex', sm: 'none' }} display={{ initial: "flex", sm: "none" }}
> >
{ {
toolsFiltered.map((tool) => { toolsFiltered.map(( tool) => {
const unavailableOnBranch = tool.branches?.every(
(branch) =>
branch !== assertSecret(import.meta.env.PUBLIC_BRANCH),
);
if (unavailableOnBranch) return null;
const toolStrings = const toolStrings =
strings.website.tools[ strings.website.tools[
(tool.stringsId ?? (tool.strings ?? tool.id) as keyof typeof strings.website.tools
tool.id) as keyof typeof strings.website.tools
]; ];
return ( return (
<Link <Link
color="gray" color="gray"
highContrast highContrast
style={{ display: 'contents' }} style={{ display: "contents" }}
href={`/${tool.id}`} href={`/${tool.id}`}
> >
<Box <Box
style={{ style={{
borderRadius: Var('radius-2'), borderRadius: Var("radius-2"),
backgroundImage: `url(/assets/images/tools/${tool.id}.webp)`, backgroundImage: `url(/assets/images/tools/${tool.id}.webp)`,
backgroundSize: '200%', backgroundSize: "200%",
backgroundPosition: 'right center', backgroundPosition: "right center",
}} }}
> >
<Flex <Flex
@@ -243,7 +226,7 @@ const strings = getStrings(locale);
justify="end" justify="end"
flexGrow="1" flexGrow="1"
style={{ style={{
background: `linear-gradient(90deg, transparent, ${Var('gray-1')} 90%)`, background: `linear-gradient(90deg, transparent, ${Var("gray-1")} 90%)`,
}} }}
> >
{toolStrings.name} {toolStrings.name}
@@ -261,11 +244,11 @@ const strings = getStrings(locale);
data-expanded data-expanded
flexGrow="1" flexGrow="1"
width="100%" width="100%"
display={{ initial: 'block', sm: 'none' }} display={{ initial: "block", sm: "none" }}
style={{ style={{
backgroundColor: Var('black-a7'), backgroundColor: Var("black-a7"),
backdropFilter: 'blur(0.5rem)', backdropFilter: "blur(0.5rem)",
WebkitBackdropFilter: 'blur(0.5rem)', WebkitBackdropFilter: "blur(0.5rem)",
}} }}
/> />
</Flex> </Flex>
@@ -1,4 +1,4 @@
import { PersonIcon } from '@radix-ui/react-icons'; import { PersonIcon } from "@radix-ui/react-icons";
import { import {
Button, Button,
Flex, Flex,
@@ -6,91 +6,78 @@ import {
Popover, Popover,
Skeleton, Skeleton,
Text, Text,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { Suspense } from 'react'; import { Suspense } from "react";
import { App } from '../../stores/app'; import { App } from "../../stores/app";
import { BlitzKitTheme } from '../BlitzKitTheme'; import { BlitzKitTheme } from "../BlitzKitTheme";
import { WargamingAccountName } from '../WargamingAccountName'; import { WargamingAccountName } from "../WargamingAccountName";
import { WargamingIcon } from '../WargamingIcon'; import { WargamingIcon } from "../WargamingIcon";
import { WargamingLoginButton } from '../WargamingLoginButton'; import { WargamingLoginButton } from "../WargamingLoginButton";
export function NavbarAccount() { export function NavbarAccount() {
return (
<App.Provider>
<Content />
</App.Provider>
);
}
function Content() {
const logins = App.use((state) => state.logins); const logins = App.use((state) => state.logins);
const mutateApp = App.useMutation();
return ( return (
<App.Provider> <Popover.Root>
<Popover.Root> <Popover.Trigger>
<Popover.Trigger> <IconButton variant="ghost" color="gray">
<IconButton variant="ghost" color="gray"> <PersonIcon />
<PersonIcon /> </IconButton>
</IconButton> </Popover.Trigger>
</Popover.Trigger>
<BlitzKitTheme style={{ display: 'contents' }}> <BlitzKitTheme style={{ display: "contents" }}>
<Popover.Content <Popover.Content
align="end" align="end"
width="320px" width="320px"
onOpenAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()}
> >
{logins.wargaming && ( {logins.wargaming && (
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Text align="center" color="gray"> <Text align="center" color="gray">
Logged in accounts Logged in accounts
</Text> </Text>
{logins.wargaming && ( {logins.wargaming && (
<Flex align="center" gap="2" justify="center"> <Flex align="center" gap="2" justify="center">
<WargamingIcon width={15} height={15} /> <WargamingIcon width={15} height={15} />
<Text> <Text>
<Suspense <Suspense fallback={<Skeleton height="1em" width="5rem" />}>
fallback={<Skeleton height="1em" width="5rem" />} <WargamingAccountName />
> </Suspense>
<WargamingAccountName /> </Text>
</Suspense> <Button
</Text> color="red"
<Button variant="ghost"
color="red" onClick={() => {
variant="ghost" App.mutate((draft) => {
onClick={() => { draft.logins.wargaming = undefined;
mutateApp((draft) => { });
draft.logins.wargaming = undefined; }}
}); >
}} Logout
> </Button>
Logout </Flex>
</Button> )}
</Flex> </Flex>
)} )}
</Flex>
)}
{/* {!logins. !== !logins.wargaming && ( {/* {!logins. !== !logins.wargaming && (
<Inset side="x"> <Inset side="x">
<Separator my="4" size="4" /> <Separator my="4" size="4" />
</Inset> </Inset>
)} */} )} */}
{!logins.wargaming && ( {!logins.wargaming && (
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Text align="center" color="gray"> <Text align="center" color="gray">
Log in with... Log in with...
</Text> </Text>
{!logins.wargaming && <WargamingLoginButton />} {!logins.wargaming && <WargamingLoginButton />}
</Flex> </Flex>
)} )}
</Popover.Content> </Popover.Content>
</BlitzKitTheme> </BlitzKitTheme>
</Popover.Root> </Popover.Root>
</App.Provider>
); );
} }
@@ -2,16 +2,15 @@ import {
CaretDownIcon, CaretDownIcon,
CaretSortIcon, CaretSortIcon,
CaretUpIcon, CaretUpIcon,
} from '@radix-ui/react-icons'; } from "@radix-ui/react-icons";
import { Button, Flex, Table } from '@radix-ui/themes'; import { Button, Flex, Table } from "@radix-ui/themes";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { TankPerformanceSort } from '../../stores/tankPerformanceSort'; import { TankPerformanceSort } from "../../stores/tankPerformanceSort";
import { TankPerformanceSortTypeNamesArray } from '../../stores/tankPerformanceSort/constants'; import { TankPerformanceSortTypeNamesArray } from "../../stores/tankPerformanceSort/constants";
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell'; import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
export function Header() { export function Header() {
const sort = TankPerformanceSort.use(); const sort = TankPerformanceSort.use();
const store = TankPerformanceSort.useStore();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -34,13 +33,18 @@ export function Header() {
<Flex align="center" gap="1"> <Flex align="center" gap="1">
<Button <Button
variant="ghost" variant="ghost"
color={isSelected ? undefined : 'gray'} color={isSelected ? undefined : "gray"}
highContrast={!isSelected} highContrast={!isSelected}
onClick={() => { onClick={() => {
if (isSelected) { if (isSelected) {
store.setState({ direction: -sort.direction as 1 | -1 }); TankPerformanceSort.mutate((draft) => {
draft.direction *= -1;
});
} else { } else {
store.setState({ type, direction: -1 }); TankPerformanceSort.mutate((draft) => {
draft.type = type;
draft.direction = -1;
});
} }
}} }}
> >
@@ -1,15 +1,9 @@
import { Flex, Select, Text } from '@radix-ui/themes'; import { Flex, Select, Text } from '@radix-ui/themes';
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from '../../hooks/useLocale';
import { import { Performance, type PlayerCountPeriod } from '../../stores/performance';
TankPerformanceEphemeral,
type PlayerCountPeriod,
} from '../../stores/tankPerformanceEphemeral';
export function PlayerCountControl() { export function PlayerCountControl() {
const playerCountPeriod = TankPerformanceEphemeral.use( const playerCountPeriod = Performance.use((state) => state.playerCountPeriod);
(state) => state.playerCountPeriod,
);
const mutateTankPerformanceSort = TankPerformanceEphemeral.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -18,7 +12,7 @@ export function PlayerCountControl() {
<Select.Root <Select.Root
value={playerCountPeriod} value={playerCountPeriod}
onValueChange={(period) => { onValueChange={(period) => {
mutateTankPerformanceSort((draft) => { Performance.mutate((draft) => {
draft.playerCountPeriod = period as PlayerCountPeriod; draft.playerCountPeriod = period as PlayerCountPeriod;
}); });
}} }}
@@ -4,7 +4,7 @@ import { memo } from 'react';
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions'; import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions';
import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio'; import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio';
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from '../../hooks/useLocale';
import { TankPerformanceEphemeral } from '../../stores/tankPerformanceEphemeral'; import { Performance } from '../../stores/performance';
import { TankRowHeaderCell } from '../TankRowHeaderCell'; import { TankRowHeaderCell } from '../TankRowHeaderCell';
interface TankRowProps { interface TankRowProps {
@@ -17,7 +17,7 @@ export const TankRow = memo<TankRowProps>(
({ tank }) => { ({ tank }) => {
const averages = averageDefinitions.averages[tank.id]; const averages = averageDefinitions.averages[tank.id];
const ratio = useAveragesExclusionRatio(); const ratio = useAveragesExclusionRatio();
const playerCountPeriod = TankPerformanceEphemeral.use( const playerCountPeriod = Performance.use(
(state) => state.playerCountPeriod, (state) => state.playerCountPeriod,
); );
const { locale } = useLocale(); const { locale } = useLocale();
@@ -1,17 +1,16 @@
import { useStore } from '@nanostores/react'; import { Table } from "@radix-ui/themes";
import { Table } from '@radix-ui/themes'; import { times } from "lodash-es";
import { times } from 'lodash-es'; import { Fragment, Suspense, useEffect, useMemo, useState } from "react";
import { Fragment, Suspense, useEffect, useMemo, useState } from 'react'; import { awaitableAverageDefinitions } from "../../core/awaitables/averageDefinitions";
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions'; import { awaitableTankDefinitions } from "../../core/awaitables/tankDefinitions";
import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions'; import { filterTank } from "../../core/blitzkit/filterTank";
import { filterTank } from '../../core/blitzkit/filterTank'; import { Performance } from "../../stores/performance";
import { $tankFilters } from '../../stores/tankFilters'; import { TankFilters } from "../../stores/tankFilters";
import { TankPerformanceEphemeral } from '../../stores/tankPerformanceEphemeral'; import { TankPerformanceSort } from "../../stores/tankPerformanceSort";
import { TankPerformanceSort } from '../../stores/tankPerformanceSort'; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import { RowLoader } from "./RowLoader";
import { RowLoader } from './RowLoader'; import { TankRow } from "./TankRow";
import { TankRow } from './TankRow'; import { Total } from "./Total";
import { Total } from './Total';
const PREVIEW_COUNT = 10; const PREVIEW_COUNT = 10;
const DEFAULT_LOADED_ROWS = 25; const DEFAULT_LOADED_ROWS = 25;
@@ -25,7 +24,7 @@ const averageDefinitionsArray = Object.entries(averageDefinitions.averages).map(
([id, average]) => ({ ([id, average]) => ({
id: Number(id), id: Number(id),
...average, ...average,
}), })
); );
export function Tanks({ skeleton }: MaybeSkeletonComponentProps) { export function Tanks({ skeleton }: MaybeSkeletonComponentProps) {
@@ -40,98 +39,97 @@ export function Tanks({ skeleton }: MaybeSkeletonComponentProps) {
} }
const sort = TankPerformanceSort.use(); const sort = TankPerformanceSort.use();
const tankPerformancePersistentStore = TankPerformanceEphemeral.useStore();
const filters = useStore($tankFilters);
const tanksSorted = useMemo(() => { const tanksSorted = useMemo(() => {
const { playerCountPeriod } = tankPerformancePersistentStore.getState(); const { playerCountPeriod } = Performance.state;
switch (sort.type) { switch (sort.type) {
case 'accuracy': case "accuracy":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * (a.mu.hits / a.mu.shots - b.mu.hits / b.mu.shots), sort.direction * (a.mu.hits / a.mu.shots - b.mu.hits / b.mu.shots)
); );
case 'capture_points': case "capture_points":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.capture_points / a.mu.battles - (a.mu.capture_points / a.mu.battles -
b.mu.capture_points / b.mu.battles), b.mu.capture_points / b.mu.battles)
); );
case 'damage': case "damage":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.damage_dealt / a.mu.battles - (a.mu.damage_dealt / a.mu.battles -
b.mu.damage_dealt / b.mu.battles), b.mu.damage_dealt / b.mu.battles)
); );
case 'damage_ratio': case "damage_ratio":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.damage_dealt / a.mu.damage_received - (a.mu.damage_dealt / a.mu.damage_received -
b.mu.damage_dealt / b.mu.damage_received), b.mu.damage_dealt / b.mu.damage_received)
); );
case 'damage_taken': case "damage_taken":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.damage_received / a.mu.battles - (a.mu.damage_received / a.mu.battles -
b.mu.damage_received / b.mu.battles), b.mu.damage_received / b.mu.battles)
); );
case 'hits': case "hits":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.hits / a.mu.battles - b.mu.hits / b.mu.battles), (a.mu.hits / a.mu.battles - b.mu.hits / b.mu.battles)
); );
case 'kills': case "kills":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.frags / a.mu.battles - b.mu.frags / b.mu.battles), (a.mu.frags / a.mu.battles - b.mu.frags / b.mu.battles)
); );
case 'shots': case "shots":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.shots / a.mu.battles - b.mu.shots / b.mu.battles), (a.mu.shots / a.mu.battles - b.mu.shots / b.mu.battles)
); );
case 'players': case "players":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.samples[playerCountPeriod] - b.samples[playerCountPeriod]), (a.samples[playerCountPeriod] - b.samples[playerCountPeriod])
); );
case 'spots': case "spots":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.spotted / a.mu.battles - b.mu.spotted / b.mu.battles), (a.mu.spotted / a.mu.battles - b.mu.spotted / b.mu.battles)
); );
case 'survival': case "survival":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.survived_battles / a.mu.battles - (a.mu.survived_battles / a.mu.battles -
b.mu.survived_battles / b.mu.battles), b.mu.survived_battles / b.mu.battles)
); );
case 'winrate': case "winrate":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.wins / a.mu.battles - b.mu.wins / b.mu.battles), (a.mu.wins / a.mu.battles - b.mu.wins / b.mu.battles)
); );
case 'xp': case "xp":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * (a.mu.xp / a.mu.battles - b.mu.xp / b.mu.battles), sort.direction * (a.mu.xp / a.mu.battles - b.mu.xp / b.mu.battles)
); );
} }
}, [sort]); }, [sort]);
const filters = TankFilters.use();
const tanks = useMemo(() => { const tanks = useMemo(() => {
return tanksSorted.filter((tank) => return tanksSorted.filter((tank) =>
filterTank(filters, tankDefinitions.tanks[tank.id]), filterTank(filters, tankDefinitions.tanks[tank.id])
); );
}, [filters, sort]); }, [filters, sort]);
const [loadedRows, setLoadedRows] = useState(DEFAULT_LOADED_ROWS); const [loadedRows, setLoadedRows] = useState(DEFAULT_LOADED_ROWS);
@@ -8,7 +8,7 @@ import { memo, useCallback } from 'react';
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions'; import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions';
import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio'; import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio';
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from '../../hooks/useLocale';
import { TankPerformanceEphemeral } from '../../stores/tankPerformanceEphemeral'; import { Performance } from '../../stores/performance';
import { StickyRowHeaderCell } from '../StickyRowHeaderCell'; import { StickyRowHeaderCell } from '../StickyRowHeaderCell';
interface AverageDefinitionsEntryWithId extends AverageDefinitionsEntry { interface AverageDefinitionsEntryWithId extends AverageDefinitionsEntry {
@@ -23,7 +23,7 @@ const averageDefinitions = await awaitableAverageDefinitions;
export const Total = memo<TotalProps>( export const Total = memo<TotalProps>(
({ tanks }) => { ({ tanks }) => {
const playerCountPeriod = TankPerformanceEphemeral.use( const playerCountPeriod = Performance.use(
(state) => state.playerCountPeriod, (state) => state.playerCountPeriod,
); );
const { strings, locale } = useLocale(); const { strings, locale } = useLocale();
+8 -17
View File
@@ -1,20 +1,11 @@
import { assertSecret } from '@blitzkit/core'; import { assertSecret } from "@blitzkit/core";
import { ArrowRightIcon } from '@radix-ui/react-icons'; import { ArrowRightIcon } from "@radix-ui/react-icons";
import { Button, Code, Flex, Link, Text } from '@radix-ui/themes'; import { Button, Code, Flex, Link, Text } from "@radix-ui/themes";
import { App } from '../stores/app';
export function Plugs() { export function Plugs() {
return (
<App.Provider>
<Content />
</App.Provider>
);
}
function Content() {
const promoteOpentest = const promoteOpentest =
assertSecret(import.meta.env.PUBLIC_PROMOTE_OPENTEST) === 'true' && assertSecret(import.meta.env.PUBLIC_PROMOTE_OPENTEST) === "true" &&
assertSecret(import.meta.env.PUBLIC_BRANCH) !== 'opentest'; assertSecret(import.meta.env.PUBLIC_BRANCH) !== "opentest";
const promotions = [promoteOpentest]; const promotions = [promoteOpentest];
if (promotions.every((promotion) => !promotion)) return null; if (promotions.every((promotion) => !promotion)) return null;
@@ -31,11 +22,11 @@ function Content() {
{promoteOpentest && ( {promoteOpentest && (
<Flex direction="column" gap="3" align="center"> <Flex direction="column" gap="3" align="center">
<Text size="5"> <Text size="5">
BlitzKit{' '} BlitzKit{" "}
<Code variant="outline" size="4" color="gray"> <Code variant="outline" size="4" color="gray">
opentest opentest
</Code>{' '} </Code>{" "}
is available now{' '} is available now{" "}
</Text> </Text>
<Link href="https://opentest.blitzkit.app/" target="_blank"> <Link href="https://opentest.blitzkit.app/" target="_blank">
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff. Show more