Compare commits

...
77 Commits
Author SHA1 Message Date
TrèsAbhi 12e90cc6ee feat: update dependencies 2025-09-26 14:51:49 -05:00
TrèsAbhi 01755a2a64 feat: start adding pwa 2025-09-26 14:46:07 -05:00
TrèsAbhi 080f0a0eb7 fix: make tankopedia hero section a bit smaller 2025-09-22 13:52:31 -05:00
TrèsAbhi a646cc1e57 feat: mixer buttons 2025-09-22 13:50:46 -05:00
TrèsAbhi d107813c37 feat: update closed 2025-09-22 13:27:38 -05:00
TrèsAbhi 27d3bbf346 feat: update closed 2025-09-22 13:20:47 -05:00
TrèsAbhi 50c77e0ae1 fix: seo 2025-09-18 10:43:38 -05:00
TrèsAbhi 6e0429332d feat: mixer tank selector 2025-09-18 10:41:42 -05:00
TrèsAbhi 7afce6cbcb fix: keys 2025-09-18 10:19:41 -05:00
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
219 changed files with 5029 additions and 4987 deletions

No files matched your search

+13
View File
@@ -22,6 +22,19 @@ env:
PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }}
PUBLIC_ASSET_FAST_USER: ${{ secrets.PUBLIC_ASSET_FAST_USER }}
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:
host:
-7
View File
@@ -1,7 +0,0 @@
{
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 80
}
+6 -7
View File
@@ -76,7 +76,9 @@
"echarts",
"Everytime",
"FASTNAME",
"favicons",
"flexdirection",
"fontsource",
"FOURCC",
"frameloop",
"fsformat",
@@ -154,6 +156,7 @@
"Pyogenics",
"ratingleaderboard",
"raycaster",
"Redirector",
"researchable",
"Resvg",
"rizz",
@@ -167,6 +170,7 @@
"searchtanks",
"SHAREDEXP",
"SKLLD",
"smoothstep",
"Smri",
"Specularity",
"SSAO",
@@ -222,12 +226,7 @@
"yolo",
"youtuber",
"youtubers",
"zoomable",
"zustand"
"zoomable"
],
"deno.enable": false,
"deno.unstable": false,
"cSpell.enableFiletypes": ["glsl", "proto3"],
"typescript.tsdk": "node_modules\\typescript\\lib",
"liveServer.settings.multiRootWorkspaceName": "blitzkit"
"typescript.tsdk": "node_modules\\typescript\\lib"
}
BIN
View File
Binary file not shown.
+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
+13 -9
View File
@@ -1,7 +1,7 @@
{
"name": "blitzkit",
"private": true,
"version": "1.27.17",
"version": "1.27.20",
"description": "🎉 Tools for everything World of Tanks Blitz",
"type": "module",
"types": "./types.d.ts",
@@ -16,6 +16,7 @@
"build:prisma": "prisma generate",
"build:sitemap": "bun scripts/sitemap.ts",
"build:docs": "vitepress build docs",
"build:proxyclient": "cd packages/scripts && bun run build:proxyclient",
"build:ratingleaderboard": "cd packages/scripts && bun run build:ratingleaderboard",
"dev:bot": "bun run build:bot --dev && bun start:bot",
"start:bot": "bun dist/bot/main.js",
@@ -41,19 +42,22 @@
},
"license": "MIT",
"dependencies": {
"@types/xmpp__client": "^0.13.3",
"@xmpp/client": "^0.13.6",
"astro-favicons": "^3.1.5",
"cheerio": "^1.1.2",
"googleapis": "^144.0.0",
"googleapis": "^160.0.0",
"lodash-es": "^4.17.21",
"protobufjs": "^7.5.2",
"protobufjs": "^7.5.4",
"quicklime": "^1.2.0",
"sharp": "^0.33.5"
"sharp": "^0.34.4"
},
"devDependencies": {
"@types/bun": "latest",
"@types/bun": "^1.2.22",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"prettier": "^3.5.3",
"prisma": "^6.6.0",
"typescript": "^5.8.3"
"@types/node": "^24.5.2",
"prettier": "^3.6.2",
"prisma": "^6.16.2",
"typescript": "^5.9.2"
}
}
+5 -5
View File
@@ -6,14 +6,14 @@
"dependencies": {
"@blitzkit/core": "workspace:*",
"@blitzkit/i18n": "workspace:*",
"satori": "^0.10.14",
"satori": "^0.18.3",
"@resvg/resvg-js": "^2.6.2",
"discord.js": "^14.16.3",
"react": "^18.3.1",
"discord.js": "^14.22.1",
"react": "^19.1.1",
"@radix-ui/colors": "^3.0.0",
"fuzzysort": "^2.0.4",
"fuzzysort": "^3.1.0",
"bepaint": "^0.1.0-beta.3",
"dotenv": "^16.4.5",
"dotenv": "^17.2.2",
"markdown-escape": "^2.0.0"
},
"scripts": {
+12 -14
View File
@@ -1,8 +1,8 @@
import { ReadStream } from '@blitzkit/core';
import AdmZip from 'adm-zip';
import { readFile, writeFile } from 'fs/promises';
import { Parser } from 'pickleparser';
import protobuf from 'protobufjs';
import { ReadStream } from "@blitzkit/core";
import AdmZip from "adm-zip";
import { readFile } from "fs/promises";
import { Parser } from "pickleparser";
import protobuf from "protobufjs";
interface WotbReplayMetaJson {
version: string;
@@ -24,26 +24,24 @@ export class WotbReplayReadStream extends ReadStream {
async wotbReplay() {
const pickleParser = new Parser();
const zip = new AdmZip(Buffer.from(this.buffer));
const metaJsonBuffer = zip.getEntry('meta.json')?.getData();
const battleResultsDat = zip.getEntry('battle_results.dat')?.getData();
const metaJsonBuffer = zip.getEntry("meta.json")?.getData();
const battleResultsDat = zip.getEntry("battle_results.dat")?.getData();
if (!metaJsonBuffer || !battleResultsDat)
throw new SyntaxError('meta.json not found');
throw new SyntaxError("meta.json not found");
const metaJson = JSON.parse(
metaJsonBuffer.toString(),
metaJsonBuffer.toString()
) as WotbReplayMetaJson;
const unpickledBattleResults = pickleParser.parse(battleResultsDat);
const [, battleResultsString] = unpickledBattleResults as [number, string];
const root = await protobuf.load(
`${__dirname}/../../protos/BattleResultsClient.proto`,
`${__dirname}/../../protos/BattleResultsClient.proto`
);
const BattleResults = root.lookupType(
'BattleResultsGenerated.BattleResultsClient',
"BattleResultsGenerated.BattleResultsClient"
);
writeFile('test.dat', Buffer.from(battleResultsString));
const reader = protobuf.Reader.create(Buffer.from(battleResultsString));
// 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);
stream.wotbReplay();
+8 -8
View File
@@ -4,20 +4,20 @@
"type": "module",
"main": "src/index.ts",
"dependencies": {
"@octokit/plugin-throttling": "^8.2.0",
"@octokit/rest": "^20.1.1",
"@prisma/client": "^6.6.0",
"@octokit/plugin-throttling": "^11.0.1",
"@octokit/rest": "^22.0.0",
"@prisma/client": "^6.16.2",
"adm-zip": "^0.5.16",
"crc": "^4.3.2",
"dotenv": "^16.4.5",
"dotenv": "^17.2.2",
"lz4js": "^0.2.0",
"pickleparser": "^0.2.1",
"protobufjs": "^7.4.0",
"ts-proto": "^2.2.2",
"yaml": "^2.5.0"
"protobufjs": "^7.5.4",
"ts-proto": "^2.7.7",
"yaml": "^2.8.1"
},
"devDependencies": {
"@types/adm-zip": "^0.5.5",
"@types/adm-zip": "^0.5.7",
"@types/lz4js": "^0.2.1"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"main": "src/index.ts",
"dependencies": {
"ts-deepmerge": "^7.0.2"
"ts-deepmerge": "^7.0.3"
},
"devDependencies": {}
}
+23 -14
View File
@@ -6,6 +6,8 @@
},
"common": {
"name": "BlitzKit",
"units": {
"m": "%s1m",
"mm": "%s1mm",
@@ -763,6 +765,8 @@
},
"tank_search": {
"select_dialog_title": "Search for tanks",
"swap_dialog_title": "Swap tank",
"clear_filters": "Clear filters",
"search_bar_hint": "Search tanks...",
"recent": "Recently viewed",
@@ -861,6 +865,15 @@
},
"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",
"screenshot": "Screenshot",
"randomize": "Randomize"
},
"guess": {
"name": "Guess the tank",
"button": "Guess away",
@@ -887,6 +900,10 @@
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -935,9 +952,7 @@
"green_penetration": "Green penetration",
"hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque",
"dev_wireframe": "DEV: wireframe",
"environment": "Environment",
"show_grid": "Show grid"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
@@ -1437,17 +1452,11 @@
"description": "Get started with BlitzKit",
"seo_description": "Get started with BlitzKit"
},
"blitz_analysis": {
"name": "BlitzAnalysis[]",
"button": "Analyze now",
"description": "Rich historic average stats",
"seo_description": "Rich historic average stats in 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"
"api": {
"name": "API",
"button": "To the docs",
"description": "BlitzKit hosted API",
"seo_description": "BlitzKit hosted static API for World of Tanks Blitz"
}
},
+10 -3
View File
@@ -764,6 +764,12 @@
}
},
"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": {
"name": "Guess the tank",
"button": "Guess away",
@@ -787,6 +793,9 @@
"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",
"visualizers": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -829,9 +838,7 @@
"green_penetration": "Penetración verde",
"hide_model_under_armor": "Ocultar modelo bajo la armadura",
"opaque": "Opaco",
"dev_wireframe": "DEV: wireframe",
"environment": "Entorno",
"show_grid": "Mostrar cuadrícula"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "Descargar",
+51 -44
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.",
"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.",
"start_end_equal": "# %s1 was provided twice\n\nMake sure `starting-tank` and `target-tank` are different.",
"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.",
"unordered": "# %s1 higher tiered to %s2\n\nMake sure to provide %s3 as the `starting-tank` instead as it is the lower tiered tank."
"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 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 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": {
@@ -380,7 +380,7 @@
},
"embeds": {
"name": "intègre",
"description": "How embeds work"
"description": "Comment fonctionnent les intégrations"
},
"introduction": {
"name": "introduction",
@@ -402,7 +402,7 @@
},
"breakdown": {
"name": "décomposition",
"description": "A period's breakdown by tanks played",
"description": "Répartition d'une période par chars joués",
"body": {
"total": "Total",
"battles": "Batailles",
@@ -471,7 +471,7 @@
"accuracy": "Précision"
},
"lethality": {
"title": "Lethality",
"title": "Létalité",
"average_damage": "Dégâts moyens",
"damage_per_tier": "Dégâts par tier",
"damage_ratio": "Rapport de dégâts",
@@ -499,20 +499,20 @@
},
"embed": {
"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": {
"name": "search-clans",
"description": "Search clans",
"name": "clans de recherche",
"description": "Rechercher des clans",
"options": {
"limit": {
"name": "limite",
"description": "The max number of clan results (default: %s1)"
"description": "Nombre maximum de résultats du clan (par défaut: %s1)"
}
},
"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é"
}
},
@@ -535,69 +535,69 @@
"description": "Statistiques des batailles de cotés",
"subcommands": {
"neighbors": {
"name": "neighbors",
"description": "Neighboring players on the leaderboard"
"name": "voisins",
"description": "Joueurs voisins dans le classement"
},
"league": {
"name": "ligue",
"description": "Top players of a league"
"description": "Meilleurs joueurs d'une ligue"
}
},
"options": {
"limit": {
"name": "limite",
"description": "How many neighbors to display (default: %s1)"
"description": "Combien de voisins afficher (par défaut : %s1)"
},
"season": {
"name": "season",
"description": "Season number (default: current)",
"name": "saison",
"description": "Numéro de saison (défaut : courant)",
"choices": {
"current": "%s1 (current)"
"current": "%s1 (actuel)"
}
},
"league": {
"name": "ligue",
"description": "The league to display"
"description": "La ligue à afficher"
}
},
"body": {
"top_players": "Top %s1 players",
"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_ongoing_season": "# No ongoing season\n\nThere isn't a currently running rating season.",
"deleted_player": "Deleted %s1",
"neighbors": "Rating neighbors",
"more": "+ %s1 more"
"top_players": "Top %s1 joueurs",
"no_participation": "%s1 n'a pas joué au classement dans la saison %s2",
"no_ongoing_season": "Pas de saison en cours\n\nIl n'y a pas de saison de notation en cours actuellement.",
"deleted_player": "%s1 supprimé",
"neighbors": "évaluer les voisins",
"more": "+ %s1 de plus"
}
},
"today": {
"name": "aujourd'hui",
"description": "Today's played tanks and statistics"
"description": "Les chars et les statistiques joués aujourd'hui"
},
"stats": {
"name": "statistiques",
"description": "Quick statistics for a period"
"description": "Statistiques rapides pour une période"
},
"owned_tanks": {
"name": "owned-tanks",
"description": "Shows all own tanks in a tier",
"name": "chars possédés",
"description": "Affiche tous les chars d'un niveau",
"body": {
"title": "# %s1's owned tier %s2 tanks\n\n"
"title": "# %s1 possédait des chars de rang %s2"
}
},
"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": {
"title": "# Permissions\n\n%s1",
"view_channels": "View channels: needed to find the message the refresh button was used on",
"read_message_history": "Read message history: needed to find the message to refresh",
"attach_files": "Attach files: needed to add images to image-based commands",
"external_emojis": "External emojis: needed to add national flags and tank class emojis"
"view_channels": "Afficher les canaux : nécessaire pour trouver le message sur lequel le bouton d'actualisation a été utilisé",
"read_message_history": "Lire l'historique des messages : nécessaire pour trouver le message à rafraîchir",
"attach_files": "Joindre des fichiers : nécessaire pour ajouter des images à des commandes basées sur l'image",
"external_emojis": "Émoticônes externes: nécessaire pour ajouter des drapeaux nationaux et des émojis de classe de char"
}
},
"ping": {
"name": "ping",
"description": "Check wether the bot is alive.",
"description": "Vérifiez si le bot est en vie.",
"body": {
"pong": "Pong 🏓",
"pong_with_time": "Pong 🏓 - %s1ms"
@@ -605,7 +605,7 @@
"subcommands": {
"blitzkit": {
"name": "blitzkit",
"description": "Ping BlitzKit"
"description": "Ping blitzkit"
},
"wotb": {
"name": "wotb",
@@ -614,10 +614,10 @@
}
},
"player_info": {
"name": "player-info",
"description": "Basic information about a player",
"name": "info-joueur",
"description": "Informations de base sur un joueur",
"body": {
"title": "%s1's information",
"title": "Informations de %s1",
"nickname": "Nickname",
"battles": "Batailles",
"winrate": "Taux de victoires",
@@ -635,7 +635,7 @@
"description": "Today's rating statistics"
},
"season": {
"name": "season",
"name": "saison",
"description": "This season's rating statistics"
}
},
@@ -764,6 +764,12 @@
}
},
"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": {
"name": "Guess the tank",
"button": "Guess away",
@@ -787,6 +793,9 @@
"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.",
"visualizers": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -829,9 +838,7 @@
"green_penetration": "Green penetration",
"hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque",
"dev_wireframe": "DEV: wireframe",
"environment": "Environment",
"show_grid": "Show grid"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "Download",
+10 -3
View File
@@ -764,6 +764,12 @@
}
},
"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": {
"name": "Guess the tank",
"button": "Guess away",
@@ -787,6 +793,9 @@
"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.",
"visualizers": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -829,9 +838,7 @@
"green_penetration": "貫通可能域を緑色表示",
"hide_model_under_armor": "車輌モデルを非表示",
"opaque": "貫通アシストを不透明にする",
"dev_wireframe": "DEV: wireframe",
"environment": "環境",
"show_grid": "グリッドを表示"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "保存する",
+10 -3
View File
@@ -764,6 +764,12 @@
}
},
"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": {
"name": "Guess the tank",
"button": "Guess away",
@@ -787,6 +793,9 @@
"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.",
"visualizers": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -829,9 +838,7 @@
"green_penetration": "Green penetration",
"hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque",
"dev_wireframe": "DEV: wireframe",
"environment": "Environment",
"show_grid": "Show grid"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "Download",
+10 -3
View File
@@ -764,6 +764,12 @@
}
},
"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": {
"name": "Guess the tank",
"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_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": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -829,9 +838,7 @@
"green_penetration": "Penetração verde",
"hide_model_under_armor": "Ocultar modelo sob blindagem",
"opaque": "Opaco",
"dev_wireframe": "DEV: wireframe",
"environment": "Ambiente",
"show_grid": "Exibir grade"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "Download",
+59 -52
View File
@@ -1,8 +1,8 @@
{
"test": {
"default": "тест",
"long": "Это длинная тестовая строка. Вы НИКОГДА не должны видеть её в пользовательском интерфейсе. Мой любимый фрукт - %s1, потому что его цвет %s2. Он мне так нравится, что я произнесу его имя 3 раза! %s1! %s1! %s1! И ещё раз, это %s2, а не %s3.",
"fox": "Это длинная тестовая строка. Вы НИКОГДА не должны видеть её в пользовательском интерфейсе. Мой любимый фрукт - %s1, потому что его цвет %s2. Он мне так нравится, что я произнесу его имя 3 раза! %s1! %s1! %s1! И ещё раз, это %s2, а не %s3."
"long": "Это длинная тестовая строка. Вы НИКОГДА не должны видеть её в пользовательском интерфейсе. Мой любимый фрукт — %s1, потому что это цвет %s2. Он мне так нравится, что я произнесу его имя 3 раза! %s1! %s1! %s1! И ещё раз, это %s2, а не %s3.",
"fox": "Съешь же ещё этих мягких французских булок, да выпей чаю."
},
"common": {
"units": {
@@ -283,15 +283,15 @@
"name": "уровень",
"description": "Уровень",
"choices": {
"tier": "Уровень %s - %s"
"tier": "Уровень %s1 - %s2"
}
}
},
"periods": {
"career": "Карьера",
"today": "Cегодня",
"days": "%s дней",
"custom": "%s - %s дней"
"days": "%s1 дней",
"custom": "%s1 - %s2 дней"
},
"filters": {
"none": "Без фильтров",
@@ -323,9 +323,9 @@
"description": "Танки доступные на аукционе сейчас",
"body": {
"title": "Blitz аукцион",
"subtitle": "%s доступные • следующие цены %s\nПокупка сейчас: %s",
"next": "Следующая цена: %s",
"available": "Доступно: %s / %s"
"subtitle": "%s1 доступные • следующие цены %s2\nПокупка сейчас: %s3",
"next": "Следующая цена: %s1",
"available": "Доступно: %s1 / %s2"
},
"errors": {
"no_auction": "# Аукцион сейчас не проходит\n\nНа данный момент нет танков для покупки на аукционе. Если считаете, что это ошибка, пожалуйста о несоответствии на [официальном сервере Discord](https://discord.gg/nDt7AjGJQH)."
@@ -354,19 +354,19 @@
"body": {
"subtitle": "Исследование",
"total": "Всего",
"research": "Исследование: %s",
"upgrades": "Улучшения: %s",
"purchase": "Покупка: %s",
"equipment": "Снаряжение: %s",
"research": "Исследование: %s1",
"upgrades": "Улучшения: %s1",
"purchase": "Покупка: %s1",
"equipment": "Снаряжение: %s1",
"estimation": "# Это приблизительная оценка!\n\nВ этот список могут входить уже исследованные танки из-за неточной информации, предоставляемой Wargaming. Попробуйте указать «стартовый-танк» в строке при использовании команды, если сможете."
},
"errors": {
"non_tech_tree": "# %s отсутствует в дереве танков\n\nВы не можете исследовать премиум / коллекционный танк.",
"tier_1": "# %s - это уровень I\n\nУровень I всегда открыт бесплатно для всех игроков.",
"already_researched": "# %s уже исследован\n\nПопробуйте предоставить «starting-tank» при использовании команды для просмотра итогов уже исследованной ветки.",
"start_end_equal": "# %s был предоставлен дважды\n\nУбедитесь, что «starting-tank» и «target-tank» различаются.",
"tanks_not_on_line": "# %s и %s находятся не в одной ветке\n\nУбедитесь, что «starting-tank» и «target-tank» находятся на той же ветке дерева танков.",
"unordered": "# У %s более высокий уровень чем у %s\n\nОбязательно укажите %s в качестве «starting-tank», так как это танк более низкого уровня."
"non_tech_tree": "# %s1 отсутствует в дереве танков\n\nВы не можете исследовать премиум / коллекционный танк.",
"tier_1": "# %s1 - это уровень I\n\nУровень I всегда открыт бесплатно для всех игроков.",
"already_researched": "# %s1 уже исследован\n\nПопробуйте предоставить «starting-tank» при использовании команды для просмотра итогов уже исследованной ветки.",
"start_end_equal": "# %s1 был предоставлен дважды\n\nУбедитесь, что «starting-tank» и «target-tank» различаются.",
"tanks_not_on_line": "# %s1 и %s2 находятся не в одной ветке\n\nУбедитесь, что «starting-tank» и «target-tank» находятся на той же ветке дерева танков.",
"unordered": "# У %s1 более высокий уровень чем у %s2\n\nОбязательно укажите %s3 в качестве «starting-tank», так как это танк более низкого уровня."
}
},
"about": {
@@ -409,7 +409,7 @@
"wn8": "WN8",
"winrate": "Коэффициент побед",
"damage": "Урон",
"unknown_tank": "Неизвестный танк %s"
"unknown_tank": "Неизвестный танк %s1"
}
},
"debug": {
@@ -445,12 +445,12 @@
"options": {
"threshold": {
"name": "порог",
"description": "Количество дней не активности (по умолчанию: %s дней)"
"description": "Количество дней не активности (по умолчанию: %s1 дней)"
}
},
"body": {
"subtitle": "Неактивен %s+ дней",
"listing": "%s дней"
"subtitle": "Неактивен %s1+ дней",
"listing": "%s1 дней"
}
},
"full_stats": {
@@ -508,11 +508,11 @@
"options": {
"limit": {
"name": "лимит",
"description": "Максимальное количество результатов клана (по умолчанию: %s)"
"description": "Максимальное количество результатов клана (по умолчанию: %s1)"
}
},
"body": {
"title": "Поиск клана %s в регионе %s",
"title": "Поиск клана %s1 в регионе %s2",
"no_results": "Кланов не найдено"
}
},
@@ -522,11 +522,11 @@
"options": {
"limit": {
"name": "лимит",
"description": "Максимальное число результатов игрока (по умолчанию: %s)"
"description": "Максимальное число результатов игрока (по умолчанию: %s1)"
}
},
"body": {
"title": "Поиск игрока %s в регионе %s",
"title": "Поиск игрока %s1 в регионе %s2",
"no_results": "Игроков не найдено"
}
},
@@ -546,13 +546,13 @@
"options": {
"limit": {
"name": "лимит",
"description": "Количество отображаемых ближайших игроков (по умолчанию: %s)"
"description": "Количество отображаемых ближайших игроков (по умолчанию: %s1)"
},
"season": {
"name": "сезон",
"description": "Номер сезона (по умолчанию: текущий)",
"choices": {
"current": "%s (текущая)"
"current": "%s1 (текущая)"
}
},
"league": {
@@ -561,12 +561,12 @@
}
},
"body": {
"top_players": "Топ %s игроков",
"no_participation": "# %s не играл в рейтинг в сезоне %s\n\nЭтот игрок не участвовал в этом сезоне или не получил прошлую калибровку.",
"top_players": "Топ %s1 игроков",
"no_participation": "# %s1 не играл в рейтинг в сезоне %s2\n\nЭтот игрок не участвовал в этом сезоне или не получил прошлую калибровку.",
"no_ongoing_season": "# Нет текущего сезона\n\nВ данный момент нет рейтингового сезона.",
"deleted_player": "Удалено %s",
"deleted_player": "Удалено %s1",
"neighbors": "Рейтинг ближайших игроков",
"more": "+ ещё %s"
"more": "+ ещё %s1"
}
},
"today": {
@@ -581,14 +581,14 @@
"name": "cобственные-танки",
"description": "Показывает все принадлежащие танки на уровне",
"body": {
"title": "# %s принадлежал уровень %s танков\n\n"
"title": "# %s1 принадлежал уровень %s2 танков\n\n"
}
},
"permissions": {
"name": "разрешения",
"description": "Проверяет, есть ли у бота необходимые разрешения для работы",
"body": {
"title": "# Разрешения\n\n%s",
"title": "# Разрешения\n\n%s1",
"view_channels": "Просмотр каналов: необходимо найти сообщение, на котором была использована кнопка обновления",
"read_message_history": "Просмотр истории сообщений: необходимо найти сообщение для обновления",
"attach_files": "Прикрепить файлы: необходимо для добавления изображений в команды на основе изображения",
@@ -600,7 +600,7 @@
"description": "Проверьте, что бот жив.",
"body": {
"pong": "Понг 🏓",
"pong_with_time": "Понг 🏓 - %sмс"
"pong_with_time": "Понг 🏓 - %s1мс"
},
"subcommands": {
"blitzkit": {
@@ -617,7 +617,7 @@
"name": "информация-об-игроке",
"description": "Основная информация о игроке",
"body": {
"title": "Информация о %s",
"title": "Информация о %s1",
"nickname": "Никнейм",
"battles": "Боёв",
"winrate": "Коэффициент побед",
@@ -646,7 +646,7 @@
}
},
"errors": {
"no_participation": "# %s не получил рейтинга\n\nЭтот игрок в этом сезоне не участвовал в рейтинговом режиме.",
"no_participation": "# %s1 не получил рейтинга\n\nЭтот игрок в этом сезоне не участвовал в рейтинговом режиме.",
"unrecorded_stats": "# Статистика недоступна в настоящее время\n\nВ настоящее время Wargaming, возможно, сталкивается с простоями сервера. Если сезон продолжается, статистика должна быть доступна в течение 24 часов."
}
}
@@ -676,7 +676,7 @@
"recent": "Недавние",
"select_all": "Выбрать всё",
"count_singular": "1 танк",
"count_plural": "%s1 танк",
"count_plural": "Танков: %s1",
"sorting_by": "Сортировать по %s1",
"no_tanks_found": {
"body": "Танков не найдено.",
@@ -693,7 +693,7 @@
"survivability.volume": "объём",
"survivability.length": "длина",
"fire.dpm": "базовый ДПМ",
"fire.dpmPremium": "улучшенный ДПМ",
"fire.dpmPremium": "премиумный ДПМ",
"fire.reload": "перезарядка",
"fire.caliber": "калибр",
"fire.standardPenetration": "базовое пробитие",
@@ -764,6 +764,12 @@
}
},
"tools": {
"mixer": {
"name": "Смешиватель",
"button": "Смешать и собрать",
"description": "Модули смешанного танка",
"seo_description": "Смешивание и сбор корпусов, башен и пушек танков из World of Tanks Blitz"
},
"guess": {
"name": "Угадайте танк",
"button": "Угадать",
@@ -784,9 +790,12 @@
"button": "Поиск танков",
"description": "Танковая энциклопедия 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 здоровье.",
"visualizers": {
"reload": {
"skip": "Пропуск"
},
"assault": {
"title": "Штурмовые снаряды"
},
@@ -812,7 +821,7 @@
"dead_rail": "Эшелон"
},
"flexibility": {
"yaw": "Отклонение",
"yaw": "Поворот",
"pitch": "Наклон",
"min": "Вниз",
"max": "Вверх"
@@ -829,9 +838,7 @@
"green_penetration": "Зелёное пробитие",
"hide_model_under_armor": "Скрыть модель под броней",
"opaque": "Прозрачность",
"dev_wireframe": "DEV: wireframe",
"environment": "Окружение",
"show_grid": "Показать сетку"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "Загрузить",
@@ -902,7 +909,7 @@
"tier": "Уровень",
"type": "Тип",
"dev_id": "DEV: ID",
"purchase": "Стоимость покупки",
"purchase": "Цена покупки",
"restoration": "Цена восстановления",
"sale": "Цена продажи",
"research": "Необходимо опыта"
@@ -978,7 +985,7 @@
"dpmEffective": "Эффективность за 60с",
"shells": "Снарядов в барабане",
"mostOptimalShellIndex": "Самый эффективный снаряд",
"shellReloads": "Reloads",
"shellReloads": "Перезарядка",
"shellReload": "Перезарядка",
"intraClip": "Между снарядами",
"caliber": "Калибр",
@@ -1038,8 +1045,8 @@
"shellCapacity": "Боезапас",
"gunType": "Тип орудия",
"penetrationAt250m": "Пробитие на 250м",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
"burstShells": "Кол-во разрывных снарядов",
"burstInterShell": "Взрыв между снарядами"
}
},
"firepower": {
@@ -1136,7 +1143,7 @@
"hits": "Уничтожен",
"damage_ratio": "Коэф. урона",
"damage_taken": "Получено урона",
"capture_points": "Ограничительные точки"
"capture_points": "Точки ограничения"
}
}
},
@@ -1148,7 +1155,7 @@
"last_updated": "Последнее обновление %s1",
"search": {
"hint": "Поиск аватаров...",
"results": "%s1 аватар"
"results": "Аватаров: %s1"
}
},
"session": {
@@ -1157,7 +1164,7 @@
"description": "Статистика сеанса игры",
"seo_description": "Отслеживайте свою статистику в реальном времени во время игры World of Tanks Blitz",
"search": {
"lookup": "Искать игрока, чтобы начать",
"lookup": "Введите ник игрока",
"hint": "Поиск игроков...",
"searching": "Поиск...",
"no_results": "Игроков не найдено"
@@ -1198,7 +1205,7 @@
"button": "Создать",
"description": "Встраиваемые элементы для стримов",
"seo_description": "Создать вставки для стримов по World of Tanks Blitz с невероятной кастомизации",
"coming_soon": "Скоро появятся новые встраиваемые элементы.",
"coming_soon": "В скором времени появятся новые встраиваемые элементы.",
"suggest": "Предложить идеи.",
"preview": {
"title": "Предпросмотр",
+10 -3
View File
@@ -764,6 +764,12 @@
}
},
"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": {
"name": "Guess the tank",
"button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Рівень %s2 %s3 %s4 | %s5 Танкопедія | WoT Blitz",
"tank_seo_description": "%s1 броня, характеристики, обладнання, амуніція та більше. %s2 шкода на хвилину, %s3 разова шкода, %s4 мм пробиття, %s5 км/год максимальна швидкість, %s6 міцність.",
"visualizers": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -829,9 +838,7 @@
"green_penetration": "Зелене пробиття",
"hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque",
"dev_wireframe": "DEV: wireframe",
"environment": "Environment",
"show_grid": "Show grid"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "Завантажити",
+10 -3
View File
@@ -764,6 +764,12 @@
}
},
"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": {
"name": "猜坦克",
"button": "猜猜看",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - %s2 阶 %s3 %s4 | %s5 战车百科 | 战车世界闪击战 (WoTB)",
"tank_seo_description": "%s1 装甲,统计设备,装备,供给等。 %s2 每分钟伤害, %s3生命值, %s4毫米穿透, %s5kph 最高速度, %s6hp 生命值。",
"visualizers": {
"reload": {
"skip": "跳过"
},
"assault": {
"title": "Assault shells"
},
@@ -829,9 +838,7 @@
"green_penetration": "可击穿区域",
"hide_model_under_armor": "隐藏装甲下的模型",
"opaque": "不透明",
"dev_wireframe": "开发选项:网格",
"environment": "环境",
"show_grid": "显示网格"
"dev_wireframe": "开发选项:网格"
},
"screenshot": {
"download": "下载",
+8 -7
View File
@@ -11,20 +11,21 @@
"build:ids": "bun src/discoverIds.ts",
"build:bot": "bun src/buildBot.ts",
"build:ratingleaderboard": "bun src/buildRatingLeaderboard.ts",
"build:proxyclient": "bun src/buildProxyClient.ts",
"prunestrings": "bun src/pruneStrings.ts"
},
"dependencies": {
"@blitzkit/core": "workspace:*",
"@blitzkit/i18n": "workspace:*",
"@gltf-transform/core": "^4.0.10",
"@gltf-transform/extensions": "^4.0.10",
"@gltf-transform/functions": "^4.0.10",
"csv-parse": "^5.5.6",
"@gltf-transform/core": "^4.2.1",
"@gltf-transform/extensions": "^4.2.1",
"@gltf-transform/functions": "^4.2.1",
"csv-parse": "^6.1.0",
"decompress": "^4.2.1",
"esbuild": "^0.19.12",
"fast-xml-parser": "^4.4.1",
"esbuild": "^0.25.10",
"fast-xml-parser": "^5.2.5",
"progress": "^2.0.3",
"puppeteer": "^23.10.4"
"puppeteer": "^24.22.3"
},
"devDependencies": {
"@types/decompress": "^4.2.7",
+25 -23
View File
@@ -1,50 +1,52 @@
import { toUniqueId } from '@blitzkit/core';
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import { readdir } from 'fs/promises';
import { extractModel } from '../core/blitz/extractModel';
import { readXMLDVPL } from '../core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../core/blitz/readYAMLDVPL';
import { commitAssets } from '../core/github/commitAssets';
import { FileChange } from '../core/github/commitMultipleFiles';
import { DATA } from './constants';
import { VehicleDefinitionList } from './definitions';
import { TankParameters } from './tankIcons';
import { toUniqueId } from "@blitzkit/core";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { readdir } from "fs/promises";
import { extractModel } from "../core/blitz/extractModel";
import { readXMLDVPL } from "../core/blitz/readXMLDVPL";
import { readYAMLDVPL } from "../core/blitz/readYAMLDVPL";
import { commitAssets } from "../core/github/commitAssets";
import { FileChange } from "../core/github/commitMultipleFiles";
import { DATA } from "./constants";
import { VehicleDefinitionList } from "./definitions";
import { TankParameters } from "./tankIcons";
export async function tankModels() {
console.log('Building tank models...');
console.log("Building tank models...");
const nodeIO = new NodeIO().registerExtensions(ALL_EXTENSIONS);
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) {
const nation = nations[nationIndex];
const changes: FileChange[] = [];
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}`);
for (const [tankKey, tank] of Object.entries(tanks.root)) {
if (tankKey.includes('tutorial_bot')) continue;
let index = 0;
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 parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`,
`${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`
);
const model = await extractModel(
DATA,
parameters.resourcesPath.blitzModelPath.replace(/\.sc2$/, ''),
parameters.resourcesPath.blitzModelPath.replace(/\.sc2$/, "")
);
const content = Buffer.from(await nodeIO.writeBinary(model));
changes.push({
path: `3d/tanks/models/${id}.glb`,
content: Buffer.from(await nodeIO.writeBinary(model)),
});
changes.push({ path: `3d/tanks/models/${id}.glb`, content });
}
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>> =
{
[VertexAttribute.VERTEX]: 'POSITION',
[VertexAttribute.NORMAL]: 'NORMAL',
[VertexAttribute.TEXCOORD0]: 'TEXCOORD_0',
[VertexAttribute.TEXCOORD1]: 'TEXCOORD_0',
[VertexAttribute.TEXCOORD2]: 'TEXCOORD_0',
[VertexAttribute.TEXCOORD3]: 'TEXCOORD_0',
[VertexAttribute.VERTEX]: "POSITION",
[VertexAttribute.NORMAL]: "NORMAL",
[VertexAttribute.TEXCOORD0]: "TEXCOORD_0",
[VertexAttribute.TEXCOORD1]: "TEXCOORD_1",
[VertexAttribute.TEXCOORD2]: "TEXCOORD_2",
[VertexAttribute.TEXCOORD3]: "TEXCOORD_3",
};
export const vertexAttributeGltfVectorSizes = {
...vertexAttributeVectorSizes,
@@ -4,35 +4,35 @@ import {
ScgReadStream,
Textures,
VertexAttribute,
} from '@blitzkit/core';
import { Document, Material, Node, Scene } from '@gltf-transform/core';
import { dedup, prune } from '@gltf-transform/functions';
import { times } from 'lodash-es';
import { dirname } from 'path';
import { Matrix4, Quaternion, Vector3, Vector4Tuple } from 'three';
import { readDVPLFile } from '../readDVPLFile';
import { readTexture } from '../readTexture';
import { TextureMutation } from '../readTexture/constants';
} from "@blitzkit/core";
import { Document, Material, Node, Scene } from "@gltf-transform/core";
import { dedup, prune } from "@gltf-transform/functions";
import { times } from "lodash-es";
import { dirname } from "path";
import { readBaseColor } from "../readBaseColor";
import { readDVPLFile } from "../readDVPLFile";
import { readNormal } from "../readNormal";
import { readRoughnessMetallic } from "../readRoughnessMetallic";
import {
vertexAttributeGLTFName,
vertexAttributeGltfVectorSizes,
} from './constants';
} from "./constants";
const ERROR_ON_UNKNOWN_COMPONENT = false;
const omitMeshNames = {
start: ['chassis_chassis_', 'chassis_track_crash_', 'HP_'],
end: ['_POINT'],
start: ["chassis_chassis_", "chassis_track_crash_", "HP_"],
end: ["_POINT"],
};
export async function extractModel(data: string, path: string) {
const sc2Path = `${data}/3d/${path}.sc2`;
const scgPath = `${data}/3d/${path}.scg`;
const sc2 = new Sc2ReadStream(
(await readDVPLFile(sc2Path)).buffer as ArrayBuffer,
(await readDVPLFile(sc2Path)).buffer as ArrayBuffer
).sc2();
const scg = new ScgReadStream(
(await readDVPLFile(scgPath)).buffer as ArrayBuffer,
(await readDVPLFile(scgPath)).buffer as ArrayBuffer
).scg();
const document = new Document();
const scene = document.createScene();
@@ -41,8 +41,8 @@ export async function extractModel(data: string, path: string) {
// create materials
await Promise.all(
sc2['#dataNodes'].map(async (node) => {
const id = new DataView(node['#id']).getBigUint64(0, true);
sc2["#dataNodes"].map(async (node) => {
const id = new DataView(node["#id"]).getBigUint64(0, true);
if (node.parentMaterialKey !== undefined) {
/**
@@ -58,7 +58,7 @@ export async function extractModel(data: string, path: string) {
if (node.textures) {
textures = node.textures;
} else if (typeof node.configCount === 'number') {
} else if (typeof node.configCount === "number") {
textures = node.configArchive_0.textures;
}
@@ -66,26 +66,27 @@ export async function extractModel(data: string, path: string) {
material.setBaseColorTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setMimeType("image/jpeg")
.setImage(
await readTexture(
`${data}/3d/${dirname(path)}/${textures.albedo}`,
TextureMutation.Albedo,
),
),
await readBaseColor(
`${data}/3d/${dirname(path)}/${textures.baseColorMap ?? textures.albedo}`,
textures.miscMap
? `${data}/3d/${dirname(path)}/${textures.miscMap}`
: undefined
)
)
);
if (textures.baseRMMap) {
material.setMetallicRoughnessTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setMimeType("image/jpeg")
.setImage(
await readTexture(
`${data}/3d/${dirname(path)}/${textures.baseRMMap}`,
TextureMutation.RoughnessMetallicness,
),
),
await readRoughnessMetallic(
`${data}/3d/${dirname(path)}/${textures.baseRMMap}`
)
)
);
}
@@ -95,47 +96,33 @@ export async function extractModel(data: string, path: string) {
material.setNormalTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setMimeType("image/jpeg")
.setImage(
await readTexture(
await readNormal(
`${data}/3d/${dirname(path)}/${
textures.baseNormalMap ?? textures.normalmap
}`,
isBase ? TextureMutation.Normal : undefined,
),
),
);
}
if (textures.miscMap) {
material.setOcclusionTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setImage(
await readTexture(
`${data}/3d/${dirname(path)}/${textures.miscMap}`,
TextureMutation.Miscellaneous,
),
),
isBase
)
)
);
}
materials.set(
new DataView(node['#id']).getBigUint64(0, true),
material,
new DataView(node["#id"]).getBigUint64(0, true),
material
);
}
}),
})
);
// replace children materials with parents
materials.forEach((material, id) => {
if (typeof material !== 'bigint') return;
if (typeof material !== "bigint") return;
let resolvedMaterial: Material | bigint | undefined = material;
while (typeof resolvedMaterial === 'bigint') {
while (typeof resolvedMaterial === "bigint") {
const linkedParentMaterial = materials.get(resolvedMaterial);
resolvedMaterial = linkedParentMaterial;
@@ -155,36 +142,36 @@ export async function extractModel(data: string, path: string) {
const node = document.createNode(hierarchy.name);
const components = times(
hierarchy.components.count,
(index) => hierarchy.components[index.toString().padStart(4, '0')],
(index) => hierarchy.components[index.toString().padStart(4, "0")]
);
components.forEach((component) => {
switch (component['comp.typename']) {
case 'LodComponent':
switch (component["comp.typename"]) {
case "LodComponent":
// found and used later by transform component
// TODO: lod component always shows up before transform component; cache it before parsing transform
break;
case 'TransformComponent': {
node.setTranslation(component['tc.localTranslation']);
node.setRotation(component['tc.localRotation']);
node.setScale(component['tc.localScale']);
case "TransformComponent": {
node.setTranslation(component["tc.localTranslation"]);
node.setRotation(component["tc.localRotation"]);
node.setScale(component["tc.localScale"]);
break;
}
case 'RenderComponent': {
const renderObject = component['rc.renderObj'];
case "RenderComponent": {
const renderObject = component["rc.renderObj"];
times(renderObject['ro.batchCount'], (batchIndex): void => {
times(renderObject["ro.batchCount"], (batchIndex): void => {
const lodIndex = renderObject[`rb${batchIndex}.lodIndex`];
if (lodIndex !== 0) return;
const batchKey = batchIndex.toString().padStart(4, '0');
const batch = renderObject['ro.batches'][batchKey];
const material = materials.get(batch['rb.nmatname']);
const polygonGroup = scg.get(batch['rb.datasource']);
const batchKey = batchIndex.toString().padStart(4, "0");
const batch = renderObject["ro.batches"][batchKey];
const material = materials.get(batch["rb.nmatname"]);
const polygonGroup = scg.get(batch["rb.datasource"]);
if (!(material instanceof Material)) {
// probably shadow material
@@ -192,14 +179,14 @@ export async function extractModel(data: string, path: string) {
}
if (polygonGroup === undefined) {
throw new Error(
`Missing polygon group ${batch['rb.datasource']}`,
`Missing polygon group ${batch["rb.datasource"]}`
);
}
const lodNode = document.createNode(batchKey);
const indicesAccessor = document
.createAccessor()
.setType('SCALAR')
.setType("SCALAR")
.setArray(new Uint16Array(polygonGroup.indices))
.setBuffer(buffer);
const primitive = document
@@ -233,7 +220,7 @@ export async function extractModel(data: string, path: string) {
const vertexSize = vertexAttributeGltfVectorSizes[attribute];
const attributeAccessor = document
.createAccessor(name)
.setType(vertexSize === 1 ? 'SCALAR' : `VEC${vertexSize}`)
.setType(vertexSize === 1 ? "SCALAR" : `VEC${vertexSize}`)
.setArray(new Float32Array(value.flat()))
.setBuffer(buffer);
@@ -241,7 +228,7 @@ export async function extractModel(data: string, path: string) {
});
const mesh = document
.createMesh(batch['##name'])
.createMesh(batch["##name"])
.addPrimitive(primitive);
lodNode.setMesh(mesh);
node.addChild(lodNode);
@@ -253,24 +240,24 @@ export async function extractModel(data: string, path: string) {
default: {
if (ERROR_ON_UNKNOWN_COMPONENT) {
throw new Error(
`Unhandled component type: ${component['comp.typename']}`,
`Unhandled component type: ${component["comp.typename"]}`
);
}
}
}
});
if (hierarchy['#hierarchy']) {
parseHierarchies(hierarchy['#hierarchy'], node);
if (hierarchy["#hierarchy"]) {
parseHierarchies(hierarchy["#hierarchy"], 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());
@@ -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 { existsSync } from 'fs';
import sharp from 'sharp';
import { DATA_COMPRESSED } from '../../../buildAssets/constants';
import { readDVPLFile } from '../readDVPLFile';
import { TextureMutation } from './constants';
import { DdsReadStream, PvrReadStream } from "@blitzkit/core";
import { existsSync } from "fs";
import sharp from "sharp";
import { DATA_COMPRESSED } from "../../../buildAssets/constants";
import { readDVPLFile } from "../readDVPLFile";
import { TextureMutation } from "./constants";
export async function readTexture(path: string, mutation?: TextureMutation) {
const ddsTexturePath = path.replace('.tex', '.dx11.dds');
export async function readTexture(path: string) {
const ddsTexturePath = path.replace(".tex", ".dx11.dds");
const isDds = existsSync(
`${ddsTexturePath}${DATA_COMPRESSED ? '.dvpl' : ''}`,
`${ddsTexturePath}${DATA_COMPRESSED ? ".dvpl" : ""}`
);
const resolvedTexturePath = isDds
? ddsTexturePath
: ddsTexturePath.replace('.dds', '.pvr');
: ddsTexturePath.replace(".dds", ".pvr");
const decompressedDvpl = await readDVPLFile(`${resolvedTexturePath}.dvpl`);
const raw = isDds
? await new DdsReadStream(decompressedDvpl.buffer as ArrayBuffer).dds()
: new PvrReadStream(decompressedDvpl.buffer as ArrayBuffer).pvr();
switch (mutation) {
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);
return raw;
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;
}
// switch (mutation) {
// 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);
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: {
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 sharp(raw.data, { raw }).jpeg().toBuffer();
// }
return await newImage
.joinChannel(await Promise.all([roughness, metallicness]))
.jpeg()
.toBuffer();
}
// case TextureMutation.RoughnessMetallicness: {
// 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();
case TextureMutation.Miscellaneous: {
/**
* 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();
}
// return await newImage
// .joinChannel(await Promise.all([roughness, metallicness]))
// .jpeg()
// .toBuffer();
// }
case TextureMutation.Albedo: {
return await sharp(raw.data, { raw }).removeAlpha().jpeg().toBuffer();
}
// case TextureMutation.Miscellaneous: {
// /**
// * 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:
return await sharp(raw.data, { raw }).jpeg().toBuffer();
}
// case TextureMutation.Albedo: {
// 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 { existsSync } from 'fs';
import { readdir, rm, writeFile } from 'fs/promises';
import { promisify } from 'util';
import { exec as execSync } from "child_process";
import { existsSync } from "fs";
import { readdir, rm } from "fs/promises";
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);
let executableFileExtension: string;
if (existsSync('node_modules/.bin/protoc-gen-ts_proto.exe')) {
executableFileExtension = '.exe';
if (existsSync("node_modules/.bin/protoc-gen-ts_proto.exe")) {
executableFileExtension = ".exe";
} else {
executableFileExtension = '';
executableFileExtension = "";
}
const filesRaw = await readdir(`${ROOT}`);
await Promise.all(
filesRaw.map(async (file) => {
if (!file.endsWith('.proto')) {
await rm(`${ROOT}/${file}`);
for (const root of roots) {
const filesRaw = await readdir(`${root}`);
const files: string[] = [];
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'));
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'),
);
await exec(command);
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "varuna",
"type": "module",
"main": "src/index.ts",
"dependencies": {
"react": "^19.1.1"
}
}
+121
View File
@@ -0,0 +1,121 @@
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[]) {
if (this.initialized) return;
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;
console.log("dispatched");
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"
}
+34 -11
View File
@@ -1,13 +1,15 @@
// @ts-check
import react from '@astrojs/react';
import sitemap from '@astrojs/sitemap';
import { SUPPORTED_LOCALES } from '@blitzkit/i18n';
import { defineConfig } from 'astro/config';
import react from "@astrojs/react";
import sitemap from "@astrojs/sitemap";
import { SUPPORTED_LOCALES } from "@blitzkit/i18n";
import strings from "@blitzkit/i18n/strings/en.json";
import favicons from "astro-favicons";
import { defineConfig } from "astro/config";
export default defineConfig({
i18n: {
defaultLocale: 'en',
defaultLocale: "en",
locales: [...SUPPORTED_LOCALES],
routing: {
prefixDefaultLocale: false,
@@ -15,17 +17,38 @@ export default defineConfig({
},
devToolbar: { enabled: false },
output: 'static',
site: 'https://blitzkit.app',
outDir: '../../dist/website',
output: "static",
site: "https://blitzkit.app",
outDir: "../../dist/website",
prefetch: true,
integrations: [react(), sitemap()],
integrations: [
react(),
sitemap(),
favicons({
name: strings.common.name,
short_name: strings.common.name,
manifest: {
start_url: "/",
display_override: ["minimal-ui"],
},
icons: {
windows: true,
android: true,
appleIcon: true,
appleStartup: true,
favicons: true,
yandex: true,
},
}),
],
vite: {
resolve: {
alias: {
'.prisma/client/index-browser':
'../../node_modules/.prisma/client/index-browser.js',
".prisma/client/index-browser":
"../../node_modules/.prisma/client/index-browser.js",
},
},
ssr: {
+26 -26
View File
@@ -13,42 +13,42 @@
"@astrolib/seo": "^1.0.0-beta.8",
"@blitzkit/core": "workspace:*",
"@blitzkit/i18n": "workspace:*",
"@fontsource/inter": "^5.2.5",
"@nanostores/persistent": "^0.10.2",
"@nanostores/react": "^0.7.3",
"@nivo/bar": "^0.87.0",
"@nivo/line": "^0.88.0",
"varuna": "workspace:*",
"@fontsource/inter": "^5.2.8",
"@nanostores/persistent": "^1.1.0",
"@nanostores/react": "^1.0.0",
"@nivo/bar": "^0.99.0",
"@nivo/line": "^0.99.0",
"@radix-ui/colors": "^3.0.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/themes": "^3.1.4",
"@react-three/drei": "^9.115.0",
"@react-three/fiber": "^8.17.10",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/themes": "^3.2.1",
"@react-three/drei": "^10.7.6",
"@react-three/fiber": "^9.3.0",
"compare-versions": "^6.1.1",
"echarts": "^5.5.1",
"immer": "^10.1.1",
"echarts": "^6.0.0",
"immer": "^10.1.3",
"js-cookie": "^3.0.5",
"markdown-to-jsx": "^7.5.0",
"nanostores": "^0.11.3",
"markdown-to-jsx": "^7.7.13",
"nanostores": "^1.0.1",
"quicklime": "^1.2.0",
"react": "^18.3.1",
"react": "^19.1.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.3.1",
"react-dom": "^19.1.1",
"react-promise-suspense": "^0.3.4",
"three": "^0.169.0",
"urlon": "^3.1.0",
"zustand": "^4.5.5"
"three": "^0.180.0",
"urlon": "^3.1.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/react": "^3.6.2",
"@astrojs/sitemap": "^3.2.1",
"@playform/compress": "^0.1.9",
"@astrojs/react": "^4.4.0",
"@astrojs/sitemap": "^3.6.0",
"@playform/compress": "^0.2.0",
"@playform/inline": "^0.1.2",
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.160.0",
"astro": "^4.16.7",
"astro-compressor": "^1.0.0"
"@types/react": "^19.1.14",
"@types/react-dom": "^19.1.9",
"@types/three": "^0.180.0",
"astro": "^5.14.1",
"astro-compressor": "^1.1.2"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

After

Width:  |  Height:  |  Size: 669 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 692 B

+47
View File
@@ -0,0 +1,47 @@
<svg width="426" height="426" viewBox="0 0 426 426" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M416.628 244.997L235.625 372.974C223.13 381.809 202.87 381.809 190.375 372.974L9.37173 244.997C3.1239 240.58 -3.95076e-06 234.79 0 229V196.6H426V229C426 234.79 422.876 240.58 416.628 244.997Z" fill="url(#paint0_linear_544_230)"/>
<path d="M416.628 244.997L235.625 372.974C223.13 381.809 202.87 381.809 190.375 372.974L9.37173 244.997C3.1239 240.58 -3.95076e-06 234.79 0 229V196.6H426V229C426 234.79 422.876 240.58 416.628 244.997Z" fill="url(#paint1_linear_544_230)"/>
<path d="M416.628 180.603L235.625 52.6262C223.13 43.7913 202.87 43.7913 190.375 52.6262L9.37173 180.603C-3.12391 189.438 -3.12391 203.762 9.37173 212.597L190.375 340.574C202.87 349.409 223.13 349.409 235.625 340.574L416.628 212.597C429.124 203.762 429.124 189.438 416.628 180.603Z" fill="#D9D9D9"/>
<path d="M416.628 180.603L235.625 52.6262C223.13 43.7913 202.87 43.7913 190.375 52.6262L9.37173 180.603C-3.12391 189.438 -3.12391 203.762 9.37173 212.597L190.375 340.574C202.87 349.409 223.13 349.409 235.625 340.574L416.628 212.597C429.124 203.762 429.124 189.438 416.628 180.603Z" fill="url(#paint2_linear_544_230)"/>
<path d="M416.628 180.603L235.625 52.6262C223.13 43.7913 202.87 43.7913 190.375 52.6262L9.37173 180.603C-3.12391 189.438 -3.12391 203.762 9.37173 212.597L190.375 340.574C202.87 349.409 223.13 349.409 235.625 340.574L416.628 212.597C429.124 203.762 429.124 189.438 416.628 180.603Z" fill="url(#paint3_linear_544_230)"/>
<g filter="url(#filter0_d_544_230)">
<path d="M197.056 150.108L290.596 213.945L310.832 199.599L289.153 184.23L288.86 177.31L335.236 182.298L359.102 165.378L288.049 158.111L286.355 118L263.968 133.871L265.867 167.722L217.292 135.762L197.056 150.108Z" fill="url(#paint4_linear_544_230)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M192.905 289L70 201.714L116.393 168.767C129.906 159.17 143.124 153.539 156.048 151.875C168.971 150.211 180.919 153.275 191.893 161.069C198.702 165.905 202.718 171.006 203.941 176.372C205.165 181.658 204.302 186.506 201.354 190.918L201.692 191.158C208.978 188.301 216.935 187.286 225.563 188.113C234.247 188.9 242.613 192.151 250.66 197.866C261.803 205.779 266.398 214.825 264.446 225.004C262.551 235.142 254.96 244.929 241.672 254.366L192.905 289ZM221.999 242.08L200.456 257.38L163.905 231.422L185.448 216.123C192.007 211.465 198.473 209.23 204.846 209.42C211.22 209.529 217.193 211.562 222.764 215.519C228.392 219.516 231.31 223.797 231.521 228.364C231.732 232.85 228.557 237.422 221.999 242.08ZM165.937 205.64L146.77 219.252L114.693 196.472L133.861 182.859C140.363 178.241 146.435 175.887 152.076 175.798C157.717 175.708 163.07 177.462 168.135 181.059C173.256 184.696 175.809 188.557 175.796 192.643C175.726 196.69 172.44 201.022 165.937 205.64Z" fill="url(#paint5_linear_544_230)"/>
</g>
<defs>
<filter id="filter0_d_544_230" x="51.6" y="107.6" width="325.902" height="207.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="8"/>
<feGaussianBlur stdDeviation="9.2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_544_230"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_544_230" result="shape"/>
</filter>
<linearGradient id="paint0_linear_544_230" x1="127.008" y1="227.288" x2="174.926" y2="391.583" gradientUnits="userSpaceOnUse">
<stop stop-color="#0F3F7D"/>
<stop offset="1" stop-color="#10284C"/>
</linearGradient>
<linearGradient id="paint1_linear_544_230" x1="213" y1="196.6" x2="213" y2="379.6" gradientUnits="userSpaceOnUse">
<stop stop-color="#10243E"/>
<stop offset="1" stop-color="#0F1B2D"/>
</linearGradient>
<linearGradient id="paint2_linear_544_230" x1="213" y1="46" x2="213" y2="347.2" gradientUnits="userSpaceOnUse">
<stop stop-color="#10243E"/>
<stop offset="1" stop-color="#0F1B2D"/>
</linearGradient>
<linearGradient id="paint3_linear_544_230" x1="127.008" y1="96.51" x2="241.48" y2="334.974" gradientUnits="userSpaceOnUse">
<stop stop-color="#0F3F7D"/>
<stop offset="1" stop-color="#10284C"/>
</linearGradient>
<linearGradient id="paint4_linear_544_230" x1="318.524" y1="40.5884" x2="402.41" y2="158.706" gradientUnits="userSpaceOnUse">
<stop offset="0.34" stop-color="#EAF6FF"/>
<stop offset="0.64" stop-color="#97CBFF"/>
</linearGradient>
<linearGradient id="paint5_linear_544_230" x1="318.524" y1="40.5884" x2="402.41" y2="158.706" gradientUnits="userSpaceOnUse">
<stop offset="0.34" stop-color="#EAF6FF"/>
<stop offset="0.64" stop-color="#97CBFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

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