Compare commits

...
75 Commits
Author SHA1 Message Date
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
205 changed files with 4835 additions and 4880 deletions

No files matched your search

+13
View File
@@ -22,6 +22,19 @@ env:
PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }} PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }}
PUBLIC_ASSET_FAST_USER: ${{ secrets.PUBLIC_ASSET_FAST_USER }} PUBLIC_ASSET_FAST_USER: ${{ secrets.PUBLIC_ASSET_FAST_USER }}
PUBLIC_ASSET_FAST_REPO: ${{ secrets.PUBLIC_ASSET_FAST_REPO }} PUBLIC_ASSET_FAST_REPO: ${{ secrets.PUBLIC_ASSET_FAST_REPO }}
PUBLIC_PROMOTE_OPENTEST: ${{ secrets.PUBLIC_PROMOTE_OPENTEST }}
ALLOW_ROBOTS: ${{ secrets.ALLOW_ROBOTS }}
WOTB_GLOSSARY: ${{ secrets.WOTB_GLOSSARY }}
PUBLIC_GOOGLE_TAG_MANAGER_ID: ${{ secrets.PUBLIC_GOOGLE_TAG_MANAGER_ID }}
PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ${{ secrets.PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID }}
GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}
WOTB_GLOSSARY_DOMAIN: ${{ secrets.WOTB_GLOSSARY_DOMAIN }}
WOTB_GLOSSARY_PATH: ${{ secrets.WOTB_GLOSSARY_PATH }}
PUBLIC_DISCORD_SERVER_ID: ${{ secrets.PUBLIC_DISCORD_SERVER_ID }}
PUBLIC_ASSET_BASE: ${{ secrets.PUBLIC_ASSET_BASE }}
PUBLIC_BRANCH: ${{ secrets.PUBLIC_BRANCH }}
PUBLIC_GUIDE_WEBSITE: ${{ secrets.PUBLIC_GUIDE_WEBSITE }}
SERVICING: ${{ secrets.SERVICING }}
jobs: jobs:
host: host:
-7
View File
@@ -1,7 +0,0 @@
{
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 80
}
+3 -7
View File
@@ -167,6 +167,7 @@
"searchtanks", "searchtanks",
"SHAREDEXP", "SHAREDEXP",
"SKLLD", "SKLLD",
"smoothstep",
"Smri", "Smri",
"Specularity", "Specularity",
"SSAO", "SSAO",
@@ -222,12 +223,7 @@
"yolo", "yolo",
"youtuber", "youtuber",
"youtubers", "youtubers",
"zoomable", "zoomable"
"zustand"
], ],
"deno.enable": false, "typescript.tsdk": "node_modules\\typescript\\lib"
"deno.unstable": false,
"cSpell.enableFiletypes": ["glsl", "proto3"],
"typescript.tsdk": "node_modules\\typescript\\lib",
"liveServer.settings.multiRootWorkspaceName": "blitzkit"
} }
BIN
View File
Binary file not shown.
+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
+4 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "blitzkit", "name": "blitzkit",
"private": true, "private": true,
"version": "1.27.17", "version": "1.27.20",
"description": "🎉 Tools for everything World of Tanks Blitz", "description": "🎉 Tools for everything World of Tanks Blitz",
"type": "module", "type": "module",
"types": "./types.d.ts", "types": "./types.d.ts",
@@ -16,6 +16,7 @@
"build:prisma": "prisma generate", "build:prisma": "prisma generate",
"build:sitemap": "bun scripts/sitemap.ts", "build:sitemap": "bun scripts/sitemap.ts",
"build:docs": "vitepress build docs", "build:docs": "vitepress build docs",
"build:proxyclient": "cd packages/scripts && bun run build:proxyclient",
"build:ratingleaderboard": "cd packages/scripts && bun run build:ratingleaderboard", "build:ratingleaderboard": "cd packages/scripts && bun run build:ratingleaderboard",
"dev:bot": "bun run build:bot --dev && bun start:bot", "dev:bot": "bun run build:bot --dev && bun start:bot",
"start:bot": "bun dist/bot/main.js", "start:bot": "bun dist/bot/main.js",
@@ -41,6 +42,8 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/xmpp__client": "^0.13.3",
"@xmpp/client": "^0.13.6",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
"googleapis": "^144.0.0", "googleapis": "^144.0.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
+12 -14
View File
@@ -1,8 +1,8 @@
import { ReadStream } from '@blitzkit/core'; import { ReadStream } from "@blitzkit/core";
import AdmZip from 'adm-zip'; import AdmZip from "adm-zip";
import { readFile, writeFile } from 'fs/promises'; import { readFile } from "fs/promises";
import { Parser } from 'pickleparser'; import { Parser } from "pickleparser";
import protobuf from 'protobufjs'; import protobuf from "protobufjs";
interface WotbReplayMetaJson { interface WotbReplayMetaJson {
version: string; version: string;
@@ -24,26 +24,24 @@ export class WotbReplayReadStream extends ReadStream {
async wotbReplay() { async wotbReplay() {
const pickleParser = new Parser(); const pickleParser = new Parser();
const zip = new AdmZip(Buffer.from(this.buffer)); const zip = new AdmZip(Buffer.from(this.buffer));
const metaJsonBuffer = zip.getEntry('meta.json')?.getData(); const metaJsonBuffer = zip.getEntry("meta.json")?.getData();
const battleResultsDat = zip.getEntry('battle_results.dat')?.getData(); const battleResultsDat = zip.getEntry("battle_results.dat")?.getData();
if (!metaJsonBuffer || !battleResultsDat) if (!metaJsonBuffer || !battleResultsDat)
throw new SyntaxError('meta.json not found'); throw new SyntaxError("meta.json not found");
const metaJson = JSON.parse( const metaJson = JSON.parse(
metaJsonBuffer.toString(), metaJsonBuffer.toString()
) as WotbReplayMetaJson; ) as WotbReplayMetaJson;
const unpickledBattleResults = pickleParser.parse(battleResultsDat); const unpickledBattleResults = pickleParser.parse(battleResultsDat);
const [, battleResultsString] = unpickledBattleResults as [number, string]; const [, battleResultsString] = unpickledBattleResults as [number, string];
const root = await protobuf.load( const root = await protobuf.load(
`${__dirname}/../../protos/BattleResultsClient.proto`, `${__dirname}/../../protos/BattleResultsClient.proto`
); );
const BattleResults = root.lookupType( const BattleResults = root.lookupType(
'BattleResultsGenerated.BattleResultsClient', "BattleResultsGenerated.BattleResultsClient"
); );
writeFile('test.dat', Buffer.from(battleResultsString));
const reader = protobuf.Reader.create(Buffer.from(battleResultsString)); const reader = protobuf.Reader.create(Buffer.from(battleResultsString));
// const decodedBattleResults = BattleResults.decodeDelimited( // const decodedBattleResults = BattleResults.decodeDelimited(
@@ -53,7 +51,7 @@ export class WotbReplayReadStream extends ReadStream {
} }
} }
const replay = await readFile('temp/test3.wotbreplay'); const replay = await readFile("temp/test3.wotbreplay");
const stream = new WotbReplayReadStream(replay.buffer as ArrayBuffer); const stream = new WotbReplayReadStream(replay.buffer as ArrayBuffer);
stream.wotbReplay(); stream.wotbReplay();
+21 -14
View File
@@ -763,6 +763,8 @@
}, },
"tank_search": { "tank_search": {
"select_dialog_title": "Search for tanks",
"swap_dialog_title": "Swap tank",
"clear_filters": "Clear filters", "clear_filters": "Clear filters",
"search_bar_hint": "Search tanks...", "search_bar_hint": "Search tanks...",
"recent": "Recently viewed", "recent": "Recently viewed",
@@ -861,6 +863,15 @@
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz",
"screenshot": "Screenshot",
"randomize": "Randomize"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -887,6 +898,10 @@
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.", "tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -935,9 +950,7 @@
"green_penetration": "Green penetration", "green_penetration": "Green penetration",
"hide_model_under_armor": "Hide model under armor", "hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque", "opaque": "Opaque",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Environment",
"show_grid": "Show grid"
}, },
"screenshot": { "screenshot": {
@@ -1437,17 +1450,11 @@
"description": "Get started with BlitzKit", "description": "Get started with BlitzKit",
"seo_description": "Get started with BlitzKit" "seo_description": "Get started with BlitzKit"
}, },
"blitz_analysis": { "api": {
"name": "BlitzAnalysis[]", "name": "API",
"button": "Analyze now", "button": "To the docs",
"description": "Rich historic average stats", "description": "BlitzKit hosted API",
"seo_description": "Rich historic average stats in World of Tanks Blitz" "seo_description": "BlitzKit hosted static API for World of Tanks Blitz"
},
"blitz_insider": {
"name": "Blitz Insider",
"button": "Discover today",
"description": "In-game session stats",
"seo_description": "In-game session stats in World of Tanks Blitz"
} }
}, },
+10 -3
View File
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Nivel %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)", "tank_seo_title": "%s1 - Nivel %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 blindaje, estadisticas, equipamiento, proviciones y mas.\n%s2 DPM, %3hp alfa, %s4mm penetracion, %s5kph velocidad maxima, %s6 hp salud", "tank_seo_description": "%s1 blindaje, estadisticas, equipamiento, proviciones y mas.\n%s2 DPM, %3hp alfa, %s4mm penetracion, %s5kph velocidad maxima, %s6 hp salud",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -829,9 +838,7 @@
"green_penetration": "Penetración verde", "green_penetration": "Penetración verde",
"hide_model_under_armor": "Ocultar modelo bajo la armadura", "hide_model_under_armor": "Ocultar modelo bajo la armadura",
"opaque": "Opaco", "opaque": "Opaco",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Entorno",
"show_grid": "Mostrar cuadrícula"
}, },
"screenshot": { "screenshot": {
"download": "Descargar", "download": "Descargar",
+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.", "non_tech_tree": "# %s1 n'est pas dans l'arbre technologique\n\nVous ne pouvez pas rechercher un char prenium/de collection.",
"tier_1": "# %s1 est un char de tier I\n\nLe char de tier 1 sont déverrouillé gratuitement pour tous les joueurs.", "tier_1": "# %s1 est un char de tier I\n\nLe char de tier 1 sont déverrouillé gratuitement pour tous les joueurs.",
"already_researched": "# %s1 est déjà recherché\n\nEssayez de fournir un `char de départ` en utilisant la commande pour voir la progression d'une lignée.", "already_researched": "# %s1 est déjà recherché\n\nEssayez de fournir un `char de départ` en utilisant la commande pour voir la progression d'une lignée.",
"start_end_equal": "# %s1 was provided twice\n\nMake sure `starting-tank` and `target-tank` are different.", "start_end_equal": "# %s1 a été fourni deux fois\n\nAssurez-vous que `starting-tank` et `target-tank` sont différents.",
"tanks_not_on_line": "# %s1 and %s2 are not on the same line\n\nMake sure `starting-tank` and `target-tank` are on the same tech-tree line.", "tanks_not_on_line": "%s1 et %s2 ne sont pas sur la même ligne \n\nAssurez-vous que « starting-tank » et « target-tank » sont sur la même ligne d'arbre technologique.",
"unordered": "# %s1 higher tiered to %s2\n\nMake sure to provide %s3 as the `starting-tank` instead as it is the lower tiered tank." "unordered": "# %s1 niveau supérieur à %s2\n\nAssurez-vous de fournir %s3 comme le `starting-tank` à la place car c'est le réservoir de niveau inférieur."
} }
}, },
"about": { "about": {
@@ -380,7 +380,7 @@
}, },
"embeds": { "embeds": {
"name": "intègre", "name": "intègre",
"description": "How embeds work" "description": "Comment fonctionnent les intégrations"
}, },
"introduction": { "introduction": {
"name": "introduction", "name": "introduction",
@@ -402,7 +402,7 @@
}, },
"breakdown": { "breakdown": {
"name": "décomposition", "name": "décomposition",
"description": "A period's breakdown by tanks played", "description": "Répartition d'une période par chars joués",
"body": { "body": {
"total": "Total", "total": "Total",
"battles": "Batailles", "battles": "Batailles",
@@ -471,7 +471,7 @@
"accuracy": "Précision" "accuracy": "Précision"
}, },
"lethality": { "lethality": {
"title": "Lethality", "title": "Létalité",
"average_damage": "Dégâts moyens", "average_damage": "Dégâts moyens",
"damage_per_tier": "Dégâts par tier", "damage_per_tier": "Dégâts par tier",
"damage_ratio": "Rapport de dégâts", "damage_ratio": "Rapport de dégâts",
@@ -499,20 +499,20 @@
}, },
"embed": { "embed": {
"title": "Compte invalide ?", "title": "Compte invalide ?",
"description": "Use the command again and select a username from the search results." "description": "Utilisez à nouveau la commande et sélectionnez un nom d'utilisateur à partir des résultats de recherche."
} }
}, },
"search_clans": { "search_clans": {
"name": "search-clans", "name": "clans de recherche",
"description": "Search clans", "description": "Rechercher des clans",
"options": { "options": {
"limit": { "limit": {
"name": "limite", "name": "limite",
"description": "The max number of clan results (default: %s1)" "description": "Nombre maximum de résultats du clan (par défaut: %s1)"
} }
}, },
"body": { "body": {
"title": "Clan search for %s1 in region %s2", "title": "Recherche de clan %s1 dans la région %s2",
"no_results": "Pas de clan trouvé" "no_results": "Pas de clan trouvé"
} }
}, },
@@ -535,69 +535,69 @@
"description": "Statistiques des batailles de cotés", "description": "Statistiques des batailles de cotés",
"subcommands": { "subcommands": {
"neighbors": { "neighbors": {
"name": "neighbors", "name": "voisins",
"description": "Neighboring players on the leaderboard" "description": "Joueurs voisins dans le classement"
}, },
"league": { "league": {
"name": "ligue", "name": "ligue",
"description": "Top players of a league" "description": "Meilleurs joueurs d'une ligue"
} }
}, },
"options": { "options": {
"limit": { "limit": {
"name": "limite", "name": "limite",
"description": "How many neighbors to display (default: %s1)" "description": "Combien de voisins afficher (par défaut : %s1)"
}, },
"season": { "season": {
"name": "season", "name": "saison",
"description": "Season number (default: current)", "description": "Numéro de saison (défaut : courant)",
"choices": { "choices": {
"current": "%s1 (current)" "current": "%s1 (actuel)"
} }
}, },
"league": { "league": {
"name": "ligue", "name": "ligue",
"description": "The league to display" "description": "La ligue à afficher"
} }
}, },
"body": { "body": {
"top_players": "Top %s1 players", "top_players": "Top %s1 joueurs",
"no_participation": "# %s1 didn't play rating in season %s2\n\nThis player did not participate in this season or did not get past calibration.", "no_participation": "%s1 n'a pas joué au classement dans la saison %s2",
"no_ongoing_season": "# No ongoing season\n\nThere isn't a currently running rating season.", "no_ongoing_season": "Pas de saison en cours\n\nIl n'y a pas de saison de notation en cours actuellement.",
"deleted_player": "Deleted %s1", "deleted_player": "%s1 supprimé",
"neighbors": "Rating neighbors", "neighbors": "évaluer les voisins",
"more": "+ %s1 more" "more": "+ %s1 de plus"
} }
}, },
"today": { "today": {
"name": "aujourd'hui", "name": "aujourd'hui",
"description": "Today's played tanks and statistics" "description": "Les chars et les statistiques joués aujourd'hui"
}, },
"stats": { "stats": {
"name": "statistiques", "name": "statistiques",
"description": "Quick statistics for a period" "description": "Statistiques rapides pour une période"
}, },
"owned_tanks": { "owned_tanks": {
"name": "owned-tanks", "name": "chars possédés",
"description": "Shows all own tanks in a tier", "description": "Affiche tous les chars d'un niveau",
"body": { "body": {
"title": "# %s1's owned tier %s2 tanks\n\n" "title": "# %s1 possédait des chars de rang %s2"
} }
}, },
"permissions": { "permissions": {
"name": "permissions", "name": "permissions",
"description": "Checks wether the bot has the required permissions to function", "description": "Vérifie si le bot a les permissions nécessaires pour fonctionner",
"body": { "body": {
"title": "# Permissions\n\n%s1", "title": "# Permissions\n\n%s1",
"view_channels": "View channels: needed to find the message the refresh button was used on", "view_channels": "Afficher les canaux : nécessaire pour trouver le message sur lequel le bouton d'actualisation a été utilisé",
"read_message_history": "Read message history: needed to find the message to refresh", "read_message_history": "Lire l'historique des messages : nécessaire pour trouver le message à rafraîchir",
"attach_files": "Attach files: needed to add images to image-based commands", "attach_files": "Joindre des fichiers : nécessaire pour ajouter des images à des commandes basées sur l'image",
"external_emojis": "External emojis: needed to add national flags and tank class emojis" "external_emojis": "Émoticônes externes: nécessaire pour ajouter des drapeaux nationaux et des émojis de classe de char"
} }
}, },
"ping": { "ping": {
"name": "ping", "name": "ping",
"description": "Check wether the bot is alive.", "description": "Vérifiez si le bot est en vie.",
"body": { "body": {
"pong": "Pong 🏓", "pong": "Pong 🏓",
"pong_with_time": "Pong 🏓 - %s1ms" "pong_with_time": "Pong 🏓 - %s1ms"
@@ -605,7 +605,7 @@
"subcommands": { "subcommands": {
"blitzkit": { "blitzkit": {
"name": "blitzkit", "name": "blitzkit",
"description": "Ping BlitzKit" "description": "Ping blitzkit"
}, },
"wotb": { "wotb": {
"name": "wotb", "name": "wotb",
@@ -614,10 +614,10 @@
} }
}, },
"player_info": { "player_info": {
"name": "player-info", "name": "info-joueur",
"description": "Basic information about a player", "description": "Informations de base sur un joueur",
"body": { "body": {
"title": "%s1's information", "title": "Informations de %s1",
"nickname": "Nickname", "nickname": "Nickname",
"battles": "Batailles", "battles": "Batailles",
"winrate": "Taux de victoires", "winrate": "Taux de victoires",
@@ -635,7 +635,7 @@
"description": "Today's rating statistics" "description": "Today's rating statistics"
}, },
"season": { "season": {
"name": "season", "name": "saison",
"description": "This season's rating statistics" "description": "This season's rating statistics"
} }
}, },
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)", "tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.", "tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -829,9 +838,7 @@
"green_penetration": "Green penetration", "green_penetration": "Green penetration",
"hide_model_under_armor": "Hide model under armor", "hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque", "opaque": "Opaque",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Environment",
"show_grid": "Show grid"
}, },
"screenshot": { "screenshot": {
"download": "Download", "download": "Download",
+10 -3
View File
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)", "tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.", "tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -829,9 +838,7 @@
"green_penetration": "貫通可能域を緑色表示", "green_penetration": "貫通可能域を緑色表示",
"hide_model_under_armor": "車輌モデルを非表示", "hide_model_under_armor": "車輌モデルを非表示",
"opaque": "貫通アシストを不透明にする", "opaque": "貫通アシストを不透明にする",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "環境",
"show_grid": "グリッドを表示"
}, },
"screenshot": { "screenshot": {
"download": "保存する", "download": "保存する",
+10 -3
View File
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)", "tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.", "tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -829,9 +838,7 @@
"green_penetration": "Green penetration", "green_penetration": "Green penetration",
"hide_model_under_armor": "Hide model under armor", "hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque", "opaque": "Opaque",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Environment",
"show_grid": "Show grid"
}, },
"screenshot": { "screenshot": {
"download": "Download", "download": "Download",
+10 -3
View File
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Nível %s2 %s3 %s4 | %s5 Tanquepedia | World of Tanks Blitz (WoTB)", "tank_seo_title": "%s1 - Nível %s2 %s3 %s4 | %s5 Tanquepedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 blindagem, estatísticas, equipamentos, provisões e mais. %s2 DPM, %s3hp alfa, %s4mm de penetração, %s5km/h velocidade máxima, %s6hp vida.", "tank_seo_description": "%s1 blindagem, estatísticas, equipamentos, provisões e mais. %s2 DPM, %s3hp alfa, %s4mm de penetração, %s5km/h velocidade máxima, %s6hp vida.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -829,9 +838,7 @@
"green_penetration": "Penetração verde", "green_penetration": "Penetração verde",
"hide_model_under_armor": "Ocultar modelo sob blindagem", "hide_model_under_armor": "Ocultar modelo sob blindagem",
"opaque": "Opaco", "opaque": "Opaco",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Ambiente",
"show_grid": "Exibir grade"
}, },
"screenshot": { "screenshot": {
"download": "Download", "download": "Download",
+59 -52
View File
@@ -1,8 +1,8 @@
{ {
"test": { "test": {
"default": "тест", "default": "тест",
"long": "Это длинная тестовая строка. Вы НИКОГДА не должны видеть её в пользовательском интерфейсе. Мой любимый фрукт - %s1, потому что его цвет %s2. Он мне так нравится, что я произнесу его имя 3 раза! %s1! %s1! %s1! И ещё раз, это %s2, а не %s3.", "long": "Это длинная тестовая строка. Вы НИКОГДА не должны видеть её в пользовательском интерфейсе. Мой любимый фрукт — %s1, потому что это цвет %s2. Он мне так нравится, что я произнесу его имя 3 раза! %s1! %s1! %s1! И ещё раз, это %s2, а не %s3.",
"fox": "Это длинная тестовая строка. Вы НИКОГДА не должны видеть её в пользовательском интерфейсе. Мой любимый фрукт - %s1, потому что его цвет %s2. Он мне так нравится, что я произнесу его имя 3 раза! %s1! %s1! %s1! И ещё раз, это %s2, а не %s3." "fox": "Съешь же ещё этих мягких французских булок, да выпей чаю."
}, },
"common": { "common": {
"units": { "units": {
@@ -283,15 +283,15 @@
"name": "уровень", "name": "уровень",
"description": "Уровень", "description": "Уровень",
"choices": { "choices": {
"tier": "Уровень %s - %s" "tier": "Уровень %s1 - %s2"
} }
} }
}, },
"periods": { "periods": {
"career": "Карьера", "career": "Карьера",
"today": "Cегодня", "today": "Cегодня",
"days": "%s дней", "days": "%s1 дней",
"custom": "%s - %s дней" "custom": "%s1 - %s2 дней"
}, },
"filters": { "filters": {
"none": "Без фильтров", "none": "Без фильтров",
@@ -323,9 +323,9 @@
"description": "Танки доступные на аукционе сейчас", "description": "Танки доступные на аукционе сейчас",
"body": { "body": {
"title": "Blitz аукцион", "title": "Blitz аукцион",
"subtitle": "%s доступные • следующие цены %s\nПокупка сейчас: %s", "subtitle": "%s1 доступные • следующие цены %s2\nПокупка сейчас: %s3",
"next": "Следующая цена: %s", "next": "Следующая цена: %s1",
"available": "Доступно: %s / %s" "available": "Доступно: %s1 / %s2"
}, },
"errors": { "errors": {
"no_auction": "# Аукцион сейчас не проходит\n\nНа данный момент нет танков для покупки на аукционе. Если считаете, что это ошибка, пожалуйста о несоответствии на [официальном сервере Discord](https://discord.gg/nDt7AjGJQH)." "no_auction": "# Аукцион сейчас не проходит\n\nНа данный момент нет танков для покупки на аукционе. Если считаете, что это ошибка, пожалуйста о несоответствии на [официальном сервере Discord](https://discord.gg/nDt7AjGJQH)."
@@ -354,19 +354,19 @@
"body": { "body": {
"subtitle": "Исследование", "subtitle": "Исследование",
"total": "Всего", "total": "Всего",
"research": "Исследование: %s", "research": "Исследование: %s1",
"upgrades": "Улучшения: %s", "upgrades": "Улучшения: %s1",
"purchase": "Покупка: %s", "purchase": "Покупка: %s1",
"equipment": "Снаряжение: %s", "equipment": "Снаряжение: %s1",
"estimation": "# Это приблизительная оценка!\n\nВ этот список могут входить уже исследованные танки из-за неточной информации, предоставляемой Wargaming. Попробуйте указать «стартовый-танк» в строке при использовании команды, если сможете." "estimation": "# Это приблизительная оценка!\n\nВ этот список могут входить уже исследованные танки из-за неточной информации, предоставляемой Wargaming. Попробуйте указать «стартовый-танк» в строке при использовании команды, если сможете."
}, },
"errors": { "errors": {
"non_tech_tree": "# %s отсутствует в дереве танков\n\nВы не можете исследовать премиум / коллекционный танк.", "non_tech_tree": "# %s1 отсутствует в дереве танков\n\nВы не можете исследовать премиум / коллекционный танк.",
"tier_1": "# %s - это уровень I\n\nУровень I всегда открыт бесплатно для всех игроков.", "tier_1": "# %s1 - это уровень I\n\nУровень I всегда открыт бесплатно для всех игроков.",
"already_researched": "# %s уже исследован\n\nПопробуйте предоставить «starting-tank» при использовании команды для просмотра итогов уже исследованной ветки.", "already_researched": "# %s1 уже исследован\n\nПопробуйте предоставить «starting-tank» при использовании команды для просмотра итогов уже исследованной ветки.",
"start_end_equal": "# %s был предоставлен дважды\n\nУбедитесь, что «starting-tank» и «target-tank» различаются.", "start_end_equal": "# %s1 был предоставлен дважды\n\nУбедитесь, что «starting-tank» и «target-tank» различаются.",
"tanks_not_on_line": "# %s и %s находятся не в одной ветке\n\nУбедитесь, что «starting-tank» и «target-tank» находятся на той же ветке дерева танков.", "tanks_not_on_line": "# %s1 и %s2 находятся не в одной ветке\n\nУбедитесь, что «starting-tank» и «target-tank» находятся на той же ветке дерева танков.",
"unordered": "# У %s более высокий уровень чем у %s\n\nОбязательно укажите %s в качестве «starting-tank», так как это танк более низкого уровня." "unordered": "# У %s1 более высокий уровень чем у %s2\n\nОбязательно укажите %s3 в качестве «starting-tank», так как это танк более низкого уровня."
} }
}, },
"about": { "about": {
@@ -409,7 +409,7 @@
"wn8": "WN8", "wn8": "WN8",
"winrate": "Коэффициент побед", "winrate": "Коэффициент побед",
"damage": "Урон", "damage": "Урон",
"unknown_tank": "Неизвестный танк %s" "unknown_tank": "Неизвестный танк %s1"
} }
}, },
"debug": { "debug": {
@@ -445,12 +445,12 @@
"options": { "options": {
"threshold": { "threshold": {
"name": "порог", "name": "порог",
"description": "Количество дней не активности (по умолчанию: %s дней)" "description": "Количество дней не активности (по умолчанию: %s1 дней)"
} }
}, },
"body": { "body": {
"subtitle": "Неактивен %s+ дней", "subtitle": "Неактивен %s1+ дней",
"listing": "%s дней" "listing": "%s1 дней"
} }
}, },
"full_stats": { "full_stats": {
@@ -508,11 +508,11 @@
"options": { "options": {
"limit": { "limit": {
"name": "лимит", "name": "лимит",
"description": "Максимальное количество результатов клана (по умолчанию: %s)" "description": "Максимальное количество результатов клана (по умолчанию: %s1)"
} }
}, },
"body": { "body": {
"title": "Поиск клана %s в регионе %s", "title": "Поиск клана %s1 в регионе %s2",
"no_results": "Кланов не найдено" "no_results": "Кланов не найдено"
} }
}, },
@@ -522,11 +522,11 @@
"options": { "options": {
"limit": { "limit": {
"name": "лимит", "name": "лимит",
"description": "Максимальное число результатов игрока (по умолчанию: %s)" "description": "Максимальное число результатов игрока (по умолчанию: %s1)"
} }
}, },
"body": { "body": {
"title": "Поиск игрока %s в регионе %s", "title": "Поиск игрока %s1 в регионе %s2",
"no_results": "Игроков не найдено" "no_results": "Игроков не найдено"
} }
}, },
@@ -546,13 +546,13 @@
"options": { "options": {
"limit": { "limit": {
"name": "лимит", "name": "лимит",
"description": "Количество отображаемых ближайших игроков (по умолчанию: %s)" "description": "Количество отображаемых ближайших игроков (по умолчанию: %s1)"
}, },
"season": { "season": {
"name": "сезон", "name": "сезон",
"description": "Номер сезона (по умолчанию: текущий)", "description": "Номер сезона (по умолчанию: текущий)",
"choices": { "choices": {
"current": "%s (текущая)" "current": "%s1 (текущая)"
} }
}, },
"league": { "league": {
@@ -561,12 +561,12 @@
} }
}, },
"body": { "body": {
"top_players": "Топ %s игроков", "top_players": "Топ %s1 игроков",
"no_participation": "# %s не играл в рейтинг в сезоне %s\n\nЭтот игрок не участвовал в этом сезоне или не получил прошлую калибровку.", "no_participation": "# %s1 не играл в рейтинг в сезоне %s2\n\nЭтот игрок не участвовал в этом сезоне или не получил прошлую калибровку.",
"no_ongoing_season": "# Нет текущего сезона\n\nВ данный момент нет рейтингового сезона.", "no_ongoing_season": "# Нет текущего сезона\n\nВ данный момент нет рейтингового сезона.",
"deleted_player": "Удалено %s", "deleted_player": "Удалено %s1",
"neighbors": "Рейтинг ближайших игроков", "neighbors": "Рейтинг ближайших игроков",
"more": "+ ещё %s" "more": "+ ещё %s1"
} }
}, },
"today": { "today": {
@@ -581,14 +581,14 @@
"name": "cобственные-танки", "name": "cобственные-танки",
"description": "Показывает все принадлежащие танки на уровне", "description": "Показывает все принадлежащие танки на уровне",
"body": { "body": {
"title": "# %s принадлежал уровень %s танков\n\n" "title": "# %s1 принадлежал уровень %s2 танков\n\n"
} }
}, },
"permissions": { "permissions": {
"name": "разрешения", "name": "разрешения",
"description": "Проверяет, есть ли у бота необходимые разрешения для работы", "description": "Проверяет, есть ли у бота необходимые разрешения для работы",
"body": { "body": {
"title": "# Разрешения\n\n%s", "title": "# Разрешения\n\n%s1",
"view_channels": "Просмотр каналов: необходимо найти сообщение, на котором была использована кнопка обновления", "view_channels": "Просмотр каналов: необходимо найти сообщение, на котором была использована кнопка обновления",
"read_message_history": "Просмотр истории сообщений: необходимо найти сообщение для обновления", "read_message_history": "Просмотр истории сообщений: необходимо найти сообщение для обновления",
"attach_files": "Прикрепить файлы: необходимо для добавления изображений в команды на основе изображения", "attach_files": "Прикрепить файлы: необходимо для добавления изображений в команды на основе изображения",
@@ -600,7 +600,7 @@
"description": "Проверьте, что бот жив.", "description": "Проверьте, что бот жив.",
"body": { "body": {
"pong": "Понг 🏓", "pong": "Понг 🏓",
"pong_with_time": "Понг 🏓 - %sмс" "pong_with_time": "Понг 🏓 - %s1мс"
}, },
"subcommands": { "subcommands": {
"blitzkit": { "blitzkit": {
@@ -617,7 +617,7 @@
"name": "информация-об-игроке", "name": "информация-об-игроке",
"description": "Основная информация о игроке", "description": "Основная информация о игроке",
"body": { "body": {
"title": "Информация о %s", "title": "Информация о %s1",
"nickname": "Никнейм", "nickname": "Никнейм",
"battles": "Боёв", "battles": "Боёв",
"winrate": "Коэффициент побед", "winrate": "Коэффициент побед",
@@ -646,7 +646,7 @@
} }
}, },
"errors": { "errors": {
"no_participation": "# %s не получил рейтинга\n\nЭтот игрок в этом сезоне не участвовал в рейтинговом режиме.", "no_participation": "# %s1 не получил рейтинга\n\nЭтот игрок в этом сезоне не участвовал в рейтинговом режиме.",
"unrecorded_stats": "# Статистика недоступна в настоящее время\n\nВ настоящее время Wargaming, возможно, сталкивается с простоями сервера. Если сезон продолжается, статистика должна быть доступна в течение 24 часов." "unrecorded_stats": "# Статистика недоступна в настоящее время\n\nВ настоящее время Wargaming, возможно, сталкивается с простоями сервера. Если сезон продолжается, статистика должна быть доступна в течение 24 часов."
} }
} }
@@ -676,7 +676,7 @@
"recent": "Недавние", "recent": "Недавние",
"select_all": "Выбрать всё", "select_all": "Выбрать всё",
"count_singular": "1 танк", "count_singular": "1 танк",
"count_plural": "%s1 танк", "count_plural": "Танков: %s1",
"sorting_by": "Сортировать по %s1", "sorting_by": "Сортировать по %s1",
"no_tanks_found": { "no_tanks_found": {
"body": "Танков не найдено.", "body": "Танков не найдено.",
@@ -693,7 +693,7 @@
"survivability.volume": "объём", "survivability.volume": "объём",
"survivability.length": "длина", "survivability.length": "длина",
"fire.dpm": "базовый ДПМ", "fire.dpm": "базовый ДПМ",
"fire.dpmPremium": "улучшенный ДПМ", "fire.dpmPremium": "премиумный ДПМ",
"fire.reload": "перезарядка", "fire.reload": "перезарядка",
"fire.caliber": "калибр", "fire.caliber": "калибр",
"fire.standardPenetration": "базовое пробитие", "fire.standardPenetration": "базовое пробитие",
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Смешиватель",
"button": "Смешать и собрать",
"description": "Модули смешанного танка",
"seo_description": "Смешивание и сбор корпусов, башен и пушек танков из World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Угадайте танк", "name": "Угадайте танк",
"button": "Угадать", "button": "Угадать",
@@ -784,9 +790,12 @@
"button": "Поиск танков", "button": "Поиск танков",
"description": "Танковая энциклопедия Blitz", "description": "Танковая энциклопедия Blitz",
"seo_description": "Статистика, броня и многое другое по всем танкам World of Tanks Blitz", "seo_description": "Статистика, броня и многое другое по всем танкам World of Tanks Blitz",
"tank_seo_title": "%s1 - %s4 %s3 %s2 уровня | Танкопедия %s5 | World of Tanks Blitz (WoTB)", "tank_seo_title": "%s1 — %s4 %s3 %s2 уровня | Танкопедия %s5 | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 броня, статистика, оборудование, снабжение и многое другое. %s2 УВМ, %s3hp альфа, %s4mm пробитие, %s5км/ч лучшая скорость, %s6hp здоровье.", "tank_seo_description": "%s1 броня, статистика, оборудование, снабжение и многое другое. %s2 УВМ, %s3hp альфа, %s4mm пробитие, %s5км/ч лучшая скорость, %s6hp здоровье.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Пропуск"
},
"assault": { "assault": {
"title": "Штурмовые снаряды" "title": "Штурмовые снаряды"
}, },
@@ -812,7 +821,7 @@
"dead_rail": "Эшелон" "dead_rail": "Эшелон"
}, },
"flexibility": { "flexibility": {
"yaw": "Отклонение", "yaw": "Поворот",
"pitch": "Наклон", "pitch": "Наклон",
"min": "Вниз", "min": "Вниз",
"max": "Вверх" "max": "Вверх"
@@ -829,9 +838,7 @@
"green_penetration": "Зелёное пробитие", "green_penetration": "Зелёное пробитие",
"hide_model_under_armor": "Скрыть модель под броней", "hide_model_under_armor": "Скрыть модель под броней",
"opaque": "Прозрачность", "opaque": "Прозрачность",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Окружение",
"show_grid": "Показать сетку"
}, },
"screenshot": { "screenshot": {
"download": "Загрузить", "download": "Загрузить",
@@ -902,7 +909,7 @@
"tier": "Уровень", "tier": "Уровень",
"type": "Тип", "type": "Тип",
"dev_id": "DEV: ID", "dev_id": "DEV: ID",
"purchase": "Стоимость покупки", "purchase": "Цена покупки",
"restoration": "Цена восстановления", "restoration": "Цена восстановления",
"sale": "Цена продажи", "sale": "Цена продажи",
"research": "Необходимо опыта" "research": "Необходимо опыта"
@@ -978,7 +985,7 @@
"dpmEffective": "Эффективность за 60с", "dpmEffective": "Эффективность за 60с",
"shells": "Снарядов в барабане", "shells": "Снарядов в барабане",
"mostOptimalShellIndex": "Самый эффективный снаряд", "mostOptimalShellIndex": "Самый эффективный снаряд",
"shellReloads": "Reloads", "shellReloads": "Перезарядка",
"shellReload": "Перезарядка", "shellReload": "Перезарядка",
"intraClip": "Между снарядами", "intraClip": "Между снарядами",
"caliber": "Калибр", "caliber": "Калибр",
@@ -1038,8 +1045,8 @@
"shellCapacity": "Боезапас", "shellCapacity": "Боезапас",
"gunType": "Тип орудия", "gunType": "Тип орудия",
"penetrationAt250m": "Пробитие на 250м", "penetrationAt250m": "Пробитие на 250м",
"burstShells": "Burst shell count", "burstShells": "Кол-во разрывных снарядов",
"burstInterShell": "Burst inter-shell" "burstInterShell": "Взрыв между снарядами"
} }
}, },
"firepower": { "firepower": {
@@ -1136,7 +1143,7 @@
"hits": "Уничтожен", "hits": "Уничтожен",
"damage_ratio": "Коэф. урона", "damage_ratio": "Коэф. урона",
"damage_taken": "Получено урона", "damage_taken": "Получено урона",
"capture_points": "Ограничительные точки" "capture_points": "Точки ограничения"
} }
} }
}, },
@@ -1148,7 +1155,7 @@
"last_updated": "Последнее обновление %s1", "last_updated": "Последнее обновление %s1",
"search": { "search": {
"hint": "Поиск аватаров...", "hint": "Поиск аватаров...",
"results": "%s1 аватар" "results": "Аватаров: %s1"
} }
}, },
"session": { "session": {
@@ -1157,7 +1164,7 @@
"description": "Статистика сеанса игры", "description": "Статистика сеанса игры",
"seo_description": "Отслеживайте свою статистику в реальном времени во время игры World of Tanks Blitz", "seo_description": "Отслеживайте свою статистику в реальном времени во время игры World of Tanks Blitz",
"search": { "search": {
"lookup": "Искать игрока, чтобы начать", "lookup": "Введите ник игрока",
"hint": "Поиск игроков...", "hint": "Поиск игроков...",
"searching": "Поиск...", "searching": "Поиск...",
"no_results": "Игроков не найдено" "no_results": "Игроков не найдено"
@@ -1198,7 +1205,7 @@
"button": "Создать", "button": "Создать",
"description": "Встраиваемые элементы для стримов", "description": "Встраиваемые элементы для стримов",
"seo_description": "Создать вставки для стримов по World of Tanks Blitz с невероятной кастомизации", "seo_description": "Создать вставки для стримов по World of Tanks Blitz с невероятной кастомизации",
"coming_soon": "Скоро появятся новые встраиваемые элементы.", "coming_soon": "В скором времени появятся новые встраиваемые элементы.",
"suggest": "Предложить идеи.", "suggest": "Предложить идеи.",
"preview": { "preview": {
"title": "Предпросмотр", "title": "Предпросмотр",
+10 -3
View File
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Рівень %s2 %s3 %s4 | %s5 Танкопедія | WoT Blitz", "tank_seo_title": "%s1 - Рівень %s2 %s3 %s4 | %s5 Танкопедія | WoT Blitz",
"tank_seo_description": "%s1 броня, характеристики, обладнання, амуніція та більше. %s2 шкода на хвилину, %s3 разова шкода, %s4 мм пробиття, %s5 км/год максимальна швидкість, %s6 міцність.", "tank_seo_description": "%s1 броня, характеристики, обладнання, амуніція та більше. %s2 шкода на хвилину, %s3 разова шкода, %s4 мм пробиття, %s5 км/год максимальна швидкість, %s6 міцність.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -829,9 +838,7 @@
"green_penetration": "Зелене пробиття", "green_penetration": "Зелене пробиття",
"hide_model_under_armor": "Hide model under armor", "hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque", "opaque": "Opaque",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Environment",
"show_grid": "Show grid"
}, },
"screenshot": { "screenshot": {
"download": "Завантажити", "download": "Завантажити",
+10 -3
View File
@@ -764,6 +764,12 @@
} }
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "猜坦克", "name": "猜坦克",
"button": "猜猜看", "button": "猜猜看",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - %s2 阶 %s3 %s4 | %s5 战车百科 | 战车世界闪击战 (WoTB)", "tank_seo_title": "%s1 - %s2 阶 %s3 %s4 | %s5 战车百科 | 战车世界闪击战 (WoTB)",
"tank_seo_description": "%s1 装甲,统计设备,装备,供给等。 %s2 每分钟伤害, %s3生命值, %s4毫米穿透, %s5kph 最高速度, %s6hp 生命值。", "tank_seo_description": "%s1 装甲,统计设备,装备,供给等。 %s2 每分钟伤害, %s3生命值, %s4毫米穿透, %s5kph 最高速度, %s6hp 生命值。",
"visualizers": { "visualizers": {
"reload": {
"skip": "跳过"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -829,9 +838,7 @@
"green_penetration": "可击穿区域", "green_penetration": "可击穿区域",
"hide_model_under_armor": "隐藏装甲下的模型", "hide_model_under_armor": "隐藏装甲下的模型",
"opaque": "不透明", "opaque": "不透明",
"dev_wireframe": "开发选项:网格", "dev_wireframe": "开发选项:网格"
"environment": "环境",
"show_grid": "显示网格"
}, },
"screenshot": { "screenshot": {
"download": "下载", "download": "下载",
+1
View File
@@ -11,6 +11,7 @@
"build:ids": "bun src/discoverIds.ts", "build:ids": "bun src/discoverIds.ts",
"build:bot": "bun src/buildBot.ts", "build:bot": "bun src/buildBot.ts",
"build:ratingleaderboard": "bun src/buildRatingLeaderboard.ts", "build:ratingleaderboard": "bun src/buildRatingLeaderboard.ts",
"build:proxyclient": "bun src/buildProxyClient.ts",
"prunestrings": "bun src/pruneStrings.ts" "prunestrings": "bun src/pruneStrings.ts"
}, },
"dependencies": { "dependencies": {
+25 -23
View File
@@ -1,50 +1,52 @@
import { toUniqueId } from '@blitzkit/core'; import { toUniqueId } from "@blitzkit/core";
import { NodeIO } from '@gltf-transform/core'; import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { readdir } from 'fs/promises'; import { readdir } from "fs/promises";
import { extractModel } from '../core/blitz/extractModel'; import { extractModel } from "../core/blitz/extractModel";
import { readXMLDVPL } from '../core/blitz/readXMLDVPL'; import { readXMLDVPL } from "../core/blitz/readXMLDVPL";
import { readYAMLDVPL } from '../core/blitz/readYAMLDVPL'; import { readYAMLDVPL } from "../core/blitz/readYAMLDVPL";
import { commitAssets } from '../core/github/commitAssets'; import { commitAssets } from "../core/github/commitAssets";
import { FileChange } from '../core/github/commitMultipleFiles'; import { FileChange } from "../core/github/commitMultipleFiles";
import { DATA } from './constants'; import { DATA } from "./constants";
import { VehicleDefinitionList } from './definitions'; import { VehicleDefinitionList } from "./definitions";
import { TankParameters } from './tankIcons'; import { TankParameters } from "./tankIcons";
export async function tankModels() { export async function tankModels() {
console.log('Building tank models...'); console.log("Building tank models...");
const nodeIO = new NodeIO().registerExtensions(ALL_EXTENSIONS); const nodeIO = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const nations = await readdir(`${DATA}/XML/item_defs/vehicles`).then( const nations = await readdir(`${DATA}/XML/item_defs/vehicles`).then(
(nations) => nations.filter((nation) => nation !== 'common'), (nations) => nations.filter((nation) => nation !== "common")
); );
for (const nationIndex in nations) { for (const nationIndex in nations) {
const nation = nations[nationIndex]; const nation = nations[nationIndex];
const changes: FileChange[] = []; const changes: FileChange[] = [];
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>( const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/XML/item_defs/vehicles/${nation}/list.xml`, `${DATA}/XML/item_defs/vehicles/${nation}/list.xml`
); );
const entries = Object.entries(tanks.root);
console.log(`Building models for ${nation}`); console.log(`Building models for ${nation}`);
for (const [tankKey, tank] of Object.entries(tanks.root)) { let index = 0;
if (tankKey.includes('tutorial_bot')) continue; for (const [tankKey, tank] of entries) {
console.log(` ${++index} / ${entries.length} : ${tankKey}`);
if (tankKey.includes("tutorial_bot")) continue;
const id = toUniqueId(nation, tank.id); const id = toUniqueId(nation, tank.id);
const parameters = await readYAMLDVPL<TankParameters>( const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, `${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`
); );
const model = await extractModel( const model = await extractModel(
DATA, DATA,
parameters.resourcesPath.blitzModelPath.replace(/\.sc2$/, ''), parameters.resourcesPath.blitzModelPath.replace(/\.sc2$/, "")
); );
const content = Buffer.from(await nodeIO.writeBinary(model));
changes.push({ changes.push({ path: `3d/tanks/models/${id}.glb`, content });
path: `3d/tanks/models/${id}.glb`,
content: Buffer.from(await nodeIO.writeBinary(model)),
});
} }
await commitAssets(`tank models ${nation}`, changes); await commitAssets(`tank models ${nation}`, changes);
+45
View File
@@ -0,0 +1,45 @@
import { readFile, writeFile } from "fs/promises";
const IMPORT = "@protos/auto_legacy_proxy_client";
const SOURCE =
"../../submodules/blitzkit-closed/protos/auto_legacy_proxy_client.proto";
const TARGET =
"../../submodules/blitzkit-closed/src/unreal/methodPopulatedProxyClient.ts";
const requestPattern = /message (\w+)Request {/g;
const source = await readFile(SOURCE).then((buffer) => buffer.toString());
const requests = source.matchAll(requestPattern);
const imports: string[] = [];
const methods: { name: string; method: string }[] = [];
for (const match of requests) {
const name = match[1];
// const method =
// name.charAt(0).toLowerCase() + name.slice(1).replace(/Async$/, "");
const method = name;
imports.push(`${name}Request`, `${name}Response`);
methods.push({ name, method });
}
let content = "";
content += "import {\n";
content += imports.map((i) => ` ${i},`).join("\n");
content += `\n} from "${IMPORT}";\n`;
content += 'import { BaseProxyClient } from "./baseProxyClient";\n\n';
content +=
"export abstract class MethodPopulatedProxyClient extends BaseProxyClient {\n";
content += methods
.map(({ name, method }) => {
return ` ${method}(data: ${name}Request) {
return this.request("${name}Request", ${name}Request, data, ${name}Response);
}\n`;
})
.join("");
content += "}\n";
await writeFile(TARGET, content);
@@ -1,13 +1,13 @@
import { VertexAttribute, vertexAttributeVectorSizes } from '@blitzkit/core'; import { VertexAttribute, vertexAttributeVectorSizes } from "@blitzkit/core";
export const vertexAttributeGLTFName: Partial<Record<VertexAttribute, string>> = export const vertexAttributeGLTFName: Partial<Record<VertexAttribute, string>> =
{ {
[VertexAttribute.VERTEX]: 'POSITION', [VertexAttribute.VERTEX]: "POSITION",
[VertexAttribute.NORMAL]: 'NORMAL', [VertexAttribute.NORMAL]: "NORMAL",
[VertexAttribute.TEXCOORD0]: 'TEXCOORD_0', [VertexAttribute.TEXCOORD0]: "TEXCOORD_0",
[VertexAttribute.TEXCOORD1]: 'TEXCOORD_0', [VertexAttribute.TEXCOORD1]: "TEXCOORD_1",
[VertexAttribute.TEXCOORD2]: 'TEXCOORD_0', [VertexAttribute.TEXCOORD2]: "TEXCOORD_2",
[VertexAttribute.TEXCOORD3]: 'TEXCOORD_0', [VertexAttribute.TEXCOORD3]: "TEXCOORD_3",
}; };
export const vertexAttributeGltfVectorSizes = { export const vertexAttributeGltfVectorSizes = {
...vertexAttributeVectorSizes, ...vertexAttributeVectorSizes,
@@ -4,35 +4,35 @@ import {
ScgReadStream, ScgReadStream,
Textures, Textures,
VertexAttribute, VertexAttribute,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { Document, Material, Node, Scene } from '@gltf-transform/core'; import { Document, Material, Node, Scene } from "@gltf-transform/core";
import { dedup, prune } from '@gltf-transform/functions'; import { dedup, prune } from "@gltf-transform/functions";
import { times } from 'lodash-es'; import { times } from "lodash-es";
import { dirname } from 'path'; import { dirname } from "path";
import { Matrix4, Quaternion, Vector3, Vector4Tuple } from 'three'; import { readBaseColor } from "../readBaseColor";
import { readDVPLFile } from '../readDVPLFile'; import { readDVPLFile } from "../readDVPLFile";
import { readTexture } from '../readTexture'; import { readNormal } from "../readNormal";
import { TextureMutation } from '../readTexture/constants'; import { readRoughnessMetallic } from "../readRoughnessMetallic";
import { import {
vertexAttributeGLTFName, vertexAttributeGLTFName,
vertexAttributeGltfVectorSizes, vertexAttributeGltfVectorSizes,
} from './constants'; } from "./constants";
const ERROR_ON_UNKNOWN_COMPONENT = false; const ERROR_ON_UNKNOWN_COMPONENT = false;
const omitMeshNames = { const omitMeshNames = {
start: ['chassis_chassis_', 'chassis_track_crash_', 'HP_'], start: ["chassis_chassis_", "chassis_track_crash_", "HP_"],
end: ['_POINT'], end: ["_POINT"],
}; };
export async function extractModel(data: string, path: string) { export async function extractModel(data: string, path: string) {
const sc2Path = `${data}/3d/${path}.sc2`; const sc2Path = `${data}/3d/${path}.sc2`;
const scgPath = `${data}/3d/${path}.scg`; const scgPath = `${data}/3d/${path}.scg`;
const sc2 = new Sc2ReadStream( const sc2 = new Sc2ReadStream(
(await readDVPLFile(sc2Path)).buffer as ArrayBuffer, (await readDVPLFile(sc2Path)).buffer as ArrayBuffer
).sc2(); ).sc2();
const scg = new ScgReadStream( const scg = new ScgReadStream(
(await readDVPLFile(scgPath)).buffer as ArrayBuffer, (await readDVPLFile(scgPath)).buffer as ArrayBuffer
).scg(); ).scg();
const document = new Document(); const document = new Document();
const scene = document.createScene(); const scene = document.createScene();
@@ -41,8 +41,8 @@ export async function extractModel(data: string, path: string) {
// create materials // create materials
await Promise.all( await Promise.all(
sc2['#dataNodes'].map(async (node) => { sc2["#dataNodes"].map(async (node) => {
const id = new DataView(node['#id']).getBigUint64(0, true); const id = new DataView(node["#id"]).getBigUint64(0, true);
if (node.parentMaterialKey !== undefined) { if (node.parentMaterialKey !== undefined) {
/** /**
@@ -58,7 +58,7 @@ export async function extractModel(data: string, path: string) {
if (node.textures) { if (node.textures) {
textures = node.textures; textures = node.textures;
} else if (typeof node.configCount === 'number') { } else if (typeof node.configCount === "number") {
textures = node.configArchive_0.textures; textures = node.configArchive_0.textures;
} }
@@ -66,26 +66,27 @@ export async function extractModel(data: string, path: string) {
material.setBaseColorTexture( material.setBaseColorTexture(
document document
.createTexture(node.materialName) .createTexture(node.materialName)
.setMimeType('image/jpeg') .setMimeType("image/jpeg")
.setImage( .setImage(
await readTexture( await readBaseColor(
`${data}/3d/${dirname(path)}/${textures.albedo}`, `${data}/3d/${dirname(path)}/${textures.baseColorMap ?? textures.albedo}`,
TextureMutation.Albedo, textures.miscMap
), ? `${data}/3d/${dirname(path)}/${textures.miscMap}`
), : undefined
)
)
); );
if (textures.baseRMMap) { if (textures.baseRMMap) {
material.setMetallicRoughnessTexture( material.setMetallicRoughnessTexture(
document document
.createTexture(node.materialName) .createTexture(node.materialName)
.setMimeType('image/jpeg') .setMimeType("image/jpeg")
.setImage( .setImage(
await readTexture( await readRoughnessMetallic(
`${data}/3d/${dirname(path)}/${textures.baseRMMap}`, `${data}/3d/${dirname(path)}/${textures.baseRMMap}`
TextureMutation.RoughnessMetallicness, )
), )
),
); );
} }
@@ -95,47 +96,33 @@ export async function extractModel(data: string, path: string) {
material.setNormalTexture( material.setNormalTexture(
document document
.createTexture(node.materialName) .createTexture(node.materialName)
.setMimeType('image/jpeg') .setMimeType("image/jpeg")
.setImage( .setImage(
await readTexture( await readNormal(
`${data}/3d/${dirname(path)}/${ `${data}/3d/${dirname(path)}/${
textures.baseNormalMap ?? textures.normalmap textures.baseNormalMap ?? textures.normalmap
}`, }`,
isBase ? TextureMutation.Normal : undefined, isBase
), )
), )
);
}
if (textures.miscMap) {
material.setOcclusionTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setImage(
await readTexture(
`${data}/3d/${dirname(path)}/${textures.miscMap}`,
TextureMutation.Miscellaneous,
),
),
); );
} }
materials.set( materials.set(
new DataView(node['#id']).getBigUint64(0, true), new DataView(node["#id"]).getBigUint64(0, true),
material, material
); );
} }
}), })
); );
// replace children materials with parents // replace children materials with parents
materials.forEach((material, id) => { materials.forEach((material, id) => {
if (typeof material !== 'bigint') return; if (typeof material !== "bigint") return;
let resolvedMaterial: Material | bigint | undefined = material; let resolvedMaterial: Material | bigint | undefined = material;
while (typeof resolvedMaterial === 'bigint') { while (typeof resolvedMaterial === "bigint") {
const linkedParentMaterial = materials.get(resolvedMaterial); const linkedParentMaterial = materials.get(resolvedMaterial);
resolvedMaterial = linkedParentMaterial; resolvedMaterial = linkedParentMaterial;
@@ -155,36 +142,36 @@ export async function extractModel(data: string, path: string) {
const node = document.createNode(hierarchy.name); const node = document.createNode(hierarchy.name);
const components = times( const components = times(
hierarchy.components.count, hierarchy.components.count,
(index) => hierarchy.components[index.toString().padStart(4, '0')], (index) => hierarchy.components[index.toString().padStart(4, "0")]
); );
components.forEach((component) => { components.forEach((component) => {
switch (component['comp.typename']) { switch (component["comp.typename"]) {
case 'LodComponent': case "LodComponent":
// found and used later by transform component // found and used later by transform component
// TODO: lod component always shows up before transform component; cache it before parsing transform // TODO: lod component always shows up before transform component; cache it before parsing transform
break; break;
case 'TransformComponent': { case "TransformComponent": {
node.setTranslation(component['tc.localTranslation']); node.setTranslation(component["tc.localTranslation"]);
node.setRotation(component['tc.localRotation']); node.setRotation(component["tc.localRotation"]);
node.setScale(component['tc.localScale']); node.setScale(component["tc.localScale"]);
break; break;
} }
case 'RenderComponent': { case "RenderComponent": {
const renderObject = component['rc.renderObj']; const renderObject = component["rc.renderObj"];
times(renderObject['ro.batchCount'], (batchIndex): void => { times(renderObject["ro.batchCount"], (batchIndex): void => {
const lodIndex = renderObject[`rb${batchIndex}.lodIndex`]; const lodIndex = renderObject[`rb${batchIndex}.lodIndex`];
if (lodIndex !== 0) return; if (lodIndex !== 0) return;
const batchKey = batchIndex.toString().padStart(4, '0'); const batchKey = batchIndex.toString().padStart(4, "0");
const batch = renderObject['ro.batches'][batchKey]; const batch = renderObject["ro.batches"][batchKey];
const material = materials.get(batch['rb.nmatname']); const material = materials.get(batch["rb.nmatname"]);
const polygonGroup = scg.get(batch['rb.datasource']); const polygonGroup = scg.get(batch["rb.datasource"]);
if (!(material instanceof Material)) { if (!(material instanceof Material)) {
// probably shadow material // probably shadow material
@@ -192,14 +179,14 @@ export async function extractModel(data: string, path: string) {
} }
if (polygonGroup === undefined) { if (polygonGroup === undefined) {
throw new Error( throw new Error(
`Missing polygon group ${batch['rb.datasource']}`, `Missing polygon group ${batch["rb.datasource"]}`
); );
} }
const lodNode = document.createNode(batchKey); const lodNode = document.createNode(batchKey);
const indicesAccessor = document const indicesAccessor = document
.createAccessor() .createAccessor()
.setType('SCALAR') .setType("SCALAR")
.setArray(new Uint16Array(polygonGroup.indices)) .setArray(new Uint16Array(polygonGroup.indices))
.setBuffer(buffer); .setBuffer(buffer);
const primitive = document const primitive = document
@@ -233,7 +220,7 @@ export async function extractModel(data: string, path: string) {
const vertexSize = vertexAttributeGltfVectorSizes[attribute]; const vertexSize = vertexAttributeGltfVectorSizes[attribute];
const attributeAccessor = document const attributeAccessor = document
.createAccessor(name) .createAccessor(name)
.setType(vertexSize === 1 ? 'SCALAR' : `VEC${vertexSize}`) .setType(vertexSize === 1 ? "SCALAR" : `VEC${vertexSize}`)
.setArray(new Float32Array(value.flat())) .setArray(new Float32Array(value.flat()))
.setBuffer(buffer); .setBuffer(buffer);
@@ -241,7 +228,7 @@ export async function extractModel(data: string, path: string) {
}); });
const mesh = document const mesh = document
.createMesh(batch['##name']) .createMesh(batch["##name"])
.addPrimitive(primitive); .addPrimitive(primitive);
lodNode.setMesh(mesh); lodNode.setMesh(mesh);
node.addChild(lodNode); node.addChild(lodNode);
@@ -253,24 +240,24 @@ export async function extractModel(data: string, path: string) {
default: { default: {
if (ERROR_ON_UNKNOWN_COMPONENT) { if (ERROR_ON_UNKNOWN_COMPONENT) {
throw new Error( throw new Error(
`Unhandled component type: ${component['comp.typename']}`, `Unhandled component type: ${component["comp.typename"]}`
); );
} }
} }
} }
}); });
if (hierarchy['#hierarchy']) { if (hierarchy["#hierarchy"]) {
parseHierarchies(hierarchy['#hierarchy'], node); parseHierarchies(hierarchy["#hierarchy"], node);
} }
parent.addChild(node); parent.addChild(node);
}); });
} }
parseHierarchies(sc2['#hierarchy'], scene); parseHierarchies(sc2["#hierarchy"], scene);
scene.addChild(document.createNode('test')); scene.addChild(document.createNode("test"));
await document.transform(prune({ keepAttributes: true }), dedup()); await document.transform(prune({ keepAttributes: true }), dedup());
@@ -0,0 +1,48 @@
import sharp from "sharp";
import { readTexture } from "./readTexture";
export async function readBaseColor(path: string, occlusionPath?: string) {
const baseRaw = await readTexture(path);
const occlusionRaw = occlusionPath
? await readTexture(occlusionPath)
: undefined;
const base = await sharp(baseRaw.data, { raw: baseRaw })
.removeAlpha()
.raw()
.toBuffer();
const occlusion = occlusionRaw
? await sharp(occlusionRaw.data, { raw: occlusionRaw })
.extractChannel(3)
.raw()
.toBuffer()
: undefined;
const combined = Buffer.alloc(baseRaw.width * baseRaw.height * 3);
for (let i = 0; i < baseRaw.width * baseRaw.height; i++) {
let c = 1;
if (occlusionRaw) {
const x = i % baseRaw.width;
const y = Math.floor(i / baseRaw.width);
const u = Math.floor(x * (occlusionRaw.width / baseRaw.width));
const v = Math.floor(y * (occlusionRaw.height / baseRaw.height));
const occlusionI = u + v * occlusionRaw.width;
c = occlusion![occlusionI] / 255;
}
combined[i * 3 + 0] = Math.round(base[i * 3 + 0] * c);
combined[i * 3 + 1] = Math.round(base[i * 3 + 1] * c);
combined[i * 3 + 2] = Math.round(base[i * 3 + 2] * c);
}
const image = await sharp(combined, {
raw: { width: baseRaw.width, height: baseRaw.height, channels: 3 },
})
.jpeg()
.toBuffer();
return image;
}
@@ -0,0 +1,29 @@
import sharp from "sharp";
import { readTexture } from "./readTexture";
export async function readNormal(path: string, isBase: boolean) {
const raw = await readTexture(path);
if (!isBase) {
return await sharp(raw.data, { raw }).removeAlpha().jpeg().toBuffer();
}
const bytes = 4 * raw.width * raw.height;
for (let index = 0; index < bytes; index += 4) {
/**
* Red is always 255 and blue is always 0. Only alpha and green contain any
* sort of information.
*/
let x = raw.data[index + 3] * (2 / 255) - 1;
let y = raw.data[index + 1] * (2 / 255) - 1;
let z = Math.sqrt(Math.max(0, 1 - x ** 2 - y ** 2));
raw.data[index] = Math.round((x + 1) * (255 / 2));
raw.data[index + 1] = Math.round((y + 1) * (255 / 2));
raw.data[index + 2] = Math.round((z + 1) * (255 / 2));
raw.data[index + 3] = 255;
}
return await sharp(raw.data, { raw }).jpeg().toBuffer();
}
@@ -0,0 +1,24 @@
import sharp from "sharp";
import { readTexture } from "./readTexture";
export async function readRoughnessMetallic(path: string) {
const raw = await readTexture(path);
const blitz = sharp(raw.data, { raw: raw });
const metallicness = await blitz?.extractChannel(1).raw().toBuffer();
const roughness = await blitz?.extractChannel(3).raw().toBuffer();
const combined = Buffer.alloc(raw.width * raw.height * 3);
for (let i = 0; i < raw.width * raw.height; i++) {
combined[i * 3 + 1] = roughness[i];
combined[i * 3 + 2] = metallicness[i];
}
const image = await sharp(combined, {
raw: { width: raw.width, height: raw.height, channels: 3 },
})
.jpeg()
.toBuffer();
return image;
}
@@ -1,81 +1,83 @@
import { DdsReadStream, PvrReadStream } from '@blitzkit/core'; import { DdsReadStream, PvrReadStream } from "@blitzkit/core";
import { existsSync } from 'fs'; import { existsSync } from "fs";
import sharp from 'sharp'; import sharp from "sharp";
import { DATA_COMPRESSED } from '../../../buildAssets/constants'; import { DATA_COMPRESSED } from "../../../buildAssets/constants";
import { readDVPLFile } from '../readDVPLFile'; import { readDVPLFile } from "../readDVPLFile";
import { TextureMutation } from './constants'; import { TextureMutation } from "./constants";
export async function readTexture(path: string, mutation?: TextureMutation) { export async function readTexture(path: string) {
const ddsTexturePath = path.replace('.tex', '.dx11.dds'); const ddsTexturePath = path.replace(".tex", ".dx11.dds");
const isDds = existsSync( const isDds = existsSync(
`${ddsTexturePath}${DATA_COMPRESSED ? '.dvpl' : ''}`, `${ddsTexturePath}${DATA_COMPRESSED ? ".dvpl" : ""}`
); );
const resolvedTexturePath = isDds const resolvedTexturePath = isDds
? ddsTexturePath ? ddsTexturePath
: ddsTexturePath.replace('.dds', '.pvr'); : ddsTexturePath.replace(".dds", ".pvr");
const decompressedDvpl = await readDVPLFile(`${resolvedTexturePath}.dvpl`); const decompressedDvpl = await readDVPLFile(`${resolvedTexturePath}.dvpl`);
const raw = isDds const raw = isDds
? await new DdsReadStream(decompressedDvpl.buffer as ArrayBuffer).dds() ? await new DdsReadStream(decompressedDvpl.buffer as ArrayBuffer).dds()
: new PvrReadStream(decompressedDvpl.buffer as ArrayBuffer).pvr(); : new PvrReadStream(decompressedDvpl.buffer as ArrayBuffer).pvr();
switch (mutation) { return raw;
case TextureMutation.Normal: {
const bytes = 4 * raw.width * raw.height;
for (let index = 0; index < bytes; index += 4) {
/**
* Red is always 255 and blue is always 0. Only alpha and green contain any
* sort of information.
*/
let x = raw.data[index + 3] * (2 / 255) - 1;
let y = raw.data[index + 1] * (2 / 255) - 1;
const underSqrt = 1 - x ** 2 - y ** 2;
let z = underSqrt > 0 ? Math.sqrt(underSqrt) : -Math.sqrt(-underSqrt);
raw.data[index] = Math.round((x + 1) * (255 / 2)); // switch (mutation) {
raw.data[index + 1] = Math.round((y + 1) * (255 / 2)); // case TextureMutation.Normal: {
raw.data[index + 2] = Math.round((z + 1) * (255 / 2)); // const bytes = 4 * raw.width * raw.height;
raw.data[index + 3] = 255; // for (let index = 0; index < bytes; index += 4) {
} // /**
// * Red is always 255 and blue is always 0. Only alpha and green contain any
// * sort of information.
// */
// let x = raw.data[index + 3] * (2 / 255) - 1;
// let y = raw.data[index + 1] * (2 / 255) - 1;
// const underSqrt = 1 - x ** 2 - y ** 2;
// let z = underSqrt > 0 ? Math.sqrt(underSqrt) : -Math.sqrt(-underSqrt);
return await sharp(raw.data, { raw }).jpeg().toBuffer(); // raw.data[index] = Math.round((x + 1) * (255 / 2));
} // raw.data[index + 1] = Math.round((y + 1) * (255 / 2));
// raw.data[index + 2] = Math.round((z + 1) * (255 / 2));
// raw.data[index + 3] = 255;
// }
case TextureMutation.RoughnessMetallicness: { // return await sharp(raw.data, { raw }).jpeg().toBuffer();
const newImage = sharp(Buffer.alloc(raw.width * raw.height), { // }
raw: { ...raw, channels: 1 },
});
const metallicness = sharp(raw.data, { raw })
.extractChannel('green')
.jpeg()
.toBuffer();
const roughness = sharp(raw.data, { raw })
.extractChannel('alpha')
.jpeg()
.toBuffer();
return await newImage // case TextureMutation.RoughnessMetallicness: {
.joinChannel(await Promise.all([roughness, metallicness])) // const newImage = sharp(Buffer.alloc(raw.width * raw.height), {
.jpeg() // raw: { ...raw, channels: 1 },
.toBuffer(); // });
} // const metallicness = sharp(raw.data, { raw })
// .extractChannel("green")
// .jpeg()
// .toBuffer();
// const roughness = sharp(raw.data, { raw })
// .extractChannel("alpha")
// .jpeg()
// .toBuffer();
case TextureMutation.Miscellaneous: { // return await newImage
/** // .joinChannel(await Promise.all([roughness, metallicness]))
* Alpha is ambient occlusion and green is emissive. But very few tanks // .jpeg()
* use emissive so I am ignoring it for now. // .toBuffer();
*/ // }
return await sharp(raw.data, { raw })
.extractChannel('alpha')
.jpeg()
.toBuffer();
}
case TextureMutation.Albedo: { // case TextureMutation.Miscellaneous: {
return await sharp(raw.data, { raw }).removeAlpha().jpeg().toBuffer(); // /**
} // * Alpha is ambient occlusion and green is emissive. But very few tanks
// * use emissive so I am ignoring it for now.
// */
// return await sharp(raw.data, { raw })
// .extractChannel("alpha")
// .jpeg()
// .toBuffer();
// }
default: // case TextureMutation.Albedo: {
return await sharp(raw.data, { raw }).jpeg().toBuffer(); // return await sharp(raw.data, { raw }).removeAlpha().jpeg().toBuffer();
} // }
// default:
// return await sharp(raw.data, { raw }).jpeg().toBuffer();
// }
} }
+50 -36
View File
@@ -1,47 +1,61 @@
import { exec as execSync } from 'child_process'; import { exec as execSync } from "child_process";
import { existsSync } from 'fs'; import { existsSync } from "fs";
import { readdir, rm, writeFile } from 'fs/promises'; import { readdir, rm } from "fs/promises";
import { promisify } from 'util'; import { promisify } from "util";
const ROOT = 'packages/core/src/protos'; const MAX_COMMAND_LENGTH = 2 ** 11;
const roots = [
// "packages/core/src/protos",
"submodules/blitzkit-closed/protos",
];
const exec = promisify(execSync); const exec = promisify(execSync);
let executableFileExtension: string; let executableFileExtension: string;
if (existsSync('node_modules/.bin/protoc-gen-ts_proto.exe')) { if (existsSync("node_modules/.bin/protoc-gen-ts_proto.exe")) {
executableFileExtension = '.exe'; executableFileExtension = ".exe";
} else { } else {
executableFileExtension = ''; executableFileExtension = "";
} }
const filesRaw = await readdir(`${ROOT}`); for (const root of roots) {
await Promise.all( const filesRaw = await readdir(`${root}`);
filesRaw.map(async (file) => { const files: string[] = [];
if (!file.endsWith('.proto')) {
await rm(`${ROOT}/${file}`); for (const file of filesRaw) {
if (file.endsWith(".proto")) files.push(file);
if (file.endsWith(".ts")) await rm(`${root}/${file}`);
}
while (files.length > 0) {
let command = [
"protoc",
`--plugin=./node_modules/.bin/protoc-gen-ts_proto${executableFileExtension}`,
"--ts_proto_opt=esModuleInterop=true",
"--ts_proto_opt=oneof=unions-value",
// "--ts_proto_opt=removeEnumPrefix=true",
"--ts_proto_opt=unrecognizedEnum=false",
"--ts_proto_opt=snakeToCamel=false",
`--ts_proto_out=${root}`,
`-I=${root}`,
].join(" ");
while (true) {
if (files.length === 0) break;
const file = files.at(-1);
const newLine = ` ${root}/${file}`;
if (command.length + newLine.length < MAX_COMMAND_LENGTH) {
command += newLine;
files.pop();
} else {
break;
}
} }
}),
);
const files = filesRaw.filter((file) => file.endsWith('.proto')); await exec(command);
const args = [ }
`--plugin=./node_modules/.bin/protoc-gen-ts_proto${executableFileExtension}`, }
'--ts_proto_opt=esModuleInterop=true',
'--ts_proto_opt=oneof=unions-value',
'--ts_proto_opt=removeEnumPrefix=true',
'--ts_proto_opt=unrecognizedEnum=false',
'--ts_proto_opt=snakeToCamel=false',
'--ts_proto_out=.',
...files.map((file) => `${ROOT}/${file}`),
];
await exec(`protoc ${args.join(' ')}`);
await writeFile(
`${ROOT}/index.ts`,
'// @ts-nocheck\n' +
files
.map((file) => file.replace('.proto', ''))
.map((file) => `export * from './${file}.ts';`)
.join('\n'),
);
+8
View File
@@ -0,0 +1,8 @@
{
"name": "varuna",
"type": "module",
"main": "src/index.ts",
"dependencies": {
"react": "^18.3.1"
}
}
+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"
}
+2 -2
View File
@@ -13,6 +13,7 @@
"@astrolib/seo": "^1.0.0-beta.8", "@astrolib/seo": "^1.0.0-beta.8",
"@blitzkit/core": "workspace:*", "@blitzkit/core": "workspace:*",
"@blitzkit/i18n": "workspace:*", "@blitzkit/i18n": "workspace:*",
"varuna": "workspace:*",
"@fontsource/inter": "^5.2.5", "@fontsource/inter": "^5.2.5",
"@nanostores/persistent": "^0.10.2", "@nanostores/persistent": "^0.10.2",
"@nanostores/react": "^0.7.3", "@nanostores/react": "^0.7.3",
@@ -35,8 +36,7 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-promise-suspense": "^0.3.4", "react-promise-suspense": "^0.3.4",
"three": "^0.169.0", "three": "^0.169.0",
"urlon": "^3.1.0", "urlon": "^3.1.0"
"zustand": "^4.5.5"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.4", "@astrojs/check": "^0.9.4",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

@@ -1,4 +1,4 @@
import { ReloadIcon } from '@radix-ui/react-icons'; import { ReloadIcon } from "@radix-ui/react-icons";
import { import {
Card, Card,
Code, Code,
@@ -6,26 +6,21 @@ import {
IconButton, IconButton,
Text, Text,
TextField, TextField,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { Html } from '@react-three/drei'; import { Html } from "@react-three/drei";
import { useRef } from 'react'; import { useRef } from "react";
import { radToDeg } from 'three/src/math/MathUtils.js'; import { radToDeg } from "three/src/math/MathUtils.js";
import { resolveArmorIndex } from '../../../core/blitzkit/resolveArmorIndex'; import { resolveArmorIndex } from "../../../core/blitzkit/resolveArmorIndex";
import { App } from '../../../stores/app'; import { App } from "../../../stores/app";
import { Duel } from '../../../stores/duel'; import { Duel } from "../../../stores/duel";
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../stores/tankopedia";
import { layerTypeNames } from './ShotDisplayCard'; import { layerTypeNames } from "./ShotDisplayCard";
import { ArmorType } from './SpacedArmorScene'; import { ArmorType } from "./SpacedArmorScene";
export function ArmorPlateDisplay() { export function ArmorPlateDisplay() {
const highlightArmor = TankopediaEphemeral.use( const highlightArmor = Tankopedia.use((state) => state.highlightArmor);
(state) => state.highlightArmor,
);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const developerMode = App.useDeferred((state) => state.developerMode, false); const developerMode = App.useDeferred((state) => state.developerMode, false);
const duelStore = Duel.useStore();
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
if (highlightArmor === undefined) return null; if (highlightArmor === undefined) return null;
@@ -33,20 +28,20 @@ export function ArmorPlateDisplay() {
<group position={highlightArmor.point}> <group position={highlightArmor.point}>
<Html <Html
center center
style={{ pointerEvents: highlightArmor.editingPlate ? 'auto' : 'none' }} style={{ pointerEvents: highlightArmor.editingPlate ? "auto" : "none" }}
> >
<Card <Card
variant="classic" variant="classic"
mb="4" mb="4"
style={{ style={{
whiteSpace: 'nowrap', whiteSpace: "nowrap",
color: highlightArmor.color, color: highlightArmor.color,
transform: 'translateY(-50%)', transform: "translateY(-50%)",
}} }}
> >
<Flex direction="column"> <Flex direction="column">
<Text weight="bold"> <Text weight="bold">
{layerTypeNames[highlightArmor.type]}{' '} {layerTypeNames[highlightArmor.type]}{" "}
{highlightArmor.thickness.toFixed(0)} {highlightArmor.thickness.toFixed(0)}
<Text size="1" weight="regular"> <Text size="1" weight="regular">
mm mm
@@ -92,34 +87,34 @@ export function ArmorPlateDisplay() {
if (isNaN(thickness) || thickness < 0) return; if (isNaN(thickness) || thickness < 0) return;
if (event.key === 'Enter') { if (event.key === "Enter") {
input.current?.blur(); input.current?.blur();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
const { protagonist } = duelStore.getState(); const { protagonist } = Duel.state;
const tank = draft.model; const tank = draft.model;
const turret = tank.turrets[protagonist.turret.id]; const turret = tank.turrets[protagonist.turret.id];
const gun = turret.guns[protagonist.gun.id]; const gun = turret.guns[protagonist.gun.id];
const track = tank.tracks[protagonist.track.id]; const track = tank.tracks[protagonist.track.id];
if (highlightArmor.name.startsWith('hull_')) { if (highlightArmor.name.startsWith("hull_")) {
tank.armor.thickness[index] = thickness; tank.armor.thickness[index] = thickness;
} else if (highlightArmor.name.startsWith('turret_')) { } else if (highlightArmor.name.startsWith("turret_")) {
turret.armor.thickness[index] = thickness; turret.armor.thickness[index] = thickness;
} else if (highlightArmor.name.startsWith('gun_')) { } else if (highlightArmor.name.startsWith("gun_")) {
if (highlightArmor.name.includes('_armor_')) { if (highlightArmor.name.includes("_armor_")) {
gun.armor.thickness[index] = thickness; gun.armor.thickness[index] = thickness;
} else { } else {
gun.thickness = thickness; gun.thickness = thickness;
} }
} else if (highlightArmor.name.startsWith('chassis_')) { } else if (highlightArmor.name.startsWith("chassis_")) {
track.thickness = thickness; track.thickness = thickness;
} }
}); });
} else if (event.key === 'Escape') { } else if (event.key === "Escape") {
input.current?.blur(); input.current?.blur();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
} }
@@ -137,7 +132,7 @@ export function ArmorPlateDisplay() {
mt="2" mt="2"
color="red" color="red"
onClick={() => { onClick={() => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
if (!highlightArmor.editingPlate) return; if (!highlightArmor.editingPlate) return;
const index = resolveArmorIndex(highlightArmor.name); const index = resolveArmorIndex(highlightArmor.name);
@@ -145,9 +140,8 @@ export function ArmorPlateDisplay() {
if (index === undefined) return; if (index === undefined) return;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
const initialState = const initialState = Tankopedia.initial;
tankopediaEphemeralStore.getInitialState(); const { protagonist } = Duel.state;
const { protagonist } = duelStore.getState();
const tank = draft.model; const tank = draft.model;
const initialTank = initialState.model; const initialTank = initialState.model;
const turret = tank.turrets[protagonist.turret.id]; const turret = tank.turrets[protagonist.turret.id];
@@ -159,20 +153,20 @@ export function ArmorPlateDisplay() {
const initialTrack = const initialTrack =
initialTank.tracks[protagonist.track.id]; initialTank.tracks[protagonist.track.id];
if (highlightArmor.name.startsWith('hull_')) { if (highlightArmor.name.startsWith("hull_")) {
tank.armor.thickness[index] = tank.armor.thickness[index] =
initialTank.armor.thickness[index]; initialTank.armor.thickness[index];
} else if (highlightArmor.name.startsWith('turret_')) { } else if (highlightArmor.name.startsWith("turret_")) {
turret.armor.thickness[index] = turret.armor.thickness[index] =
initialTurret.armor.thickness[index]; initialTurret.armor.thickness[index];
} else if (highlightArmor.name.startsWith('gun_')) { } else if (highlightArmor.name.startsWith("gun_")) {
if (highlightArmor.name.includes('_armor_')) { if (highlightArmor.name.includes("_armor_")) {
gun.armor.thickness[index] = gun.armor.thickness[index] =
initialGun.armor.thickness[index]; initialGun.armor.thickness[index];
} else { } else {
gun.thickness = initialGun.thickness; gun.thickness = initialGun.thickness;
} }
} else if (highlightArmor.name.startsWith('chassis_')) { } else if (highlightArmor.name.startsWith("chassis_")) {
track.thickness = initialTrack.thickness; track.thickness = initialTrack.thickness;
} }
}); });
@@ -14,7 +14,6 @@ export const PrimaryArmorScene = memo(() => {
const wrapper = useRef<Group>(null); const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
// const protagonist = Duel.use((draft) => draft.protagonist);
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track); const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
@@ -2,9 +2,9 @@ import {
canSplash, canSplash,
isExplosive, isExplosive,
resolvePenetrationCoefficient, resolvePenetrationCoefficient,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { invalidate, useFrame } from '@react-three/fiber'; import { invalidate, useFrame } from "@react-three/fiber";
import { useEffect } from 'react'; import { useEffect } from "react";
import { import {
MeshBasicMaterial, MeshBasicMaterial,
NormalBlending, NormalBlending,
@@ -14,16 +14,16 @@ import {
UniformsLib, UniformsLib,
UniformsUtils, UniformsUtils,
Vector2, Vector2,
} from 'three'; } from "three";
import { degToRad } from 'three/src/math/MathUtils.js'; import { degToRad } from "three/src/math/MathUtils.js";
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../core/blitzkit/hasEquipment";
import { jsxTree } from '../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../core/blitzkit/jsxTree";
import { Duel, type EquipmentMatrix } from '../../../../stores/duel'; import { Duel, type EquipmentMatrix } from "../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../stores/tankopedia";
import { TankopediaPersistent } from '../../../../stores/tankopediaPersistent'; import { TankopediaPersistent } from "../../../../stores/tankopediaPersistent";
import fragmentShader from './shaders/fragment.glsl?raw'; import fragmentShader from "./shaders/fragment.glsl?raw";
import vertexShader from './shaders/vertex.glsl?raw'; import vertexShader from "./shaders/vertex.glsl?raw";
import { spacedArmorRenderTarget } from './target'; import { spacedArmorRenderTarget } from "./target";
interface PrimaryArmorSceneComponentProps { interface PrimaryArmorSceneComponentProps {
thickness: number; thickness: number;
@@ -41,9 +41,6 @@ export function PrimaryArmorSceneComponent({
node, node,
thickness, thickness,
}: PrimaryArmorSceneComponentProps) { }: PrimaryArmorSceneComponentProps) {
const tankopediaPersistentStore = TankopediaPersistent.useStore();
const duelStore = Duel.useStore();
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const material = new ShaderMaterial({ const material = new ShaderMaterial({
fragmentShader, fragmentShader,
vertexShader, vertexShader,
@@ -80,30 +77,30 @@ export function PrimaryArmorSceneComponent({
}); });
useEffect(() => { useEffect(() => {
async function handleShellChange() { function handleShellChange() {
const duel = duelStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const shell =
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
material.uniforms.caliber.value = shell.caliber; material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad( material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!, isExplosive(shell.type) ? 90 : shell.ricochet!
); );
material.uniforms.normalization.value = degToRad( material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0, shell.normalization ?? 0
); );
material.uniforms.isExplosive.value = isExplosive(shell.type); material.uniforms.isExplosive.value = isExplosive(shell.type);
material.uniforms.canSplash.value = canSplash(shell.type); material.uniforms.canSplash.value = canSplash(shell.type);
material.uniforms.damage.value = shell.armor_damage; material.uniforms.damage.value = shell.armor_damage;
material.uniforms.explosionRadius.value = shell.explosion_radius; material.uniforms.explosionRadius.value = shell.explosion_radius;
await handleProtagonistEquipmentChange( handleProtagonistEquipmentChange(
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix,
true, true
); );
await handleAntagonistEquipmentChange( handleAntagonistEquipmentChange(
duel.antagonist.equipmentMatrix, Duel.state.antagonist.equipmentMatrix,
true, true
); );
invalidate(); invalidate();
@@ -121,15 +118,14 @@ export function PrimaryArmorSceneComponent({
function handleWireframeChange(wireframe: boolean) { function handleWireframeChange(wireframe: boolean) {
material.wireframe = wireframe; material.wireframe = wireframe;
} }
async function handleProtagonistEquipmentChange( function handleProtagonistEquipmentChange(
equipment: EquipmentMatrix, equipment: EquipmentMatrix,
noInvalidate = false, noInvalidate = false
) { ) {
const duel = duelStore.getState(); const hasEnhancedArmor = hasEquipment(
const hasEnhancedArmor = await hasEquipment(
110, 110,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.thickness.value = hasEnhancedArmor material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03 ? thickness * 1.03
@@ -137,18 +133,18 @@ export function PrimaryArmorSceneComponent({
if (!noInvalidate) invalidate(); if (!noInvalidate) invalidate();
} }
async function handleAntagonistEquipmentChange( function handleAntagonistEquipmentChange(
equipment: EquipmentMatrix, equipment: EquipmentMatrix,
noInvalidate = false, noInvalidate = false
) { ) {
const duel = duelStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const shell =
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
const penetration = shell.penetration.near; const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment( const hasCalibratedShells = hasEquipment(
103, 103,
duel.antagonist.tank.equipment_preset, Duel.state.antagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.penetration.value = material.uniforms.penetration.value =
@@ -158,45 +154,35 @@ export function PrimaryArmorSceneComponent({
if (!noInvalidate) invalidate(); if (!noInvalidate) invalidate();
} }
const initialState = tankopediaPersistentStore.getState();
handleShellChange(); handleShellChange();
handleGreenPenetrationChange(initialState.greenPenetration); handleGreenPenetrationChange(TankopediaPersistent.state.greenPenetration);
handleAdvancedHighlightingChange(initialState.advancedHighlighting); handleAdvancedHighlightingChange(
handleOpaqueChange(initialState.opaque); TankopediaPersistent.state.advancedHighlighting
handleWireframeChange(initialState.wireframe);
handleProtagonistEquipmentChange(
duelStore.getState().protagonist.equipmentMatrix,
);
handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix,
); );
handleOpaqueChange(TankopediaPersistent.state.opaque);
handleWireframeChange(TankopediaPersistent.state.wireframe);
handleProtagonistEquipmentChange(Duel.state.protagonist.equipmentMatrix);
handleAntagonistEquipmentChange(Duel.state.antagonist.equipmentMatrix);
const unsubscribes = [ const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange), Duel.on((state) => state.antagonist.shell, handleShellChange),
tankopediaEphemeralStore.subscribe( Tankopedia.on((state) => state.customShell, handleShellChange),
(state) => state.customShell, TankopediaPersistent.on(
handleShellChange,
),
tankopediaPersistentStore.subscribe(
(state) => state.greenPenetration, (state) => state.greenPenetration,
handleGreenPenetrationChange, handleGreenPenetrationChange
), ),
tankopediaPersistentStore.subscribe( TankopediaPersistent.on(
(state) => state.advancedHighlighting, (state) => state.advancedHighlighting,
handleAdvancedHighlightingChange, handleAdvancedHighlightingChange
), ),
tankopediaPersistentStore.subscribe( TankopediaPersistent.on((state) => state.opaque, handleOpaqueChange),
(state) => state.opaque, Duel.on(
handleOpaqueChange,
),
duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix, (state) => state.protagonist.equipmentMatrix,
(equipment) => handleProtagonistEquipmentChange(equipment), (equipment) => handleProtagonistEquipmentChange(equipment)
), ),
duelStore.subscribe( Duel.on(
(state) => state.antagonist.equipmentMatrix, (state) => state.antagonist.equipmentMatrix,
(equipment) => handleAntagonistEquipmentChange(equipment), (equipment) => handleAntagonistEquipmentChange(equipment)
), ),
]; ];
@@ -207,7 +193,7 @@ export function PrimaryArmorSceneComponent({
useFrame(({ gl, camera }) => { useFrame(({ gl, camera }) => {
gl.getSize(material.uniforms.resolution.value).multiplyScalar( gl.getSize(material.uniforms.resolution.value).multiplyScalar(
gl.getPixelRatio(), gl.getPixelRatio()
); );
material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture; material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
material.uniforms.spacedArmorDepth.value = material.uniforms.spacedArmorDepth.value =
@@ -37,7 +37,7 @@ float getDistance(vec2 coord, float depth) {
vec3 getPenetrationColor(bool isThreeCalibersRule, bool couldHaveRicochet) { vec3 getPenetrationColor(bool isThreeCalibersRule, bool couldHaveRicochet) {
if (advancedHighlighting && 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); return vec3(0.0, 1.0, 0.0);
@@ -107,7 +107,7 @@ void main() {
vec3 baseColor = vec3(1.0, splashChance * GREEN_VALUE, 0.0); vec3 baseColor = vec3(1.0, splashChance * GREEN_VALUE, 0.0);
if (advancedHighlighting && didRicochet) { if (advancedHighlighting && didRicochet) {
baseColor = vec3(1.0, baseColor.g, 0.5); baseColor = vec3(1.0, baseColor.g, 1.0);
} }
if (greenPenetration || advancedHighlighting) { if (greenPenetration || advancedHighlighting) {
@@ -1,9 +1,9 @@
import { J_HAT, K_HAT } from '@blitzkit/core'; import { J_HAT, K_HAT } from "@blitzkit/core";
import { Box, Flex, Text } from '@radix-ui/themes'; import { Box, Flex, Text } from "@radix-ui/themes";
import { Html } from '@react-three/drei'; import { Html } from "@react-three/drei";
import { invalidate, useFrame } from '@react-three/fiber'; import { invalidate, useFrame } from "@react-three/fiber";
import { clamp } from 'lodash-es'; import { clamp } from "lodash-es";
import { useEffect, useRef, type ComponentProps } from 'react'; import { useEffect, useRef, type ComponentProps } from "react";
import { import {
BufferGeometry, BufferGeometry,
DoubleSide, DoubleSide,
@@ -15,23 +15,24 @@ import {
MeshBasicMaterial, MeshBasicMaterial,
Path, Path,
Quaternion, Quaternion,
} from 'three'; } from "three";
import { LocaleProvider, useLocale } from '../../../hooks/useLocale'; import { isHalloween } from "../../../core/blitzkit/isHalloween";
import { LocaleProvider, useLocale } from "../../../hooks/useLocale";
import { import {
TankopediaEphemeral, Tankopedia,
type ShotLayerNonExternal, type ShotLayerNonExternal,
type ShotStatus, type ShotStatus,
} from '../../../stores/tankopediaEphemeral'; } from "../../../stores/tankopedia";
import { ShotDisplayCard } from './ShotDisplayCard'; import { ShotDisplayCard } from "./ShotDisplayCard";
export const shotStatusColors: Record< export const shotStatusColors: Record<
ShotStatus, ShotStatus,
ComponentProps<typeof Text>['color'] ComponentProps<typeof Text>["color"]
> = { > = {
blocked: 'red', blocked: "red",
penetration: 'green', penetration: "green",
ricochet: 'yellow', ricochet: "yellow",
splash: 'orange', splash: "orange",
}; };
const SURFACE_POINT_SIZE = 12; const SURFACE_POINT_SIZE = 12;
@@ -40,7 +41,7 @@ const TRACER_THICK = 1 / 32;
const TRACER_THIN = TRACER_THICK / 2; const TRACER_THIN = TRACER_THICK / 2;
export function ShotDisplay() { export function ShotDisplay() {
const shot = TankopediaEphemeral.use((state) => state.shot); const shot = Tankopedia.use((state) => state.shot);
const inTracer = useRef<Mesh>(null); const inTracer = useRef<Mesh>(null);
const outTracer = useRef<Mesh>(null); const outTracer = useRef<Mesh>(null);
const splashRadiusWrapper = useRef<Group>(null); const splashRadiusWrapper = useRef<Group>(null);
@@ -51,16 +52,16 @@ export function ShotDisplay() {
useEffect(() => { useEffect(() => {
if (shot?.splashRadius !== undefined) { if (shot?.splashRadius !== undefined) {
const outlineGeometry = new BufferGeometry().setFromPoints( const outlineGeometry = new BufferGeometry().setFromPoints(
new Path().absarc(0, 0, 1, 0, Math.PI * 2).getSpacedPoints(50), new Path().absarc(0, 0, 1, 0, Math.PI * 2).getSpacedPoints(50)
); );
const outlineMaterial = new LineDashedMaterial({ const outlineMaterial = new LineDashedMaterial({
color: 'orange', color: "orange",
dashSize: 0.05, dashSize: 0.05,
gapSize: 0.025, gapSize: 0.025,
}); });
const outline = new Line( const outline = new Line(
outlineGeometry, outlineGeometry,
outlineMaterial, outlineMaterial
).computeLineDistances(); ).computeLineDistances();
splashRadiusWrapper.current?.add(outline); splashRadiusWrapper.current?.add(outline);
@@ -72,15 +73,12 @@ export function ShotDisplay() {
}); });
useEffect(() => { useEffect(() => {
const data = new Date();
const isHalloween = data.getMonth() === 9 && data.getDate() === 31;
if ( if (
isHalloween && isHalloween() &&
shot?.splashRadius !== undefined && shot?.splashRadius !== undefined &&
shot.in.status === 'penetration' shot.in.status === "penetration"
) { ) {
const audio = new Audio('/assets/audio/lotta-damage.mp3'); const audio = new Audio("/assets/audio/lotta-damage.mp3");
audio.currentTime = 0.7; audio.currentTime = 0.7;
audio.volume = 0.25; audio.volume = 0.25;
@@ -136,11 +134,11 @@ export function ShotDisplay() {
const outLength = const outLength =
shot.out && shot.out.layers.length > 0 shot.out && shot.out.layers.length > 0
? (shot.in.layers.at(-1) as ShotLayerNonExternal).point.distanceTo( ? (shot.in.layers.at(-1) as ShotLayerNonExternal).point.distanceTo(
(shot.out.layers.at(-1) as ShotLayerNonExternal).point, (shot.out.layers.at(-1) as ShotLayerNonExternal).point
) )
: SHOT_DISPLAY_LENGTH_INFINITY; : SHOT_DISPLAY_LENGTH_INFINITY;
const inLast = shot.in.layers.findLast( const inLast = shot.in.layers.findLast(
(layer) => layer.type !== null, (layer) => layer.type !== null
) as ShotLayerNonExternal; ) as ShotLayerNonExternal;
const outTitleColor = shot.out const outTitleColor = shot.out
? shotStatusColors[shot.out.status] ? shotStatusColors[shot.out.status]
@@ -156,7 +154,7 @@ export function ShotDisplay() {
position={inLast.point} position={inLast.point}
ref={splashRadiusWrapper} ref={splashRadiusWrapper}
rotation={new Euler().setFromQuaternion( rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(K_HAT, shot.in.surfaceNormal), new Quaternion().setFromUnitVectors(K_HAT, shot.in.surfaceNormal)
)} )}
> >
<mesh> <mesh>
@@ -174,13 +172,13 @@ export function ShotDisplay() {
<Html position={inLast.point} center> <Html position={inLast.point} center>
<Box <Box
style={{ style={{
pointerEvents: 'none', pointerEvents: "none",
transform: `translateY(${tracerGoingUp ? 50 : -50}%)`, transform: `translateY(${tracerGoingUp ? 50 : -50}%)`,
}} }}
mb={tracerGoingUp ? '0' : '9'} mb={tracerGoingUp ? "0" : "9"}
pb={tracerGoingUp ? '0' : '4'} pb={tracerGoingUp ? "0" : "4"}
mt={tracerGoingUp ? '9' : '0'} mt={tracerGoingUp ? "9" : "0"}
pt={tracerGoingUp ? '4' : '0'} pt={tracerGoingUp ? "4" : "0"}
> >
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<ShotDisplayCard shot={shot} /> <ShotDisplayCard shot={shot} />
@@ -192,7 +190,7 @@ export function ShotDisplay() {
if (layer.type === null) return null; if (layer.type === null) return null;
const shotStatusColor = const shotStatusColor =
shot.in.status === 'splash' shot.in.status === "splash"
? shotStatusColors.splash ? shotStatusColors.splash
: shotStatusColors[layer.status]; : shotStatusColors[layer.status];
@@ -201,8 +199,8 @@ export function ShotDisplay() {
position={layer.point} position={layer.point}
key={index} key={index}
style={{ style={{
pointerEvents: 'none', pointerEvents: "none",
transform: 'translateY(-50%)', transform: "translateY(-50%)",
}} }}
> >
<Flex <Flex
@@ -227,7 +225,7 @@ export function ShotDisplay() {
<group <group
position={inLast.point} position={inLast.point}
rotation={new Euler().setFromQuaternion( rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(J_HAT, shot.in.surfaceNormal), new Quaternion().setFromUnitVectors(J_HAT, shot.in.surfaceNormal)
)} )}
> >
<mesh position={[0, inLength / 2, 0]} renderOrder={2}> <mesh position={[0, inLength / 2, 0]} renderOrder={2}>
@@ -255,8 +253,8 @@ export function ShotDisplay() {
rotation={new Euler().setFromQuaternion( rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors( new Quaternion().setFromUnitVectors(
J_HAT, J_HAT,
shot.out.surfaceNormal.clone(), shot.out.surfaceNormal.clone()
), )
)} )}
> >
<mesh position={[0, outLength / 2, 0]} renderOrder={2}> <mesh position={[0, outLength / 2, 0]} renderOrder={2}>
@@ -1,4 +1,4 @@
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { import {
Card, Card,
Flex, Flex,
@@ -6,19 +6,19 @@ import {
Separator, Separator,
Text, Text,
type CardProps, type CardProps,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import type { ComponentProps } from 'react'; import type { ComponentProps } from "react";
import { radToDeg } from 'three/src/math/MathUtils.js'; import { radToDeg } from "three/src/math/MathUtils.js";
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from "../../../hooks/useLocale";
import type { Shot, ShotLayer } from '../../../stores/tankopediaEphemeral'; import type { Shot, ShotLayer } from "../../../stores/tankopedia";
import { shotStatusColors } from './ShotDisplay'; import { shotStatusColors } from "./ShotDisplay";
import { ArmorType } from './SpacedArmorScene'; import { ArmorType } from "./SpacedArmorScene";
export const layerTypeNames = { export const layerTypeNames = {
[ArmorType.Primary]: 'primary', [ArmorType.Primary]: "primary",
[ArmorType.Spaced]: 'spaced', [ArmorType.Spaced]: "spaced",
[ArmorType.External]: 'external', [ArmorType.External]: "external",
null: 'gap', null: "gap",
} as const; } as const;
function LayerEntry({ function LayerEntry({
@@ -29,7 +29,7 @@ function LayerEntry({
angle, angle,
}: { }: {
layerIndex?: string; layerIndex?: string;
shotStatusColor: ComponentProps<typeof Text>['color']; shotStatusColor: ComponentProps<typeof Text>["color"];
layerName: string; layerName: string;
layer: ShotLayer; layer: ShotLayer;
angle?: number; angle?: number;
@@ -37,44 +37,38 @@ function LayerEntry({
const { locale, strings } = useLocale(); const { locale, strings } = useLocale();
return ( return (
<Flex align="center" gap="2"> <tr>
<Text size="1" color="gray" style={{ width: 12 }}> <td>
{layerIndex} <Text size="1" color="gray">
</Text> {layerIndex}
<Text size="2" color={shotStatusColor} style={{ width: 48 }}> </Text>
{layerName} </td>
</Text>
{layer.type === null && ( <td>
<> <Text size="2" color={shotStatusColor}>
<Text size="2" color={shotStatusColor} style={{ width: 64 }}> {layerName}
</Text>
</td>
<td>
{layer.type === null && (
<Text size="2" color={shotStatusColor}>
{literals(strings.common.units.mm, [ {literals(strings.common.units.mm, [
Math.round(layer.distance * 1000).toLocaleString(locale), Math.round(layer.distance * 1000).toLocaleString(locale),
])} ])}
</Text> </Text>
)}
<Text size="2" color="gray"> {layer.type === ArmorType.External && (
{literals( <Text size="2" color={shotStatusColor}>
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats {literals(strings.common.units.mm, [
.ricochet_loss, Math.round(layer.thickness).toLocaleString(locale),
[Math.max(-100, -50 * layer.distance).toFixed(0)], ])}
)}
</Text> </Text>
</> )}
)} {(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
{layer.type === ArmorType.External && ( <Text size="2" color={shotStatusColor}>
<Text size="2" color={shotStatusColor} style={{ width: 64 }}>
{literals(strings.common.units.mm, [
Math.round(layer.thickness).toLocaleString(locale),
])}
</Text>
)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
<>
<Text size="2" color={shotStatusColor} style={{ width: 80 }}>
{literals( {literals(
angle === undefined angle === undefined
? strings.common.units.mm ? strings.common.units.mm
@@ -83,20 +77,35 @@ function LayerEntry({
[ [
Math.round(layer.thicknessAngled).toLocaleString(locale), Math.round(layer.thicknessAngled).toLocaleString(locale),
radToDeg(angle ?? 0).toFixed(0), radToDeg(angle ?? 0).toFixed(0),
], ]
)} )}
</Text> </Text>
)}
</td>
<td>
{layer.type === null && (
<Text size="2" color="gray">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
.ricochet_loss,
[Math.max(-100, -50 * layer.distance).toFixed(0)]
)}
</Text>
)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
<Text size="2" color="gray"> <Text size="2" color="gray">
{literals( {literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
.nominal, .nominal,
[Math.round(layer.thickness).toLocaleString(locale)], [Math.round(layer.thickness).toLocaleString(locale)]
)} )}
</Text> </Text>
</> )}
)} </td>
</Flex> </tr>
); );
} }
@@ -115,7 +124,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
const inTitleColor = shotStatusColors[shot.in.status]; const inTitleColor = shotStatusColors[shot.in.status];
return ( return (
<Card variant="classic" style={{ width: 300 }} {...props}> <Card variant="classic" {...props}>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Flex direction="column" gap="1"> <Flex direction="column" gap="1">
<Text color={inTitleColor} weight="bold"> <Text color={inTitleColor} weight="bold">
@@ -123,11 +132,16 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.status[ strings.website.tools.tankopedia.sandbox.dynamic.shot_card.status[
shot.in.status shot.in.status
], ],
[Math.round(shot.damage).toLocaleString(locale)], [Math.round(shot.damage).toLocaleString(locale)]
)} )}
</Text> </Text>
<Flex direction="column"> <table
style={{
whiteSpace: "nowrap",
borderSpacing: "var(--space-1) 0",
}}
>
{shot.in.layers.map((layer, index) => { {shot.in.layers.map((layer, index) => {
const layerName = const layerName =
strings.website.tools.tankopedia.sandbox.dynamic.shot_card strings.website.tools.tankopedia.sandbox.dynamic.shot_card
@@ -141,7 +155,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
? undefined ? undefined
: `${(shot.containsGaps ? index / 2 : index) + 1}.`; : `${(shot.containsGaps ? index / 2 : index) + 1}.`;
const shotStatusColor = const shotStatusColor =
shot.in.status === 'splash' shot.in.status === "splash"
? shotStatusColors.splash ? shotStatusColors.splash
: shotStatusColors[layer.status]; : shotStatusColors[layer.status];
@@ -161,7 +175,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
/> />
); );
})} })}
</Flex> </table>
</Flex> </Flex>
{shot.out && shot.out.layers.length > 0 && ( {shot.out && shot.out.layers.length > 0 && (
@@ -188,7 +202,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
{literals( {literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.status[shot.out.status], .status[shot.out.status],
[Math.round(shot.damage).toLocaleString(locale)], [Math.round(shot.damage).toLocaleString(locale)]
)} )}
</Text> </Text>
@@ -210,7 +224,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
1 1
}.`; }.`;
const shotStatusColor = const shotStatusColor =
shot.out!.status === 'splash' shot.out!.status === "splash"
? shotStatusColors.splash ? shotStatusColors.splash
: shotStatusColors[layer.status]; : shotStatusColors[layer.status];
@@ -1,20 +1,20 @@
import { resolvePenetrationCoefficient } from '@blitzkit/core'; import { resolvePenetrationCoefficient } from "@blitzkit/core";
import { useEffect } from 'react'; import { useEffect } from "react";
import { import {
AdditiveBlending, AdditiveBlending,
MeshBasicMaterial, MeshBasicMaterial,
Object3D, Object3D,
Plane, Plane,
ShaderMaterial, ShaderMaterial,
} from 'three'; } from "three";
import type { ArmorUserData, ExternalModuleVariant } from '../..'; import type { ArmorUserData, ExternalModuleVariant } from "../..";
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../../../core/blitzkit/jsxTree";
import { Duel, type EquipmentMatrix } from '../../../../../../stores/duel'; import { Duel, type EquipmentMatrix } from "../../../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
import { ArmorType } from '../../../SpacedArmorScene'; import { ArmorType } from "../../../SpacedArmorScene";
import fragmentShader from './shaders/fragment.glsl?raw'; import fragmentShader from "./shaders/fragment.glsl?raw";
import vertexShader from './shaders/vertex.glsl?raw'; import vertexShader from "./shaders/vertex.glsl?raw";
interface SpacedArmorSubExternalProps { interface SpacedArmorSubExternalProps {
node: Object3D; node: Object3D;
@@ -29,8 +29,6 @@ export function SpacedArmorSubExternal({
variant, variant,
clip, clip,
}: SpacedArmorSubExternalProps) { }: SpacedArmorSubExternalProps) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const duelStore = Duel.useStore();
const material = new ShaderMaterial({ const material = new ShaderMaterial({
fragmentShader, fragmentShader,
vertexShader, vertexShader,
@@ -49,33 +47,30 @@ export function SpacedArmorSubExternal({
useEffect(() => { useEffect(() => {
function handleShellChange() { function handleShellChange() {
const duel = duelStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const shell =
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
material.uniforms.penetration.value = shell.penetration.near; material.uniforms.penetration.value = shell.penetration.near;
} }
async function handleProtagonistEquipmentChange( function handleProtagonistEquipmentChange(equipment: EquipmentMatrix) {
equipment: EquipmentMatrix, const hasEnhancedArmor = hasEquipment(
) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
110, 110,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.thickness.value = hasEnhancedArmor material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03 ? thickness * 1.03
: thickness; : thickness;
} }
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) { function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = duelStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const shell =
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
const penetration = shell.penetration.near; const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment( const hasCalibratedShells = hasEquipment(
103, 103,
duel.antagonist.tank.equipment_preset, Duel.state.antagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.penetration.value = material.uniforms.penetration.value =
@@ -84,26 +79,19 @@ export function SpacedArmorSubExternal({
} }
handleShellChange(); handleShellChange();
handleProtagonistEquipmentChange( handleProtagonistEquipmentChange(Duel.state.protagonist.equipmentMatrix);
duelStore.getState().protagonist.equipmentMatrix, handleAntagonistEquipmentChange(Duel.state.antagonist.equipmentMatrix);
);
handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix,
);
const unsubscribes = [ const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange), Duel.on((state) => state.antagonist.shell, handleShellChange),
tankopediaEphemeralStore.subscribe( Tankopedia.on((state) => state.customShell, handleShellChange),
(state) => state.customShell, Duel.on(
handleShellChange,
),
duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix, (state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange, handleProtagonistEquipmentChange
), ),
duelStore.subscribe( Duel.on(
(state) => state.antagonist.equipmentMatrix, (state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange, handleAntagonistEquipmentChange
), ),
]; ];
@@ -125,6 +113,7 @@ export function SpacedArmorSubExternal({
return ( return (
<mesh <mesh
{...props} {...props}
onClick={() => {}}
key={`${key}-spaced-sub-external-exclude`} key={`${key}-spaced-sub-external-exclude`}
renderOrder={3} renderOrder={3}
material={ material={
@@ -1,20 +1,20 @@
import { isExplosive, resolvePenetrationCoefficient } from '@blitzkit/core'; import { isExplosive, resolvePenetrationCoefficient } from "@blitzkit/core";
import { useEffect } from 'react'; import { useEffect } from "react";
import { import {
AdditiveBlending, AdditiveBlending,
MeshBasicMaterial, MeshBasicMaterial,
ShaderMaterial, ShaderMaterial,
type Object3D, type Object3D,
} from 'three'; } from "three";
import { degToRad } from 'three/src/math/MathUtils.js'; import { degToRad } from "three/src/math/MathUtils.js";
import type { ArmorUserData } from '../..'; import type { ArmorUserData } from "../..";
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../../../core/blitzkit/jsxTree";
import { Duel, type EquipmentMatrix } from '../../../../../../stores/duel'; import { Duel, type EquipmentMatrix } from "../../../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
import { ArmorType } from '../../../SpacedArmorScene'; import { ArmorType } from "../../../SpacedArmorScene";
import fragmentShader from './shaders/fragment.glsl?raw'; import fragmentShader from "./shaders/fragment.glsl?raw";
import vertexShader from './shaders/vertex.glsl?raw'; import vertexShader from "./shaders/vertex.glsl?raw";
interface SpacedArmorSubSpacedProps { interface SpacedArmorSubSpacedProps {
node: Object3D; node: Object3D;
@@ -30,8 +30,6 @@ export function SpacedArmorSubSpaced({
node, node,
thickness, thickness,
}: SpacedArmorSubSpacedProps) { }: SpacedArmorSubSpacedProps) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const duelStore = Duel.useStore();
const material = new ShaderMaterial({ const material = new ShaderMaterial({
fragmentShader, fragmentShader,
vertexShader, vertexShader,
@@ -51,41 +49,38 @@ export function SpacedArmorSubSpaced({
useEffect(() => { useEffect(() => {
function handleShellChange() { function handleShellChange() {
const duel = duelStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const shell =
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
material.uniforms.penetration.value = shell.penetration.near; material.uniforms.penetration.value = shell.penetration.near;
material.uniforms.caliber.value = shell.caliber; material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad( material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!, isExplosive(shell.type) ? 90 : shell.ricochet!
); );
material.uniforms.normalization.value = degToRad( material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0, shell.normalization ?? 0
); );
} }
async function handleProtagonistEquipmentChange( function handleProtagonistEquipmentChange(equipment: EquipmentMatrix) {
equipment: EquipmentMatrix, const hasEnhancedArmor = hasEquipment(
) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
110, 110,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.thickness.value = hasEnhancedArmor material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03 ? thickness * 1.03
: thickness; : thickness;
} }
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) { function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = duelStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const shell =
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
const penetration = shell.penetration.near; const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment( const hasCalibratedShells = hasEquipment(
103, 103,
duel.antagonist.tank.equipment_preset, Duel.state.antagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.penetration.value = material.uniforms.penetration.value =
@@ -94,26 +89,19 @@ export function SpacedArmorSubSpaced({
} }
handleShellChange(); handleShellChange();
handleProtagonistEquipmentChange( handleProtagonistEquipmentChange(Duel.state.protagonist.equipmentMatrix);
duelStore.getState().protagonist.equipmentMatrix, handleAntagonistEquipmentChange(Duel.state.antagonist.equipmentMatrix);
);
handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix,
);
const unsubscribes = [ const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange), Duel.on((state) => state.antagonist.shell, handleShellChange),
tankopediaEphemeralStore.subscribe( Tankopedia.on((state) => state.customShell, handleShellChange),
(state) => state.customShell, Duel.on(
handleShellChange,
),
duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix, (state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange, handleProtagonistEquipmentChange
), ),
duelStore.subscribe( Duel.on(
(state) => state.antagonist.equipmentMatrix, (state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange, handleAntagonistEquipmentChange
), ),
]; ];
@@ -2,9 +2,9 @@ import {
ShellType, ShellType,
isExplosive, isExplosive,
resolvePenetrationCoefficient, resolvePenetrationCoefficient,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { useThree } from '@react-three/fiber'; import { useThree } from "@react-three/fiber";
import { useCallback } from 'react'; import { useCallback } from "react";
import { import {
type Intersection, type Intersection,
MeshBasicMaterial, MeshBasicMaterial,
@@ -14,21 +14,21 @@ import {
Raycaster, Raycaster,
Scene, Scene,
Vector3, Vector3,
} from 'three'; } from "three";
import { degToRad } from 'three/src/math/MathUtils.js'; import { degToRad } from "three/src/math/MathUtils.js";
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../core/blitzkit/hasEquipment";
import { jsxTree } from '../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../core/blitzkit/jsxTree";
import { discardClippingPlane } from '../../../../core/three/discardClippingPlane'; import { discardClippingPlane } from "../../../../core/three/discardClippingPlane";
import { Duel } from '../../../../stores/duel'; import { Duel } from "../../../../stores/duel";
import { import {
type Shot, type Shot,
type ShotLayerBase, type ShotLayerBase,
type ShotLayerNonExternal, type ShotLayerNonExternal,
TankopediaEphemeral, Tankopedia,
} from '../../../../stores/tankopediaEphemeral'; } from "../../../../stores/tankopedia";
import { ArmorType } from '../SpacedArmorScene'; import { ArmorType } from "../SpacedArmorScene";
import { SpacedArmorSubExternal } from './components/SpacedArmorSubExternal'; import { SpacedArmorSubExternal } from "./components/SpacedArmorSubExternal";
import { SpacedArmorSubSpaced } from './components/SpacedArmorSubSpaced'; import { SpacedArmorSubSpaced } from "./components/SpacedArmorSubSpaced";
type SpacedArmorSceneComponentProps = { type SpacedArmorSceneComponentProps = {
node: Object3D; node: Object3D;
@@ -45,7 +45,7 @@ type SpacedArmorSceneComponentProps = {
} }
); );
export type ExternalModuleVariant = 'gun' | 'track'; export type ExternalModuleVariant = "gun" | "track";
export type ArmorUserData = { export type ArmorUserData = {
thickness: number; thickness: number;
} & ( } & (
@@ -76,20 +76,17 @@ export function SpacedArmorSceneComponent({
clip, clip,
...props ...props
}: SpacedArmorSceneComponentProps) { }: SpacedArmorSceneComponentProps) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const duelStore = Duel.useStore();
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const shoot = useCallback( const shoot = useCallback(
async ( (
point: Vector3, point: Vector3,
intersections: Intersection[], intersections: Intersection[],
allowRicochet: boolean, allowRicochet: boolean,
remainingPenetrationInput?: number, remainingPenetrationInput?: number
) => { ) => {
const { antagonist, protagonist } = duelStore.getState(); const { customShell } = Tankopedia.state;
const { customShell } = tankopediaEphemeralStore.getState(); const shell = customShell ?? Duel.state.antagonist.shell;
const shell = customShell ?? antagonist.shell;
const cameraNormal = camera.position.clone().sub(point).normalize(); const cameraNormal = camera.position.clone().sub(point).normalize();
const shot: Shot = { const shot: Shot = {
splashRadius: splashRadius:
@@ -99,19 +96,19 @@ export function SpacedArmorSceneComponent({
in: { in: {
surfaceNormal: cameraNormal, surfaceNormal: cameraNormal,
layers: [], layers: [],
status: 'blocked', status: "blocked",
}, },
}; };
const hasCalibratedShells = await hasEquipment( const hasCalibratedShells = hasEquipment(
103, 103,
antagonist.tank.equipment_preset, Duel.state.antagonist.tank.equipment_preset,
antagonist.equipmentMatrix, Duel.state.antagonist.equipmentMatrix
); );
const hasEnhancedArmor = await hasEquipment( const hasEnhancedArmor = hasEquipment(
110, 110,
protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const penetration = const penetration =
shell.penetration.near * shell.penetration.near *
@@ -119,8 +116,8 @@ export function SpacedArmorSceneComponent({
const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1; const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1;
const filteredIntersections = intersections.filter( const filteredIntersections = intersections.filter(
(intersection) => (intersection) =>
'type' in intersection.object.userData && "type" in intersection.object.userData &&
!discardClippingPlane(intersection.object, intersection.point), !discardClippingPlane(intersection.object, intersection.point)
) as unknown as Intersection<Object3D & { userData: ArmorUserData }>[]; ) as unknown as Intersection<Object3D & { userData: ArmorUserData }>[];
const encounteredExternalModuleVariants: ExternalModuleVariant[] = []; const encounteredExternalModuleVariants: ExternalModuleVariant[] = [];
const noDuplicateIntersections: typeof filteredIntersections = []; const noDuplicateIntersections: typeof filteredIntersections = [];
@@ -129,12 +126,12 @@ export function SpacedArmorSceneComponent({
if (intersection.object.userData.type === ArmorType.External) { if (intersection.object.userData.type === ArmorType.External) {
if ( if (
!encounteredExternalModuleVariants.includes( !encounteredExternalModuleVariants.includes(
intersection.object.userData.variant, intersection.object.userData.variant
) )
) { ) {
noDuplicateIntersections.push(intersection); noDuplicateIntersections.push(intersection);
encounteredExternalModuleVariants.push( encounteredExternalModuleVariants.push(
intersection.object.userData.variant, intersection.object.userData.variant
); );
} }
} else { } else {
@@ -162,7 +159,7 @@ export function SpacedArmorSceneComponent({
const surfaceNormal = intersection const surfaceNormal = intersection
.normal!.clone() .normal!.clone()
.applyQuaternion( .applyQuaternion(
intersection.object.getWorldQuaternion(new Quaternion()), intersection.object.getWorldQuaternion(new Quaternion())
); );
if (shell.type === ShellType.HEAT && index > 0) { if (shell.type === ShellType.HEAT && index > 0) {
@@ -174,7 +171,7 @@ export function SpacedArmorSceneComponent({
shot.in.layers.push({ shot.in.layers.push({
type: null, type: null,
distance, distance,
status: blocked ? 'blocked' : 'penetration', status: blocked ? "blocked" : "penetration",
}); });
if (blocked) break; if (blocked) break;
@@ -194,16 +191,16 @@ export function SpacedArmorSceneComponent({
surfaceNormal, surfaceNormal,
status: status:
shell.type === ShellType.HE shell.type === ShellType.HE
? 'blocked' ? "blocked"
: blocked : blocked
? 'blocked' ? "blocked"
: 'penetration', : "penetration",
variant: layer.variant, variant: layer.variant,
}); });
} else { } else {
const thickness = layer.thickness * thicknessCoefficient; const thickness = layer.thickness * thicknessCoefficient;
const ricochet = degToRad( const ricochet = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!, isExplosive(shell.type) ? 90 : shell.ricochet!
); );
const angle = surfaceNormal.angleTo(cameraNormal); const angle = surfaceNormal.angleTo(cameraNormal);
@@ -227,7 +224,7 @@ export function SpacedArmorSceneComponent({
thicknessAngled: finalThickness, thicknessAngled: finalThickness,
point: intersection.point, point: intersection.point,
surfaceNormal, surfaceNormal,
status: 'ricochet', status: "ricochet",
angle, angle,
}); });
@@ -245,10 +242,10 @@ export function SpacedArmorSceneComponent({
surfaceNormal: intersection.face!.normal, surfaceNormal: intersection.face!.normal,
status: status:
shell.type === ShellType.HE && layer.type !== ArmorType.Primary shell.type === ShellType.HE && layer.type !== ArmorType.Primary
? 'blocked' ? "blocked"
: blocked : blocked
? 'blocked' ? "blocked"
: 'penetration', : "penetration",
angle, angle,
}); });
} }
@@ -282,10 +279,10 @@ export function SpacedArmorSceneComponent({
return acc + layer.thicknessAngled; return acc + layer.thicknessAngled;
}, },
0, 0
); );
const distanceFromSpacedArmor = firstLayer.point.distanceTo( const distanceFromSpacedArmor = firstLayer.point.distanceTo(
lastLayer.point, lastLayer.point
); );
const finalDamage = Math.max( const finalDamage = Math.max(
0, 0,
@@ -294,7 +291,7 @@ export function SpacedArmorSceneComponent({
(1 - distanceFromSpacedArmor / shell.explosion_radius!) - (1 - distanceFromSpacedArmor / shell.explosion_radius!) -
1.1 * 1.1 *
(lastLayer.thicknessAngled + (lastLayer.thicknessAngled +
Math.min(penetration, totalSpacedArmorThickness)), Math.min(penetration, totalSpacedArmorThickness))
); );
/** /**
@@ -308,19 +305,19 @@ export function SpacedArmorSceneComponent({
* for parity's sake, I am capping the value here. * for parity's sake, I am capping the value here.
*/ */
if (shot.in.layers.length > 1 || lastLayer.status === 'blocked') { if (shot.in.layers.length > 1 || lastLayer.status === "blocked") {
shot.in.status = finalDamage > 0 ? 'splash' : 'blocked'; shot.in.status = finalDamage > 0 ? "splash" : "blocked";
shot.damage = finalDamage; shot.damage = finalDamage;
} else { } else {
shot.in.status = 'penetration'; shot.in.status = "penetration";
shot.damage = shell.armor_damage; shot.damage = shell.armor_damage;
} }
} else { } else {
if (lastLayer.status === 'blocked') { if (lastLayer.status === "blocked") {
shot.in.status = 'blocked'; shot.in.status = "blocked";
shot.damage = 0; shot.damage = 0;
} else if (lastLayer.status === 'penetration') { } else if (lastLayer.status === "penetration") {
shot.in.status = 'penetration'; shot.in.status = "penetration";
shot.damage = shell.armor_damage; shot.damage = shell.armor_damage;
} else { } else {
const caster = new Raycaster(); const caster = new Raycaster();
@@ -331,29 +328,29 @@ export function SpacedArmorSceneComponent({
.sub( .sub(
lastLayer.surfaceNormal lastLayer.surfaceNormal
.clone() .clone()
.multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal)), .multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal))
); );
caster.set(lastLayer.point, ricochetNormal); caster.set(lastLayer.point, ricochetNormal);
const ricochetIntersections = caster.intersectObjects( const ricochetIntersections = caster.intersectObjects(
props.scene.children, props.scene.children,
true, true
); );
const outShot = await shoot( const outShot = shoot(
lastLayer.point, lastLayer.point,
ricochetIntersections, ricochetIntersections,
false, false,
remainingPenetration, remainingPenetration
); );
shot.in.status = 'ricochet'; shot.in.status = "ricochet";
if (outShot === null) { if (outShot === null) {
shot.damage = 0; shot.damage = 0;
shot.out = { shot.out = {
surfaceNormal: ricochetNormal, surfaceNormal: ricochetNormal,
layers: [], layers: [],
status: 'ricochet', status: "ricochet",
}; };
} else { } else {
shot.damage = outShot.damage; shot.damage = outShot.damage;
@@ -366,7 +363,7 @@ export function SpacedArmorSceneComponent({
return shot; return shot;
}, },
[camera], [camera]
); );
return ( return (
@@ -386,14 +383,14 @@ export function SpacedArmorSceneComponent({
thickness, thickness,
} satisfies ArmorUserData } satisfies ArmorUserData
} }
onClick={async (event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
const shot = (await shoot(
event.point, const shot = shoot(event.point, event.intersections, true)!;
event.intersections,
true, Tankopedia.mutate((draft) => {
))!; draft.shot = shot;
tankopediaEphemeralStore.setState({ shot }); });
}} }}
/> />
); );
@@ -1,22 +1,22 @@
import { useThree, type ThreeEvent } from '@react-three/fiber'; import { useThree, type ThreeEvent } from "@react-three/fiber";
import { memo, useRef } from 'react'; import { memo, useRef } from "react";
import { Group, Plane, Vector2, Vector3 } from 'three'; import { Group, Plane, Vector2, Vector3 } from "three";
import { applyPitchYawLimits } from '../../../core/blitz/applyPitchYawLimits'; import { applyPitchYawLimits } from "../../../core/blitz/applyPitchYawLimits";
import { correctZYTuple } from '../../../core/blitz/correctZYTuple'; import { correctZYTuple } from "../../../core/blitz/correctZYTuple";
import { hasEquipment } from '../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../core/blitzkit/hasEquipment";
import { modelTransformEvent } from '../../../core/blitzkit/modelTransform'; import { modelTransformEvent } from "../../../core/blitzkit/modelTransform";
import { nameToArmorId } from '../../../core/blitzkit/nameToArmorId'; import { nameToArmorId } from "../../../core/blitzkit/nameToArmorId";
import { resolveArmor } from '../../../core/blitzkit/resolveThickness'; import { resolveArmor } from "../../../core/blitzkit/resolveThickness";
import { useArmor } from '../../../hooks/useArmor'; import { useArmor } from "../../../hooks/useArmor";
import { useModel } from '../../../hooks/useModel'; import { useModel } from "../../../hooks/useModel";
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition'; import { useTankModelDefinition } from "../../../hooks/useTankModelDefinition";
import { useTankTransform } from '../../../hooks/useTankTransform'; import { useTankTransform } from "../../../hooks/useTankTransform";
import { Duel } from '../../../stores/duel'; import { Duel } from "../../../stores/duel";
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../stores/tankopedia";
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent'; import { TankopediaPersistent } from "../../../stores/tankopediaPersistent";
import { ModelTankWrapper } from './ModelTankWrapper'; import { ModelTankWrapper } from "./ModelTankWrapper";
import { ArmorType } from './SpacedArmorScene'; import { ArmorType } from "./SpacedArmorScene";
import { StaticArmorSceneComponent } from './StaticArmorSceneComponent'; import { StaticArmorSceneComponent } from "./StaticArmorSceneComponent";
interface ArmorSceneProps { interface ArmorSceneProps {
thicknessRange: ThicknessRange; thicknessRange: ThicknessRange;
@@ -30,7 +30,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const wrapper = useRef<Group>(null); const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
const protagonist = Duel.use((draft) => draft.protagonist);
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track); const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
@@ -46,25 +45,23 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const hullOrigin = correctZYTuple(trackModelDefinition.origin); const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin); const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin);
const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin); const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin);
const mutateDuel = Duel.useMutation();
const canvas = useThree((state) => state.gl.domElement); const canvas = useThree((state) => state.gl.domElement);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const duelStore = Duel.useStore();
const maskOrigin = const maskOrigin =
gunModelDefinition.mask === undefined gunModelDefinition.mask === undefined
? undefined ? undefined
: gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y; : gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y;
const showPrimaryArmor = TankopediaPersistent.use( const showPrimaryArmor = TankopediaPersistent.use(
(state) => state.showPrimaryArmor, (state) => state.showPrimaryArmor
); );
const showSpacedArmor = TankopediaPersistent.use( const showSpacedArmor = TankopediaPersistent.use(
(state) => state.showSpacedArmor, (state) => state.showSpacedArmor
); );
const showExternalModules = TankopediaPersistent.use( const showExternalModules = TankopediaPersistent.use(
(state) => state.showExternalModules, (state) => state.showExternalModules
); );
const isDynamicArmorActive = Duel.use((state) => const isDynamicArmorActive = Duel.use((state) =>
state.protagonist.consumables.includes(73), state.protagonist.consumables.includes(73)
); );
useTankTransform(track, turret, turretContainer, gunContainer); useTankTransform(track, turret, turretContainer, gunContainer);
@@ -73,12 +70,12 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
<ModelTankWrapper ref={wrapper}> <ModelTankWrapper ref={wrapper}>
<group position={hullOrigin}> <group position={hullOrigin}>
{armorNodes.map((node) => { {armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_'); const isHull = node.name.startsWith("hull_");
const isVisible = isHull; const isVisible = isHull;
const armorId = nameToArmorId(node.name); const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor( const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor, tankModelDefinition.armor,
armorId, armorId
); );
if ( if (
@@ -86,8 +83,8 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
thickness === undefined || thickness === undefined ||
(!showPrimaryArmor && !spaced) || (!showPrimaryArmor && !spaced) ||
(!showSpacedArmor && spaced) || (!showSpacedArmor && spaced) ||
(isDynamicArmorActive && node.name.includes('state_00')) || (isDynamicArmorActive && node.name.includes("state_00")) ||
(!isDynamicArmorActive && node.name.includes('state_01')) (!isDynamicArmorActive && node.name.includes("state_01"))
) )
return null; return null;
@@ -106,8 +103,8 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
</group> </group>
{modelNodes.map((node) => { {modelNodes.map((node) => {
const isWheel = node.name.startsWith('chassis_wheel_'); const isWheel = node.name.startsWith("chassis_wheel_");
const isTrack = node.name.startsWith('chassis_track_'); const isTrack = node.name.startsWith("chassis_track_");
const isVisible = isWheel || isTrack; const isVisible = isWheel || isTrack;
if (!isVisible || !showExternalModules) return null; if (!isVisible || !showExternalModules) return null;
@@ -129,13 +126,13 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
<group ref={turretContainer}> <group ref={turretContainer}>
{armorNodes.map((node) => { {armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith( const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model_id.toString().padStart(2, '0')}`, `turret_${turretModelDefinition.model_id.toString().padStart(2, "0")}`
); );
const isVisible = isCurrentTurret; const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name); const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor( const { spaced, thickness } = resolveArmor(
turretModelDefinition.armor, turretModelDefinition.armor,
armorId, armorId
); );
if ( if (
@@ -143,8 +140,8 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
thickness === undefined || thickness === undefined ||
(!showPrimaryArmor && !spaced) || (!showPrimaryArmor && !spaced) ||
(!showSpacedArmor && spaced) || (!showSpacedArmor && spaced) ||
(isDynamicArmorActive && node.name.includes('state_00')) || (isDynamicArmorActive && node.name.includes("state_00")) ||
(!isDynamicArmorActive && node.name.includes('state_01')) (!isDynamicArmorActive && node.name.includes("state_01"))
) { ) {
return null; return null;
} }
@@ -157,27 +154,26 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
async function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState(); const hasImprovedVerticalStabilizer = hasEquipment(
const hasImprovedVerticalStabilizer = await hasEquipment(
122, 122,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const hasDownImprovedVerticalStabilizer = await hasEquipment( const hasDownImprovedVerticalStabilizer = hasEquipment(
124, 124,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const boundingRect = canvas.getBoundingClientRect(); const boundingRect = canvas.getBoundingClientRect();
@@ -191,16 +187,16 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
return ( return (
@@ -209,7 +205,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
<StaticArmorSceneComponent <StaticArmorSceneComponent
name={node.name} name={node.name}
thicknessRange={thicknessRange} thicknessRange={thicknessRange}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Primary} type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness} thickness={thickness}
node={node} node={node}
@@ -223,13 +218,13 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
<group ref={gunContainer}> <group ref={gunContainer}>
{armorNodes.map((node) => { {armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith( const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model_id.toString().padStart(2, '0')}`, `gun_${gunModelDefinition.model_id.toString().padStart(2, "0")}`
); );
const isVisible = isCurrentGun; const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name); const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor( const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor, gunModelDefinition.armor,
armorId, armorId
); );
if ( if (
@@ -237,8 +232,8 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
thickness === undefined || thickness === undefined ||
(!showPrimaryArmor && !spaced) || (!showPrimaryArmor && !spaced) ||
(!showSpacedArmor && spaced) || (!showSpacedArmor && spaced) ||
(isDynamicArmorActive && node.name.includes('state_00')) || (isDynamicArmorActive && node.name.includes("state_00")) ||
(!isDynamicArmorActive && node.name.includes('state_01')) (!isDynamicArmorActive && node.name.includes("state_01"))
) )
return null; return null;
@@ -248,30 +243,29 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation(); event.stopPropagation();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
async function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState(); const hasImprovedVerticalStabilizer = hasEquipment(
const hasImprovedVerticalStabilizer = await hasEquipment(
122, 122,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const hasDownImprovedVerticalStabilizer = await hasEquipment( const hasDownImprovedVerticalStabilizer = hasEquipment(
124, 124,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const boundingRect = canvas.getBoundingClientRect(); const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position); delta.set(event.clientX, event.clientY).sub(position);
@@ -285,16 +279,16 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
return ( return (
@@ -316,7 +310,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
{modelNodes.map((node) => { {modelNodes.map((node) => {
const gunString = `gun_${gunModelDefinition.model_id const gunString = `gun_${gunModelDefinition.model_id
.toString() .toString()
.padStart(2, '0')}`; .padStart(2, "0")}`;
const isCurrentGun = gunModelDefinition.mask const isCurrentGun = gunModelDefinition.mask
? node.name.startsWith(gunString) ? node.name.startsWith(gunString)
: node.name === gunString; : node.name === gunString;
@@ -330,29 +324,28 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation(); event.stopPropagation();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
async function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState(); const hasImprovedVerticalStabilizer = hasEquipment(
const hasImprovedVerticalStabilizer = await hasEquipment(
122, 122,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const hasDownImprovedVerticalStabilizer = await hasEquipment( const hasDownImprovedVerticalStabilizer = hasEquipment(
124, 124,
duel.protagonist.tank.equipment_preset, Duel.state.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, Duel.state.protagonist.equipmentMatrix
); );
const boundingRect = canvas.getBoundingClientRect(); const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position); delta.set(event.clientX, event.clientY).sub(position);
@@ -366,17 +359,17 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
return ( return (
@@ -1,8 +1,8 @@
import { I_HAT, J_HAT } from '@blitzkit/core'; import { I_HAT, J_HAT } from "@blitzkit/core";
import { type MeshProps, useThree } from '@react-three/fiber'; import { type MeshProps, useThree } from "@react-three/fiber";
import { clamp } from 'lodash-es'; import { clamp } from "lodash-es";
import type { QuicklimeEvent } from 'quicklime'; import type { QuicklimeEvent } from "quicklime";
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from "react";
import { import {
Box3, Box3,
Color, Color,
@@ -10,33 +10,33 @@ import {
FrontSide, FrontSide,
LineBasicMaterial, LineBasicMaterial,
Mesh, Mesh,
MeshStandardMaterial, MeshBasicMaterial,
Object3D, Object3D,
Plane, Plane,
Quaternion, Quaternion,
Vector3, Vector3,
} from 'three'; } from "three";
import { unrotateDavaVector } from '../../../core/blitz/unrotateDavaVector'; import { unrotateDavaVector } from "../../../core/blitz/unrotateDavaVector";
import { jsxTree } from '../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../core/blitzkit/jsxTree";
import { import {
type ModelTransformEventData, type ModelTransformEventData,
modelTransformEvent, modelTransformEvent,
} from '../../../core/blitzkit/modelTransform'; } from "../../../core/blitzkit/modelTransform";
import { discardClippingPlane } from '../../../core/three/discardClippingPlane'; import { discardClippingPlane } from "../../../core/three/discardClippingPlane";
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../stores/tankopedia";
import { ArmorType } from './SpacedArmorScene'; import { ArmorType } from "./SpacedArmorScene";
import type { import type {
ArmorUserData, ArmorUserData,
ExternalModuleVariant, ExternalModuleVariant,
} from './SpacedArmorSceneComponent'; } from "./SpacedArmorSceneComponent";
import type { ThicknessRange } from './StaticArmor'; import type { ThicknessRange } from "./StaticArmor";
type StaticArmorSceneComponentProps = { type StaticArmorSceneComponentProps = {
name: string; name: string;
thickness: number; thickness: number;
thicknessRange: ThicknessRange; thicknessRange: ThicknessRange;
node: Object3D; node: Object3D;
onPointerDown?: MeshProps['onPointerDown']; onPointerDown?: MeshProps["onPointerDown"];
} & ( } & (
| { | {
type: Exclude<ArmorType, ArmorType.External>; type: Exclude<ArmorType, ArmorType.External>;
@@ -69,8 +69,6 @@ export function StaticArmorSceneComponent({
onPointerDown, onPointerDown,
...props ...props
}: StaticArmorSceneComponentProps) { }: StaticArmorSceneComponentProps) {
const mutateTankopediaEphemeralStore = TankopediaEphemeral.useMutation();
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const x = thickness / thicknessRange.value; const x = thickness / thicknessRange.value;
const xClamped = clamp(x, 0, 1); const xClamped = clamp(x, 0, 1);
@@ -92,7 +90,7 @@ export function StaticArmorSceneComponent({
color = new Color( color = new Color(
clamp(1 - (7 / 8) * xClamped, 0, 1), clamp(1 - (7 / 8) * xClamped, 0, 1),
0, 0,
clamp(1 - (1 / 8) * xClamped, 0, 1), clamp(1 - (1 / 8) * xClamped, 0, 1)
); );
opacity = clamp(x + 1 / 2, 0, 1); opacity = clamp(x + 1 / 2, 0, 1);
break; break;
@@ -108,14 +106,14 @@ export function StaticArmorSceneComponent({
const surfaceMaterial = useMemo( const surfaceMaterial = useMemo(
() => () =>
new MeshStandardMaterial({ new MeshBasicMaterial({
color, color,
opacity, opacity,
transparent: opacity < 1, transparent: opacity < 1,
depthWrite, depthWrite,
...(props.clip ? { clippingPlanes: [props.clip] } : {}), ...(props.clip ? { clippingPlanes: [props.clip] } : {}),
}), }),
[thickness], [thickness]
); );
const outlineMaterial = useMemo( const outlineMaterial = useMemo(
() => () =>
@@ -124,7 +122,7 @@ export function StaticArmorSceneComponent({
.clone() .clone()
.multiplyScalar(props.type === ArmorType.Spaced ? 2 ** 2 : 2 ** -1), .multiplyScalar(props.type === ArmorType.Spaced ? 2 ** 2 : 2 ** -1),
}), }),
[thickness], [thickness]
); );
useEffect(() => { useEffect(() => {
@@ -139,11 +137,11 @@ export function StaticArmorSceneComponent({
outlineMaterial.visible = true; outlineMaterial.visible = true;
} else if ( } else if (
selectedName === name || selectedName === name ||
(name.startsWith('chassis_') && selectedName.startsWith('chassis_')) || (name.startsWith("chassis_") && selectedName.startsWith("chassis_")) ||
(name.startsWith('gun_') && (name.startsWith("gun_") &&
selectedName.startsWith('gun_') && selectedName.startsWith("gun_") &&
!name.includes('_armor_') && !name.includes("_armor_") &&
!selectedName.includes('_armor_')) !selectedName.includes("_armor_"))
) { ) {
// this selected, stand out! // this selected, stand out!
surfaceMaterial.opacity = 1; surfaceMaterial.opacity = 1;
@@ -166,9 +164,9 @@ export function StaticArmorSceneComponent({
} }
const unsubscribes = [ const unsubscribes = [
tankopediaEphemeralStore.subscribe( Tankopedia.on(
(state) => state.highlightArmor?.name, (state) => state.highlightArmor?.name,
handleHighlightArmor, handleHighlightArmor
), ),
]; ];
@@ -181,14 +179,14 @@ export function StaticArmorSceneComponent({
const { clip } = props; const { clip } = props;
const gunOrigin = unrotateDavaVector(props.gunOrigin.clone()); const gunOrigin = unrotateDavaVector(props.gunOrigin.clone());
const neckOrigin = unrotateDavaVector(props.hullOrigin.clone()).add( const neckOrigin = unrotateDavaVector(props.hullOrigin.clone()).add(
unrotateDavaVector(props.turretOrigin.clone()), unrotateDavaVector(props.turretOrigin.clone())
); );
const barrelOrigin = neckOrigin.clone().add(gunOrigin); const barrelOrigin = neckOrigin.clone().add(gunOrigin);
const distanceToBarrel = clip.distanceToPoint(barrelOrigin); const distanceToBarrel = clip.distanceToPoint(barrelOrigin);
const point = new Vector3(); const point = new Vector3();
function handleModelTransform( function handleModelTransform(
event: QuicklimeEvent<ModelTransformEventData>, event: QuicklimeEvent<ModelTransformEventData>
) { ) {
clip.normal clip.normal
.set(0, 0, -1) .set(0, 0, -1)
@@ -229,7 +227,7 @@ export function StaticArmorSceneComponent({
{ {
type: props.type, type: props.type,
variant: variant:
props.type === ArmorType.External ? props.variant : 'gun', props.type === ArmorType.External ? props.variant : "gun",
thickness, thickness,
} satisfies ArmorUserData } satisfies ArmorUserData
} }
@@ -238,7 +236,7 @@ export function StaticArmorSceneComponent({
event.stopPropagation(); event.stopPropagation();
const { editStatic } = tankopediaEphemeralStore.getState(); const { editStatic } = Tankopedia.state;
const bounds = new Box3().setFromObject(event.object); const bounds = new Box3().setFromObject(event.object);
const point = bounds.min const point = bounds.min
@@ -253,13 +251,13 @@ export function StaticArmorSceneComponent({
const surfaceNormal = event const surfaceNormal = event
.normal!.clone() .normal!.clone()
.applyQuaternion( .applyQuaternion(
event.object.getWorldQuaternion(new Quaternion()), event.object.getWorldQuaternion(new Quaternion())
); );
const angle = surfaceNormal.angleTo(cameraNormal); const angle = surfaceNormal.angleTo(cameraNormal);
const thicknessAngled = const thicknessAngled =
thickness / Math.sin(Math.PI / 2 - angle); thickness / Math.sin(Math.PI / 2 - angle);
mutateTankopediaEphemeralStore((draft) => { Tankopedia.mutate((draft) => {
draft.highlightArmor = { draft.highlightArmor = {
type: props.type, type: props.type,
name, name,
@@ -1,19 +1,19 @@
import { assertSecret, idToRegion } from '@blitzkit/core'; import { assertSecret, idToRegion } from "@blitzkit/core";
import { Button, Flex, Heading, Text } from '@radix-ui/themes'; import { Button, Flex, Heading, Text } from "@radix-ui/themes";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { import {
LocaleProvider, LocaleProvider,
useLocale, useLocale,
type LocaleAcceptorProps, type LocaleAcceptorProps,
} from '../../hooks/useLocale'; } from "../../hooks/useLocale";
import { App } from '../../stores/app'; import { App } from "../../stores/app";
import { CURRENT_POLICIES_AGREEMENT_INDEX } from '../../stores/app/constants'; import { CURRENT_POLICIES_AGREEMENT_INDEX } from "../../stores/app/constants";
import { LinkI18n } from '../LinkI18n'; import { LinkI18n } from "../LinkI18n";
import './index.css'; import "./index.css";
type Extension = type Extension =
| { | {
status: 'ok'; status: "ok";
data: { data: {
access_token: string; access_token: string;
account_id: number; account_id: number;
@@ -21,15 +21,13 @@ type Extension =
}; };
} }
| { | {
status: 'error'; status: "error";
}; };
export function Checks({ locale }: LocaleAcceptorProps) { export function Checks({ locale }: LocaleAcceptorProps) {
return ( return (
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<App.Provider> <Content />
<Content />
</App.Provider>
</LocaleProvider> </LocaleProvider>
); );
} }
@@ -40,19 +38,17 @@ export function Checks({ locale }: LocaleAcceptorProps) {
function Content() { function Content() {
const { locale } = useLocale(); const { locale } = useLocale();
const logins = App.use((state) => state.logins); const logins = App.use((state) => state.logins);
const mutateApp = App.useMutation();
const appStore = App.useStore();
const policiesAgreementIndex = App.use( const policiesAgreementIndex = App.use(
(state) => state.policiesAgreementIndex, (state) => state.policiesAgreementIndex
); );
const isLegal = window.location.pathname.startsWith('/legal'); const isLegal = window.location.pathname.startsWith("/legal");
const [showPoliciesAgreement, setShowPoliciesAgreement] = useState(false); const [showPoliciesAgreement, setShowPoliciesAgreement] = useState(false);
useEffect(() => { useEffect(() => {
function unsubscribe() { function unsubscribe() {
window.removeEventListener('scroll', show); window.removeEventListener("scroll", show);
window.removeEventListener('pointermove', show); window.removeEventListener("pointermove", show);
window.removeEventListener('pointerdown', show); window.removeEventListener("pointerdown", show);
} }
function show() { function show() {
@@ -64,9 +60,9 @@ function Content() {
policiesAgreementIndex !== CURRENT_POLICIES_AGREEMENT_INDEX && policiesAgreementIndex !== CURRENT_POLICIES_AGREEMENT_INDEX &&
!isLegal !isLegal
) { ) {
window.addEventListener('scroll', show); window.addEventListener("scroll", show);
window.addEventListener('pointermove', show); window.addEventListener("pointermove", show);
window.addEventListener('pointerdown', show); window.addEventListener("pointerdown", show);
} }
return unsubscribe; return unsubscribe;
@@ -78,25 +74,25 @@ function Content() {
const expiresInDays = expiresIn / 1000 / 60 / 60 / 24; const expiresInDays = expiresIn / 1000 / 60 / 60 / 24;
if (expiresInDays < 0) { if (expiresInDays < 0) {
mutateApp((draft) => { App.mutate((draft) => {
draft.logins.wargaming = undefined; draft.logins.wargaming = undefined;
}); });
} else if (expiresInDays < 7) { } else if (expiresInDays < 7) {
const { wargaming } = appStore.getState().logins; const { wargaming } = App.state.logins;
if (!wargaming) return; if (!wargaming) return;
fetch( fetch(
`https://api.worldoftanks.${idToRegion( `https://api.worldoftanks.${idToRegion(
wargaming.id, wargaming.id
)}/wot/auth/prolongate/?application_id=${assertSecret( )}/wot/auth/prolongate/?application_id=${assertSecret(
import.meta.env.PUBLIC_WARGAMING_APPLICATION_ID, import.meta.env.PUBLIC_WARGAMING_APPLICATION_ID
)}&access_token=${wargaming.token}`, )}&access_token=${wargaming.token}`
) )
.then((response) => response.json() as Promise<Extension>) .then((response) => response.json() as Promise<Extension>)
.then((json) => { .then((json) => {
mutateApp((draft) => { App.mutate((draft) => {
if (json.status === 'error') { if (json.status === "error") {
draft.logins.wargaming = undefined; draft.logins.wargaming = undefined;
} else { } else {
draft.logins.wargaming = { draft.logins.wargaming = {
@@ -126,11 +122,11 @@ function Content() {
<Text> <Text>
This website utilizes cookies to perform analytics and personalize This website utilizes cookies to perform analytics and personalize
your experience. You can learn more through{' '} your experience. You can learn more through{" "}
<LinkI18n locale={locale} href="/docs/legal/privacy-policy"> <LinkI18n locale={locale} href="/docs/legal/privacy-policy">
our privacy policy our privacy policy
</LinkI18n> </LinkI18n>
. By using BlitzKit, you also agree to our{' '} . By using BlitzKit, you also agree to our{" "}
<LinkI18n locale={locale} href="/docs/legal/terms-of-service"> <LinkI18n locale={locale} href="/docs/legal/terms-of-service">
terms of service terms of service
</LinkI18n> </LinkI18n>
@@ -141,8 +137,8 @@ function Content() {
<Button <Button
mt="2" mt="2"
onClick={() => { onClick={() => {
appStore.setState({ App.mutate((draft) => {
policiesAgreementIndex: CURRENT_POLICIES_AGREEMENT_INDEX, draft.policiesAgreementIndex = CURRENT_POLICIES_AGREEMENT_INDEX;
}); });
setShowPoliciesAgreement(false); setShowPoliciesAgreement(false);
}} }}
@@ -1,11 +1,11 @@
import { asset } from '@blitzkit/core'; import { asset } from "@blitzkit/core";
import { Flex, Heading, IconButton, Table } from '@radix-ui/themes'; import { Flex, Heading, IconButton, Table } from "@radix-ui/themes";
import type { TankCharacteristics } from '../../core/blitzkit/tankCharacteristics'; import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell'; import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
import { CompareRow } from './CompareRow'; import { CompareRow } from "./CompareRow";
import { CompareSectionTitle } from './CompareSectionTitle'; import { CompareSectionTitle } from "./CompareSectionTitle";
interface BodyProps { interface BodyProps {
stats: TankCharacteristics[]; stats: TankCharacteristics[];
@@ -14,10 +14,9 @@ interface BodyProps {
export function Body({ stats }: BodyProps) { export function Body({ stats }: BodyProps) {
const members = CompareEphemeral.use((state) => state.members); const members = CompareEphemeral.use((state) => state.members);
const hasNonRegularGun = members.some( const hasNonRegularGun = members.some(
({ gun }) => gun.gun_type!.$case !== 'regular', ({ gun }) => gun.gun_type!.$case !== "regular"
); );
const { unwrap, strings } = useLocale(); const { unwrap, strings } = useLocale();
const mutateCompareEphemeral = CompareEphemeral.useMutation();
return ( return (
<> <>
@@ -35,7 +34,7 @@ export function Body({ stats }: BodyProps) {
<Flex> <Flex>
{gun.shells.map((thisShell, shellIndex) => ( {gun.shells.map((thisShell, shellIndex) => (
<IconButton <IconButton
color={thisShell.id === shell.id ? undefined : 'gray'} color={thisShell.id === shell.id ? undefined : "gray"}
variant="soft" variant="soft"
key={thisShell.id} key={thisShell.id}
style={{ style={{
@@ -49,7 +48,7 @@ export function Body({ stats }: BodyProps) {
marginLeft: shellIndex === 0 ? 0 : -1, marginLeft: shellIndex === 0 ? 0 : -1,
}} }}
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].shell = thisShell; draft.members[index].shell = thisShell;
}); });
}} }}
@@ -88,7 +87,7 @@ export function Body({ stats }: BodyProps) {
} }
display={(stats) => display={(stats) =>
stats.shellReload?.toFixed(2) ?? stats.shellReload?.toFixed(2) ??
stats.shellReloads!.map((reload) => reload.toFixed(2)).join(', ') stats.shellReloads!.map((reload) => reload.toFixed(2)).join(", ")
} }
deltaNominalDisplay={(delta) => delta.toFixed(2)} deltaNominalDisplay={(delta) => delta.toFixed(2)}
/> />
@@ -2,21 +2,21 @@ import {
CaretLeftIcon, CaretLeftIcon,
CaretRightIcon, CaretRightIcon,
CaretSortIcon, CaretSortIcon,
} from '@radix-ui/react-icons'; } from "@radix-ui/react-icons";
import { Flex, IconButton, Table, Text } from '@radix-ui/themes'; import { Flex, IconButton, Table, Text } from "@radix-ui/themes";
import { useRef } from 'react'; import { useRef } from "react";
import type { TankCharacteristics } from '../../core/blitzkit/tankCharacteristics'; import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
import { Var } from '../../core/radix/var'; import { Var } from "../../core/radix/var";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { ComparePersistent } from '../../stores/comparePersistent'; import { ComparePersistent } from "../../stores/comparePersistent";
import { StickyRowHeaderCell } from '../StickyRowHeaderCell'; import { StickyRowHeaderCell } from "../StickyRowHeaderCell";
type CompareRowProps = { type CompareRowProps = {
display?: ( display?: (
member: Awaited<TankCharacteristics>, member: Awaited<TankCharacteristics>
) => number | string | undefined; ) => number | string | undefined;
deltaType?: 'higherIsBetter' | 'lowerIsBetter'; deltaType?: "higherIsBetter" | "lowerIsBetter";
decimals?: number; decimals?: number;
deltaNominalDisplay?: (delta: number) => string | number; deltaNominalDisplay?: (delta: number) => string | number;
indent?: boolean; indent?: boolean;
@@ -33,7 +33,7 @@ type CompareRowProps = {
export function CompareRow({ export function CompareRow({
display, display,
deltaType = 'higherIsBetter', deltaType = "higherIsBetter",
decimals, decimals,
deltaNominalDisplay, deltaNominalDisplay,
indent, indent,
@@ -41,17 +41,16 @@ export function CompareRow({
...props ...props
}: CompareRowProps) { }: CompareRowProps) {
const { strings } = useLocale(); const { strings } = useLocale();
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const sorting = CompareEphemeral.use((state) => state.sorting); const sorting = CompareEphemeral.use((state) => state.sorting);
const deltaMode = ComparePersistent.use((state) => state.deltaMode); const deltaMode = ComparePersistent.use((state) => state.deltaMode);
const values = stats.map((stat) => const values = stats.map((stat) =>
typeof props.value === 'function' typeof props.value === "function"
? props.value(stat)! ? props.value(stat)!
: (stat[props.value] as number), : (stat[props.value] as number)
); );
const id = useRef(Math.random()); const id = useRef(Math.random());
const name = const name =
typeof props.value === 'string' typeof props.value === "string"
? strings.website.tools.tankopedia.characteristics.values[props.value] ? strings.website.tools.tankopedia.characteristics.values[props.value]
: props.name; : props.name;
@@ -60,21 +59,21 @@ export function CompareRow({
<StickyRowHeaderCell> <StickyRowHeaderCell>
<Flex <Flex
align="center" align="center"
style={{ whiteSpace: 'nowrap' }} style={{ whiteSpace: "nowrap" }}
gap={indent ? { initial: '4', sm: '6' } : { initial: '1', sm: '2' }} gap={indent ? { initial: "4", sm: "6" } : { initial: "1", sm: "2" }}
> >
<IconButton <IconButton
color={sorting?.by === id.current ? undefined : 'gray'} color={sorting?.by === id.current ? undefined : "gray"}
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members.sort((memberA, memberB) => { draft.members.sort((memberA, memberB) => {
const indexA = draft.members.indexOf(memberA); const indexA = draft.members.indexOf(memberA);
const indexB = draft.members.indexOf(memberB); const indexB = draft.members.indexOf(memberB);
const valueA = values[indexA]; const valueA = values[indexA];
const valueB = values[indexB]; const valueB = values[indexB];
return draft.sorting?.direction === 'ascending' && return draft.sorting?.direction === "ascending" &&
draft.sorting.by === id.current draft.sorting.by === id.current
? valueA - valueB ? valueA - valueB
: valueB - valueA; : valueB - valueA;
@@ -83,26 +82,26 @@ export function CompareRow({
draft.sorting = { draft.sorting = {
by: id.current, by: id.current,
direction: direction:
draft.sorting?.direction === 'ascending' && draft.sorting?.direction === "ascending" &&
draft.sorting.by === id.current draft.sorting.by === id.current
? 'descending' ? "descending"
: 'ascending', : "ascending",
}; };
}); });
}} }}
> >
{(sorting === undefined || sorting.by !== id.current) && ( {(sorting === undefined || sorting.by !== id.current) && (
<CaretSortIcon style={{ transform: 'rotate(90deg)' }} /> <CaretSortIcon style={{ transform: "rotate(90deg)" }} />
)} )}
{sorting?.by === id.current && ( {sorting?.by === id.current && (
<> <>
{sorting.direction === 'ascending' && <CaretRightIcon />} {sorting.direction === "ascending" && <CaretRightIcon />}
{sorting.direction === 'descending' && <CaretLeftIcon />} {sorting.direction === "descending" && <CaretLeftIcon />}
</> </>
)} )}
</IconButton> </IconButton>
<Text size={{ initial: '1', sm: '2' }}>{name}</Text> <Text size={{ initial: "1", sm: "2" }}>{name}</Text>
</Flex> </Flex>
</StickyRowHeaderCell> </StickyRowHeaderCell>
@@ -110,7 +109,7 @@ export function CompareRow({
const delta = value - values[0]; const delta = value - values[0];
const deltaPercentage = value / values[0] - 1; const deltaPercentage = value / values[0] - 1;
const normalizedDeltaPercentage = Math.round( const normalizedDeltaPercentage = Math.round(
Math.min(100, Math.abs(deltaPercentage) * 2 * 100 + 25), Math.min(100, Math.abs(deltaPercentage) * 2 * 100 + 25)
); );
const resolvedDisplayValue = display const resolvedDisplayValue = display
? display(stats[index]) ? display(stats[index])
@@ -129,12 +128,12 @@ export function CompareRow({
value === values[0] value === values[0]
? undefined ? undefined
: ( : (
deltaType === 'higherIsBetter' deltaType === "higherIsBetter"
? value > values[0] ? value > values[0]
: value < values[0] : value < values[0]
) )
? `color-mix(in srgb, ${Var('green-7')} ${normalizedDeltaPercentage}%, ${Var('green-3')})` ? `color-mix(in srgb, ${Var("green-7")} ${normalizedDeltaPercentage}%, ${Var("green-3")})`
: `color-mix(in srgb, ${Var('red-7')} ${normalizedDeltaPercentage}%, ${Var('red-3')})`, : `color-mix(in srgb, ${Var("red-7")} ${normalizedDeltaPercentage}%, ${Var("red-3")})`,
}} }}
> >
<Flex <Flex
@@ -142,11 +141,11 @@ export function CompareRow({
justify="center" justify="center"
gap="2" gap="2"
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "100%",
}} }}
> >
<Text style={{ textAlign: 'center' }} wrap="nowrap"> <Text style={{ textAlign: "center" }} wrap="nowrap">
{resolvedDisplayValue} {resolvedDisplayValue}
</Text> </Text>
@@ -154,10 +153,10 @@ export function CompareRow({
resolvedDisplayValue !== undefined && resolvedDisplayValue !== undefined &&
values[0] !== undefined && ( values[0] !== undefined && (
<> <>
{deltaMode === 'absolute' && ( {deltaMode === "absolute" && (
<Text color="gray"> <Text color="gray">
( (
{`${delta > 0 ? '+' : ''}${ {`${delta > 0 ? "+" : ""}${
deltaNominalDisplay deltaNominalDisplay
? deltaNominalDisplay(delta) ? deltaNominalDisplay(delta)
: decimals === undefined : decimals === undefined
@@ -168,11 +167,11 @@ export function CompareRow({
</Text> </Text>
)} )}
{deltaMode === 'percentage' && ( {deltaMode === "percentage" && (
<Text color="gray"> <Text color="gray">
( (
{`${deltaPercentage > 0 ? '+' : ''}${Math.round( {`${deltaPercentage > 0 ? "+" : ""}${Math.round(
deltaPercentage * 100, deltaPercentage * 100
)}%`} )}%`}
) )
</Text> </Text>
@@ -1,28 +1,28 @@
import { asset, availableProvisions, permanentSkills } from '@blitzkit/core'; import { asset, availableProvisions, permanentSkills } from "@blitzkit/core";
import { checkConsumableProvisionInclusivity } from '@blitzkit/core/src/blitzkit/checkConsumableProvisionInclusivity'; import { checkConsumableProvisionInclusivity } from "@blitzkit/core/src/blitzkit/checkConsumableProvisionInclusivity";
import { ComponentPlaceholderIcon } from '@radix-ui/react-icons'; import { ComponentPlaceholderIcon } from "@radix-ui/react-icons";
import { Button, Flex, IconButton, Popover, Table } from '@radix-ui/themes'; import { Button, Flex, IconButton, Popover, Table } from "@radix-ui/themes";
import { times } from 'lodash-es'; import { times } from "lodash-es";
import { awaitableConsumableDefinitions } from '../../core/awaitables/consumableDefinitions'; import { awaitableConsumableDefinitions } from "../../core/awaitables/consumableDefinitions";
import { awaitableEquipmentDefinitions } from '../../core/awaitables/equipmentDefinitions'; import { awaitableEquipmentDefinitions } from "../../core/awaitables/equipmentDefinitions";
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions'; import { awaitableProvisionDefinitions } from "../../core/awaitables/provisionDefinitions";
import { awaitableSkillDefinitions } from '../../core/awaitables/skillDefinitions'; import { awaitableSkillDefinitions } from "../../core/awaitables/skillDefinitions";
import type { TankCharacteristics } from '../../core/blitzkit/tankCharacteristics'; import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
import { Var } from '../../core/radix/var'; import { Var } from "../../core/radix/var";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import type { EquipmentMatrix } from '../../stores/duel'; import type { EquipmentMatrix } from "../../stores/duel";
import { BlitzkitButtonGrayIcon } from '../BlitzkitButtonGrayIcon'; import { BlitzkitButtonGrayIcon } from "../BlitzkitButtonGrayIcon";
import { ConsumablesManager } from '../ConsumablesManager'; import { ConsumablesManager } from "../ConsumablesManager";
import { CrewSkillManager } from '../CrewSkillManager'; import { CrewSkillManager } from "../CrewSkillManager";
import { EquipmentManager } from '../EquipmentManager'; import { EquipmentManager } from "../EquipmentManager";
import { ModuleManager } from '../ModuleManager'; import { ModuleManager } from "../ModuleManager";
import { ProvisionsManager } from '../ProvisionsManager'; import { ProvisionsManager } from "../ProvisionsManager";
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell'; import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
import { StickyTableRoot } from '../StickyTableRoot'; import { StickyTableRoot } from "../StickyTableRoot";
import { Body } from './Body'; import { Body } from "./Body";
import { InsertionMarker } from './IntersectionMarker'; import { InsertionMarker } from "./IntersectionMarker";
import { TankCard } from './TankCard'; import { TankCard } from "./TankCard";
interface CompareTableProps { interface CompareTableProps {
stats: TankCharacteristics[]; stats: TankCharacteristics[];
@@ -43,7 +43,6 @@ const [
export function CompareTable({ stats }: CompareTableProps) { export function CompareTable({ stats }: CompareTableProps) {
const crewSkills = CompareEphemeral.use((state) => state.crewSkills); const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
const members = CompareEphemeral.use((state) => state.members); const members = CompareEphemeral.use((state) => state.members);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const { unwrap, strings } = useLocale(); const { unwrap, strings } = useLocale();
return ( return (
@@ -51,20 +50,20 @@ export function CompareTable({ stats }: CompareTableProps) {
size="1" size="1"
variant="surface" variant="surface"
style={{ style={{
position: 'absolute', position: "absolute",
maxWidth: '100%', maxWidth: "100%",
maxHeight: '100%', maxHeight: "100%",
height: '100%', height: "100%",
// height: 'calc(100vh - 128px)' // height: 'calc(100vh - 128px)'
}} }}
> >
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<StickyColumnHeaderCell width="0" style={{ height: '100%' }}> <StickyColumnHeaderCell width="0" style={{ height: "100%" }}>
<Flex <Flex
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "100%",
}} }}
align="center" align="center"
justify="center" justify="center"
@@ -82,11 +81,11 @@ export function CompareTable({ stats }: CompareTableProps) {
<Table.Body> <Table.Body>
<Table.Row> <Table.Row>
<Table.Cell style={{ height: '100%' }}> <Table.Cell style={{ height: "100%" }}>
<Flex <Flex
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "100%",
}} }}
align="center" align="center"
justify="center" justify="center"
@@ -107,15 +106,15 @@ export function CompareTable({ stats }: CompareTableProps) {
borderRadius: 2, borderRadius: 2,
backgroundColor: backgroundColor:
crewSkills[skill] === 0 crewSkills[skill] === 0
? Var('gray-11') ? Var("gray-11")
: permanentSkills.includes(skill) : permanentSkills.includes(skill)
? Var('crimson-11') ? Var("crimson-11")
: Var('amber-11'), : Var("amber-11"),
}} }}
/> />
))} ))}
</Flex> </Flex>
), )
)} )}
</Flex> </Flex>
</Button> </Button>
@@ -125,7 +124,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<CrewSkillManager <CrewSkillManager
skillLevels={crewSkills} skillLevels={crewSkills}
onChange={(skills) => { onChange={(skills) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.crewSkills = skills; draft.crewSkills = skills;
}); });
}} }}
@@ -135,7 +134,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
Object.keys(draft.crewSkills).forEach((skill) => { Object.keys(draft.crewSkills).forEach((skill) => {
draft.crewSkills[skill] = 0; draft.crewSkills[skill] = 0;
}); });
@@ -147,7 +146,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
Object.keys(draft.crewSkills).forEach((skill) => { Object.keys(draft.crewSkills).forEach((skill) => {
draft.crewSkills[skill] = 7; draft.crewSkills[skill] = 7;
}); });
@@ -177,19 +176,19 @@ export function CompareTable({ stats }: CompareTableProps) {
consumables, consumables,
camouflage, camouflage,
}, },
index, index
) => { ) => {
const equipmentPreset = const equipmentPreset =
equipmentDefinitions.presets[tank.equipment_preset]; equipmentDefinitions.presets[tank.equipment_preset];
const provisionsList = availableProvisions( const provisionsList = availableProvisions(
tank, tank,
gun, gun,
provisionDefinitions, provisionDefinitions
); );
const consumablesList = Object.values( const consumablesList = Object.values(
consumableDefinitions.consumables, consumableDefinitions.consumables
).filter((consumable) => ).filter((consumable) =>
checkConsumableProvisionInclusivity(consumable, tank, gun), checkConsumableProvisionInclusivity(consumable, tank, gun)
); );
return ( return (
@@ -205,7 +204,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button variant="ghost"> <Button variant="ghost">
<Flex align="center"> <Flex align="center">
{( {(
['turret', 'gun', 'engine', 'chassis'] as const ["turret", "gun", "engine", "chassis"] as const
).map((module, index) => ( ).map((module, index) => (
<img <img
key={module} key={module}
@@ -215,7 +214,7 @@ export function CompareTable({ stats }: CompareTableProps) {
src={asset(`icons/modules/${module}.webp`)} src={asset(`icons/modules/${module}.webp`)}
style={{ style={{
marginLeft: index > 0 ? -8 : 0, marginLeft: index > 0 ? -8 : 0,
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
))} ))}
@@ -232,7 +231,7 @@ export function CompareTable({ stats }: CompareTableProps) {
engine={engine} engine={engine}
track={track} track={track}
onChange={(modules) => { onChange={(modules) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index] = { draft.members[index] = {
...draft.members[index], ...draft.members[index],
...modules, ...modules,
@@ -245,7 +244,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
const member = draft.members[index]; const member = draft.members[index];
member.turret = member.tank.turrets[0]; member.turret = member.tank.turrets[0];
@@ -261,7 +260,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
const member = draft.members[index]; const member = draft.members[index];
member.turret = member.tank.turrets.at(-1)!; member.turret = member.tank.turrets.at(-1)!;
@@ -288,9 +287,9 @@ export function CompareTable({ stats }: CompareTableProps) {
variant="ghost" variant="ghost"
radius="large" radius="large"
style={{ style={{
height: '100%', height: "100%",
width: 12, width: 12,
position: 'relative', position: "relative",
}} }}
> >
<Flex direction="column" justify="center"> <Flex direction="column" justify="center">
@@ -298,14 +297,14 @@ export function CompareTable({ stats }: CompareTableProps) {
<img <img
alt={unwrap( alt={unwrap(
provisionDefinitions.provisions[provision] provisionDefinitions.provisions[provision]
.name, .name
)} )}
key={provision} key={provision}
src={asset( src={asset(
`/icons/provisions/${provision}.webp`, `/icons/provisions/${provision}.webp`
)} )}
style={{ style={{
left: '50%', left: "50%",
/** /**
* max = 100% - 16px - 4px * max = 100% - 16px - 4px
* min = 4px * min = 4px
@@ -320,11 +319,11 @@ export function CompareTable({ stats }: CompareTableProps) {
? 0.5 ? 0.5
: index / (provisions.length - 1) : index / (provisions.length - 1)
} * (100% - 24px))`, } * (100% - 24px))`,
transform: 'translateX(-50%)', transform: "translateX(-50%)",
position: 'absolute', position: "absolute",
width: 16, width: 16,
height: 16, height: 16,
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
))} ))}
@@ -339,12 +338,12 @@ export function CompareTable({ stats }: CompareTableProps) {
<Popover.Content> <Popover.Content>
<ProvisionsManager <ProvisionsManager
provisions={provisionsList.map( provisions={provisionsList.map(
(provision) => provision.id, (provision) => provision.id
)} )}
selected={provisions} selected={provisions}
disabled={tank.max_provisions === provisions.length} disabled={tank.max_provisions === provisions.length}
onChange={(provisions) => { onChange={(provisions) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].provisions = provisions; draft.members[index].provisions = provisions;
}); });
}} }}
@@ -353,7 +352,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].provisions = []; draft.members[index].provisions = [];
}); });
}} }}
@@ -394,8 +393,8 @@ export function CompareTable({ stats }: CompareTableProps) {
height: 8, height: 8,
backgroundColor: backgroundColor:
equipment === -1 equipment === -1
? Var('crimson-11') ? Var("crimson-11")
: Var('gray-11'), : Var("gray-11"),
borderRadius: 2, borderRadius: 2,
}} }}
/> />
@@ -405,8 +404,8 @@ export function CompareTable({ stats }: CompareTableProps) {
height: 8, height: 8,
backgroundColor: backgroundColor:
equipment === 1 equipment === 1
? Var('crimson-11') ? Var("crimson-11")
: Var('gray-11'), : Var("gray-11"),
borderRadius: 2, borderRadius: 2,
}} }}
/> />
@@ -424,7 +423,7 @@ export function CompareTable({ stats }: CompareTableProps) {
matrix={equipmentMatrix} matrix={equipmentMatrix}
preset={equipmentPreset} preset={equipmentPreset}
onChange={(matrix) => { onChange={(matrix) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].equipmentMatrix = matrix; draft.members[index].equipmentMatrix = matrix;
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -436,10 +435,10 @@ export function CompareTable({ stats }: CompareTableProps) {
variant="ghost" variant="ghost"
color="red" color="red"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].equipmentMatrix = times( draft.members[index].equipmentMatrix = times(
3, 3,
() => times(3, () => 0), () => times(3, () => 0)
) as EquipmentMatrix; ) as EquipmentMatrix;
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -453,7 +452,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members.forEach((member) => { draft.members.forEach((member) => {
member.equipmentMatrix = member.equipmentMatrix =
draft.members[index].equipmentMatrix; draft.members[index].equipmentMatrix;
@@ -477,9 +476,9 @@ export function CompareTable({ stats }: CompareTableProps) {
variant="ghost" variant="ghost"
radius="large" radius="large"
style={{ style={{
height: '100%', height: "100%",
width: 12, width: 12,
position: 'relative', position: "relative",
}} }}
> >
<Flex direction="column" justify="center"> <Flex direction="column" justify="center">
@@ -488,14 +487,14 @@ export function CompareTable({ stats }: CompareTableProps) {
alt={unwrap( alt={unwrap(
consumableDefinitions.consumables[ consumableDefinitions.consumables[
consumable consumable
].name, ].name
)} )}
key={consumable} key={consumable}
src={asset( src={asset(
`/icons/consumables/${consumable}.webp`, `/icons/consumables/${consumable}.webp`
)} )}
style={{ style={{
left: '50%', left: "50%",
/** /**
* max = 100% - 16px - 4px * max = 100% - 16px - 4px
* min = 4px * min = 4px
@@ -510,11 +509,11 @@ export function CompareTable({ stats }: CompareTableProps) {
? 0.5 ? 0.5
: index / (consumables.length - 1) : index / (consumables.length - 1)
} * (100% - 24px))`, } * (100% - 24px))`,
transform: 'translateX(-50%)', transform: "translateX(-50%)",
position: 'absolute', position: "absolute",
width: 16, width: 16,
height: 16, height: 16,
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
))} ))}
@@ -531,7 +530,7 @@ export function CompareTable({ stats }: CompareTableProps) {
consumables={consumablesList} consumables={consumablesList}
selected={consumables} selected={consumables}
onConsumablesChange={(consumables) => { onConsumablesChange={(consumables) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].consumables = consumables; draft.members[index].consumables = consumables;
}); });
}} }}
@@ -544,7 +543,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].consumables = []; draft.members[index].consumables = [];
}); });
}} }}
@@ -562,18 +561,18 @@ export function CompareTable({ stats }: CompareTableProps) {
{!tank.fixed_camouflage && ( {!tank.fixed_camouflage && (
<IconButton <IconButton
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].camouflage = !camouflage; draft.members[index].camouflage = !camouflage;
}); });
}} }}
variant={camouflage ? 'solid' : 'outline'} variant={camouflage ? "solid" : "outline"}
> >
<img <img
src={asset('icons/camo.webp')} src={asset("icons/camo.webp")}
style={{ style={{
width: '1em', width: "1em",
height: '1em', height: "1em",
transform: 'scale(2) translate(15%, 15%)', transform: "scale(2) translate(15%, 15%)",
}} }}
/> />
</IconButton> </IconButton>
@@ -581,7 +580,7 @@ export function CompareTable({ stats }: CompareTableProps) {
</Flex> </Flex>
</Table.Cell> </Table.Cell>
); );
}, }
)} )}
</Table.Row> </Table.Row>
</Table.Body> </Table.Body>
@@ -1,33 +1,27 @@
import { PlusIcon, TrashIcon } from '@radix-ui/react-icons'; import { PlusIcon, TrashIcon } from "@radix-ui/react-icons";
import { Button, Dialog, Flex, SegmentedControl } from '@radix-ui/themes'; import { Button, Dialog, Flex, SegmentedControl } from "@radix-ui/themes";
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions'; import { awaitableProvisionDefinitions } from "../../core/awaitables/provisionDefinitions";
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions'; import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
import { tankToCompareMember } from '../../core/blitzkit/tankToCompareMember'; import { useLocale } from "../../hooks/useLocale";
import { useLocale } from '../../hooks/useLocale'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { CompareEphemeral } from '../../stores/compareEphemeral';
import { import {
ComparePersistent, ComparePersistent,
type DeltaMode, type DeltaMode,
} from '../../stores/comparePersistent'; } from "../../stores/comparePersistent";
import { TankSearch } from '../TankSearch'; import { TankSearch } from "../TankSearch";
interface ControlsProps { interface ControlsProps {
addTankDialogOpen: boolean; addTankDialogOpen: boolean;
onAddTankDialogOpenChange: (open: boolean) => void; onAddTankDialogOpenChange: (open: boolean) => void;
} }
const [provisionDefinitions, modelDefinitions] = await Promise.all([ const provisionDefinitions = await awaitableProvisionDefinitions;
awaitableProvisionDefinitions,
awaitableModelDefinitions,
]);
export function Controls({ export function Controls({
addTankDialogOpen, addTankDialogOpen,
onAddTankDialogOpenChange, onAddTankDialogOpenChange,
}: ControlsProps) { }: ControlsProps) {
const deltaMode = ComparePersistent.use((state) => state.deltaMode); const deltaMode = ComparePersistent.use((state) => state.deltaMode);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const mutateComparePersistent = ComparePersistent.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -60,28 +54,20 @@ export function Controls({
<TankSearch <TankSearch
compact compact
onSelect={(tank) => { onSelect={(tank) => {
const model = modelDefinitions.models[tank.id]; CompareEphemeral.mutate((draft) => {
mutateCompareEphemeral((draft) => {
draft.members.push( draft.members.push(
tankToCompareMember(tank, model, provisionDefinitions), tankToCompareMember(tank, provisionDefinitions)
); );
draft.sorting = undefined; draft.sorting = undefined;
}); });
onAddTankDialogOpenChange(false); onAddTankDialogOpenChange(false);
}} }}
onSelectAll={(tanks) => { onSelectAll={(tanks) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members.push( draft.members.push(
...tanks.map((tank) => { ...tanks.map((tank) => {
const model = modelDefinitions.models[tank.id]; return tankToCompareMember(tank, provisionDefinitions);
})
return tankToCompareMember(
tank,
model,
provisionDefinitions,
);
}),
); );
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -97,7 +83,7 @@ export function Controls({
variant="soft" variant="soft"
color="red" color="red"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members = []; draft.members = [];
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -110,18 +96,18 @@ export function Controls({
variant="classic" variant="classic"
value={deltaMode} value={deltaMode}
onValueChange={(value) => { onValueChange={(value) => {
mutateComparePersistent((draft) => { ComparePersistent.mutate((draft) => {
draft.deltaMode = value as DeltaMode; draft.deltaMode = value as DeltaMode;
}); });
}} }}
> >
<SegmentedControl.Item value={'none' satisfies DeltaMode}> <SegmentedControl.Item value={"none" satisfies DeltaMode}>
{strings.website.tools.compare.actions.deltas.none} {strings.website.tools.compare.actions.deltas.none}
</SegmentedControl.Item> </SegmentedControl.Item>
<SegmentedControl.Item value={'percentage' satisfies DeltaMode}> <SegmentedControl.Item value={"percentage" satisfies DeltaMode}>
{strings.website.tools.compare.actions.deltas.percentage} {strings.website.tools.compare.actions.deltas.percentage}
</SegmentedControl.Item> </SegmentedControl.Item>
<SegmentedControl.Item value={'absolute' satisfies DeltaMode}> <SegmentedControl.Item value={"absolute" satisfies DeltaMode}>
{strings.website.tools.compare.actions.deltas.absolute} {strings.website.tools.compare.actions.deltas.absolute}
</SegmentedControl.Item> </SegmentedControl.Item>
</SegmentedControl.Root> </SegmentedControl.Root>
@@ -1,13 +1,13 @@
import { type TankDefinition, tankIcon } from '@blitzkit/core'; import { type TankDefinition, tankIcon } from "@blitzkit/core";
import { Flex, Text } from '@radix-ui/themes'; import { Flex, Text } from "@radix-ui/themes";
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from "react";
import { Vector2 } from 'three'; import { Vector2 } from "three";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell'; import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
import { InsertionMarker } from './IntersectionMarker'; import { InsertionMarker } from "./IntersectionMarker";
import { insertionMarkers } from './IntersectionMarker/constants'; import { insertionMarkers } from "./IntersectionMarker/constants";
import { TankControl } from './TankControl'; import { TankControl } from "./TankControl";
interface TankCardProps { interface TankCardProps {
index: number; index: number;
@@ -16,7 +16,6 @@ interface TankCardProps {
export function TankCard({ index, tank }: TankCardProps) { export function TankCard({ index, tank }: TankCardProps) {
const draggable = useRef<HTMLDivElement>(null); const draggable = useRef<HTMLDivElement>(null);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const { unwrap } = useLocale(); const { unwrap } = useLocale();
useEffect(() => { useEffect(() => {
@@ -34,8 +33,8 @@ export function TankCard({ index, tank }: TankCardProps) {
delta.set(0, 0); delta.set(0, 0);
current.copy(initial.set(event.clientX, event.clientY)); current.copy(initial.set(event.clientX, event.clientY));
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
event.preventDefault(); event.preventDefault();
@@ -43,7 +42,7 @@ export function TankCard({ index, tank }: TankCardProps) {
current.set(event.clientX, event.clientY); current.set(event.clientX, event.clientY);
delta.copy(current).sub(initial); delta.copy(current).sub(initial);
insertionMarkers.forEach(({ element }) => { insertionMarkers.forEach(({ element }) => {
element.style.opacity = '0'; element.style.opacity = "0";
}); });
const distances = insertionMarkers.map((insertionMarker) => { const distances = insertionMarkers.map((insertionMarker) => {
@@ -56,34 +55,34 @@ export function TankCard({ index, tank }: TankCardProps) {
}; };
}); });
const closest = distances.reduce((a, b) => const closest = distances.reduce((a, b) =>
a.distance < b.distance ? a : b, a.distance < b.distance ? a : b
); );
dropIndex = closest.index; dropIndex = closest.index;
closest.element.style.opacity = '1'; closest.element.style.opacity = "1";
draggable.current!.style.position = 'fixed'; draggable.current!.style.position = "fixed";
draggable.current!.style.cursor = 'grabbing'; draggable.current!.style.cursor = "grabbing";
draggable.current!.style.zIndex = '1'; draggable.current!.style.zIndex = "1";
draggable.current!.style.left = `${initialRect.left + delta.x}px`; draggable.current!.style.left = `${initialRect.left + delta.x}px`;
draggable.current!.style.top = `${initialRect.top + delta.y}px`; draggable.current!.style.top = `${initialRect.top + delta.y}px`;
} }
function handlePointerUp(event: PointerEvent) { function handlePointerUp(event: PointerEvent) {
event.preventDefault(); event.preventDefault();
draggable.current!.style.position = 'static'; draggable.current!.style.position = "static";
draggable.current!.style.zIndex = 'unset'; draggable.current!.style.zIndex = "unset";
draggable.current!.style.cursor = 'grab'; draggable.current!.style.cursor = "grab";
insertionMarkers.forEach(({ element }) => { insertionMarkers.forEach(({ element }) => {
element.style.opacity = '0'; element.style.opacity = "0";
}); });
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
if (dropIndex === -1) return; if (dropIndex === -1) return;
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
const fromIndex = index; const fromIndex = index;
const toIndex = dropIndex > index ? dropIndex - 1 : dropIndex; const toIndex = dropIndex > index ? dropIndex - 1 : dropIndex;
@@ -95,10 +94,10 @@ export function TankCard({ index, tank }: TankCardProps) {
dropIndex = -1; dropIndex = -1;
} }
draggable.current?.addEventListener('pointerdown', handlePointerDown); draggable.current?.addEventListener("pointerdown", handlePointerDown);
return () => { return () => {
draggable.current?.removeEventListener('pointerdown', handlePointerDown); draggable.current?.removeEventListener("pointerdown", handlePointerDown);
}; };
}, []); }, []);
@@ -114,9 +113,9 @@ export function TankCard({ index, tank }: TankCardProps) {
justify="between" justify="between"
gap="2" gap="2"
style={{ style={{
userSelect: 'none', userSelect: "none",
touchAction: 'none', touchAction: "none",
cursor: 'grab', cursor: "grab",
}} }}
> >
<img <img
@@ -126,16 +125,16 @@ export function TankCard({ index, tank }: TankCardProps) {
height={64} height={64}
draggable={false} draggable={false}
style={{ style={{
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
<Text <Text
style={{ style={{
whiteSpace: 'nowrap', whiteSpace: "nowrap",
maxWidth: 128, maxWidth: 128,
overflow: 'hidden', overflow: "hidden",
textOverflow: 'ellipsis', textOverflow: "ellipsis",
}} }}
> >
{unwrap(tank.name)} {unwrap(tank.name)}
@@ -1,35 +1,30 @@
import { ExternalLinkIcon, LoopIcon, TrashIcon } from '@radix-ui/react-icons'; import { ExternalLinkIcon, LoopIcon, TrashIcon } from "@radix-ui/react-icons";
import { Dialog, Flex, IconButton } from '@radix-ui/themes'; import { Dialog, Flex, IconButton } from "@radix-ui/themes";
import { useState } from 'react'; import { useState } from "react";
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions'; import { awaitableProvisionDefinitions } from "../../core/awaitables/provisionDefinitions";
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions'; import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
import { tankToCompareMember } from '../../core/blitzkit/tankToCompareMember'; import { useLocale } from "../../hooks/useLocale";
import { useLocale } from '../../hooks/useLocale'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { LinkI18n } from "../LinkI18n";
import { LinkI18n } from '../LinkI18n'; import { TankSearch } from "../TankSearch";
import { TankSearch } from '../TankSearch';
interface TankControlProps { interface TankControlProps {
index: number; index: number;
slug: string; slug: string;
} }
const [provisionDefinitions, modelDefinitions] = await Promise.all([ const provisionDefinitions = await awaitableProvisionDefinitions;
awaitableProvisionDefinitions,
awaitableModelDefinitions,
]);
export function TankControl({ index, slug }: TankControlProps) { export function TankControl({ index, slug }: TankControlProps) {
const { locale } = useLocale(); const { locale, strings } = useLocale();
const [switchTankDialogOpen, setSwitchTankDialogOpen] = useState(false); const [switchTankDialogOpen, setSwitchTankDialogOpen] = useState(false);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
return ( return (
<Flex gap="3" justify="center" style={{ width: '100%' }}> <Flex gap="3" justify="center" style={{ width: "100%" }}>
<IconButton <IconButton
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members.splice(index, 1); draft.members.splice(index, 1);
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -49,7 +44,9 @@ export function TankControl({ index, slug }: TankControlProps) {
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content> <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 gap="4" direction="column">
<Flex <Flex
@@ -61,13 +58,10 @@ export function TankControl({ index, slug }: TankControlProps) {
<TankSearch <TankSearch
compact compact
onSelect={(tank) => { onSelect={(tank) => {
const model = modelDefinitions.models[tank.id]; CompareEphemeral.mutate((draft) => {
mutateCompareEphemeral((draft) => {
draft.members[index] = tankToCompareMember( draft.members[index] = tankToCompareMember(
tank, tank,
model, provisionDefinitions
provisionDefinitions,
); );
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -1,16 +1,14 @@
import { Switch } from '@radix-ui/themes'; import { Switch } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState } from '../../stores/embedState'; import { EmbedState } from "../../stores/embedState";
export function Boolean({ configKey }: EmbedPreviewControllerProps) { export function Boolean({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return ( return (
<Switch <Switch
variant="classic" variant="classic"
checked={EmbedState.use((state) => state[configKey] as boolean)} checked={EmbedState.use((state) => state[configKey] as boolean)}
onCheckedChange={() => { onCheckedChange={() => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = !draft[configKey]; draft[configKey] = !draft[configKey];
}); });
}} }}
@@ -1,16 +1,15 @@
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState } from '../../stores/embedState'; import { EmbedState } from "../../stores/embedState";
import { ColorControllerRaw } from './ColorControllerRaw'; import { ColorControllerRaw } from "./ColorControllerRaw";
export function Color({ configKey }: EmbedPreviewControllerProps) { export function Color({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
const color = EmbedState.use((state) => state[configKey] as string); const color = EmbedState.use((state) => state[configKey] as string);
return ( return (
<ColorControllerRaw <ColorControllerRaw
value={color} value={color}
onValueChange={(value) => { onValueChange={(value) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = value; draft[configKey] = value;
}); });
}} }}
@@ -1,11 +1,11 @@
import { Select } from '@radix-ui/themes'; import { Select } from "@radix-ui/themes";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState } from '../../stores/embedState'; import { EmbedState } from "../../stores/embedState";
import type { import type {
EmbedConfigItemType, EmbedConfigItemType,
EmbedItemType, EmbedItemType,
} from '../../stores/embedState/constants'; } from "../../stores/embedState/constants";
export function Enum({ export function Enum({
configKey, configKey,
@@ -13,14 +13,13 @@ export function Enum({
}: EmbedPreviewControllerProps & { }: EmbedPreviewControllerProps & {
config: EmbedConfigItemType<EmbedItemType.Enum>; config: EmbedConfigItemType<EmbedItemType.Enum>;
}) { }) {
const mutateEmbedState = EmbedState.useMutation();
const { locale } = useLocale(); const { locale } = useLocale();
return ( return (
<Select.Root <Select.Root
value={EmbedState.use((state) => state[configKey] as string)} value={EmbedState.use((state) => state[configKey] as string)}
onValueChange={(value) => { onValueChange={(value) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = value; draft[configKey] = value;
}); });
}} }}
@@ -2,9 +2,9 @@ import {
searchPlayersAcrossRegions, searchPlayersAcrossRegions,
type AccountListItem, type AccountListItem,
type AccountListWithServer, type AccountListWithServer,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { CaretLeftIcon, Link2Icon, PersonIcon } from '@radix-ui/react-icons'; import { CaretLeftIcon, Link2Icon, PersonIcon } from "@radix-ui/react-icons";
import { import {
Button, Button,
Dialog, Dialog,
@@ -13,15 +13,15 @@ import {
Spinner, Spinner,
Text, Text,
TextField, TextField,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { debounce } from 'lodash-es'; import { debounce } from "lodash-es";
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from "react";
import { stringify } from 'urlon'; import { stringify } from "urlon";
import type { embedConfigurations } from '../../constants/embeds'; import type { embedConfigurations } from "../../constants/embeds";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { EmbedState, type EmbedStateStore } from '../../stores/embedState'; import { EmbedState, type EmbedStateStore } from "../../stores/embedState";
import { CopyButton } from '../CopyButton'; import { CopyButton } from "../CopyButton";
import { SearchResults } from '../SearchResults'; import { SearchResults } from "../SearchResults";
interface GenerateURLProps { interface GenerateURLProps {
embed: keyof typeof embedConfigurations; embed: keyof typeof embedConfigurations;
@@ -31,7 +31,6 @@ export function GenerateURL({ embed }: GenerateURLProps) {
const [searchDialogOpen, setSearchDialogOpen] = useState(false); const [searchDialogOpen, setSearchDialogOpen] = useState(false);
const [copyDialogOpen, setCopyDialogOpen] = useState(false); const [copyDialogOpen, setCopyDialogOpen] = useState(false);
const [copyUser, setCopyUser] = useState<AccountListItem | null>(null); const [copyUser, setCopyUser] = useState<AccountListItem | null>(null);
const embedStateStore = EmbedState.useStore();
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [results, setResults] = useState<AccountListWithServer>([]); const [results, setResults] = useState<AccountListWithServer>([]);
@@ -52,7 +51,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
setSearching(false); setSearching(false);
setResults(results); setResults(results);
}, 500), }, 500),
[], []
); );
const handleChange = useCallback(() => { const handleChange = useCallback(() => {
if (!input.current) return; if (!input.current) return;
@@ -94,7 +93,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
<TextField.Root <TextField.Root
variant="classic" variant="classic"
mt="4" mt="4"
style={{ width: '20rem' }} style={{ width: "20rem" }}
placeholder={strings.website.common.player_search.hint} placeholder={strings.website.common.player_search.hint}
ref={input} ref={input}
onChange={handleChange} onChange={handleChange}
@@ -136,7 +135,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
</Flex> </Flex>
<Dialog.Close> <Dialog.Close>
<Button color="red" variant="outline" style={{ width: '100%' }}> <Button color="red" variant="outline" style={{ width: "100%" }}>
{strings.website.tools.embed.configuration.export.generate.cancel} {strings.website.tools.embed.configuration.export.generate.cancel}
</Button> </Button>
</Dialog.Close> </Dialog.Close>
@@ -144,7 +143,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
</Dialog.Root> </Dialog.Root>
<Dialog.Root open={copyDialogOpen} onOpenChange={setCopyDialogOpen}> <Dialog.Root open={copyDialogOpen} onOpenChange={setCopyDialogOpen}>
<Dialog.Content style={{ width: 'fit-content' }}> <Dialog.Content style={{ width: "fit-content" }}>
<Flex direction="column" gap="1"> <Flex direction="column" gap="1">
<Heading size="5"> <Heading size="5">
{ {
@@ -156,7 +155,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
{literals( {literals(
strings.website.tools.embed.configuration.export.generate strings.website.tools.embed.configuration.export.generate
.success.info, .success.info,
[`${copyUser?.nickname}`], [`${copyUser?.nickname}`]
)} )}
</Text> </Text>
</Flex> </Flex>
@@ -170,7 +169,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
setSearchDialogOpen(true); setSearchDialogOpen(true);
}} }}
> >
<CaretLeftIcon />{' '} <CaretLeftIcon />{" "}
{ {
strings.website.tools.embed.configuration.export.generate strings.website.tools.embed.configuration.export.generate
.success.back .success.back
@@ -181,12 +180,10 @@ export function GenerateURL({ embed }: GenerateURLProps) {
copy={() => { copy={() => {
if (!copyUser) return; if (!copyUser) return;
const state = embedStateStore.getState();
const initial = embedStateStore.getInitialState();
const shallowState: EmbedStateStore = {}; const shallowState: EmbedStateStore = {};
Object.entries(state).forEach(([key, value]) => { Object.entries(EmbedState.state).forEach(([key, value]) => {
if (value !== initial[key]) { if (value !== EmbedState.initial[key]) {
shallowState[key] = value; shallowState[key] = value;
} }
}); });
@@ -202,7 +199,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
return `${location.origin}/embed/${embed}/host?${searchParams.toString()}`; return `${location.origin}/embed/${embed}/host?${searchParams.toString()}`;
}} }}
> >
<Link2Icon />{' '} <Link2Icon />{" "}
{ {
strings.website.tools.embed.configuration.export.generate strings.website.tools.embed.configuration.export.generate
.success.copy .success.copy
@@ -6,17 +6,16 @@ import {
Text, Text,
TextField, TextField,
Tooltip, Tooltip,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { useRef, useState } from 'react'; import { useRef, useState } from "react";
import { parse } from 'urlon'; import { parse } from "urlon";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { EmbedState } from '../../stores/embedState'; import { EmbedState } from "../../stores/embedState";
export function Import() { export function Import() {
const importInput = useRef<HTMLInputElement>(null); const importInput = useRef<HTMLInputElement>(null);
const [showInvalidURLPrompt, setShowInvalidURLPrompt] = useState(false); const [showInvalidURLPrompt, setShowInvalidURLPrompt] = useState(false);
const [showConfirmation, setConfirmation] = useState(false); const [showConfirmation, setConfirmation] = useState(false);
const mutateEmbedState = EmbedState.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -64,15 +63,15 @@ export function Import() {
try { try {
const url = new URL(importInput.current.value); const url = new URL(importInput.current.value);
const state = url.searchParams.get('state'); const state = url.searchParams.get("state");
if (state === null) throw undefined; if (state === null) throw undefined;
const json = parse(state); const json = parse(state);
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
Object.assign(draft, json); Object.assign(draft, json);
}); });
importInput.current.value = ''; importInput.current.value = "";
setConfirmation(true); setConfirmation(true);
setTimeout(() => setConfirmation(false), 2000); setTimeout(() => setConfirmation(false), 2000);
} catch { } catch {
@@ -1,10 +1,8 @@
import { Slider } from '@radix-ui/themes'; import { Slider } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState, type RadixRadius } from '../../stores/embedState'; import { EmbedState, type RadixRadius } from "../../stores/embedState";
export function Radius({ configKey }: EmbedPreviewControllerProps) { export function Radius({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return ( return (
<Slider <Slider
variant="classic" variant="classic"
@@ -13,12 +11,12 @@ export function Radius({ configKey }: EmbedPreviewControllerProps) {
value={[ value={[
EmbedState.use((state) => { EmbedState.use((state) => {
const radius = state[configKey] as RadixRadius; const radius = state[configKey] as RadixRadius;
return radius === 'full' ? 5 : parseInt(radius); return radius === "full" ? 5 : parseInt(radius);
}), }),
]} ]}
onValueChange={([value]) => { onValueChange={([value]) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = value === 5 ? 'full' : `${value}`; draft[configKey] = value === 5 ? "full" : `${value}`;
}); });
}} }}
/> />
@@ -1,21 +1,18 @@
import { FontBoldIcon, FontSizeIcon } from '@radix-ui/react-icons'; import { FontBoldIcon, FontSizeIcon } from "@radix-ui/react-icons";
import { Flex, Slider, Text } from '@radix-ui/themes'; import { Flex, Slider, Text } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState, type RadixSizeWithout0 } from '../../stores/embedState'; import { EmbedState, type RadixSizeWithout0 } from "../../stores/embedState";
import { import {
radixTextWeights, radixTextWeights,
type EmbedConfigItemType, type EmbedConfigItemType,
type EmbedItemType, type EmbedItemType,
} from '../../stores/embedState/constants'; } from "../../stores/embedState/constants";
import { ColorControllerRaw } from './ColorControllerRaw'; import { ColorControllerRaw } from "./ColorControllerRaw";
export function RichText({ configKey }: EmbedPreviewControllerProps) { export function RichText({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
const state = EmbedState.use( const state = EmbedState.use(
(state) => (state) =>
state[ state[configKey] as EmbedConfigItemType<EmbedItemType.RichText>["default"]
configKey
] as EmbedConfigItemType<EmbedItemType.RichText>['default'],
); );
return ( return (
@@ -24,11 +21,11 @@ export function RichText({ configKey }: EmbedPreviewControllerProps) {
<ColorControllerRaw <ColorControllerRaw
value={state.color} value={state.color}
onValueChange={(value) => { onValueChange={(value) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
( (
draft[ draft[
configKey configKey
] as EmbedConfigItemType<EmbedItemType.RichText>['default'] ] as EmbedConfigItemType<EmbedItemType.RichText>["default"]
).color = value; ).color = value;
}); });
}} }}
@@ -45,11 +42,11 @@ export function RichText({ configKey }: EmbedPreviewControllerProps) {
max={9} max={9}
value={[Number(state.size)]} value={[Number(state.size)]}
onValueChange={([value]) => { onValueChange={([value]) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
( (
draft[ draft[
configKey configKey
] as EmbedConfigItemType<EmbedItemType.RichText>['default'] ] as EmbedConfigItemType<EmbedItemType.RichText>["default"]
).size = `${value}` as RadixSizeWithout0; ).size = `${value}` as RadixSizeWithout0;
}); });
}} }}
@@ -66,11 +63,11 @@ export function RichText({ configKey }: EmbedPreviewControllerProps) {
max={radixTextWeights.length - 1} max={radixTextWeights.length - 1}
value={[radixTextWeights.indexOf(state.weight)]} value={[radixTextWeights.indexOf(state.weight)]}
onValueChange={([value]) => { onValueChange={([value]) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
( (
draft[ draft[
configKey configKey
] as EmbedConfigItemType<EmbedItemType.RichText>['default'] ] as EmbedConfigItemType<EmbedItemType.RichText>["default"]
).weight = radixTextWeights[value]; ).weight = radixTextWeights[value];
}); });
}} }}
@@ -1,10 +1,8 @@
import { Slider } from '@radix-ui/themes'; import { Slider } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState, type RadixSize } from '../../stores/embedState'; import { EmbedState, type RadixSize } from "../../stores/embedState";
export function Size({ configKey }: EmbedPreviewControllerProps) { export function Size({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return ( return (
<Slider <Slider
variant="classic" variant="classic"
@@ -14,7 +12,7 @@ export function Size({ configKey }: EmbedPreviewControllerProps) {
EmbedState.use((state) => parseInt(state[configKey] as RadixSize)), EmbedState.use((state) => parseInt(state[configKey] as RadixSize)),
]} ]}
onValueChange={([value]) => { onValueChange={([value]) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = `${value}`; draft[configKey] = `${value}`;
}); });
}} }}
@@ -1,10 +1,8 @@
import { Slider } from '@radix-ui/themes'; import { Slider } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState, type RadixSizeWithout0 } from '../../stores/embedState'; import { EmbedState, type RadixSizeWithout0 } from "../../stores/embedState";
export function SizeWithout0({ configKey }: EmbedPreviewControllerProps) { export function SizeWithout0({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return ( return (
<Slider <Slider
variant="classic" variant="classic"
@@ -12,11 +10,11 @@ export function SizeWithout0({ configKey }: EmbedPreviewControllerProps) {
max={9} max={9}
value={[ value={[
EmbedState.use((state) => EmbedState.use((state) =>
parseInt(state[configKey] as RadixSizeWithout0), parseInt(state[configKey] as RadixSizeWithout0)
), ),
]} ]}
onValueChange={([value]) => { onValueChange={([value]) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = `${value}`; draft[configKey] = `${value}`;
}); });
}} }}
@@ -1,10 +1,10 @@
import { Slider as RadixSlider } from '@radix-ui/themes'; import { Slider as RadixSlider } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState, type RadixSize } from '../../stores/embedState'; import { EmbedState, type RadixSize } from "../../stores/embedState";
import type { import type {
EmbedConfigItemType, EmbedConfigItemType,
EmbedItemType, EmbedItemType,
} from '../../stores/embedState/constants'; } from "../../stores/embedState/constants";
export function Slider({ export function Slider({
configKey, configKey,
@@ -12,8 +12,6 @@ export function Slider({
}: EmbedPreviewControllerProps & { }: EmbedPreviewControllerProps & {
config: EmbedConfigItemType<EmbedItemType.Slider>; config: EmbedConfigItemType<EmbedItemType.Slider>;
}) { }) {
const mutateEmbedState = EmbedState.useMutation();
return ( return (
<RadixSlider <RadixSlider
variant="classic" variant="classic"
@@ -23,7 +21,7 @@ export function Slider({
EmbedState.use((state) => parseInt(state[configKey] as RadixSize)), EmbedState.use((state) => parseInt(state[configKey] as RadixSize)),
]} ]}
onValueChange={([value]) => { onValueChange={([value]) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = value; draft[configKey] = value;
}); });
}} }}
@@ -1,17 +1,15 @@
import { TextField } from '@radix-ui/themes'; import { TextField } from "@radix-ui/themes";
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/embed/[embed]/_index'; import type { EmbedPreviewControllerProps } from "../../pages/[...locale]/embed/[embed]/_index";
import { EmbedState } from '../../stores/embedState'; import { EmbedState } from "../../stores/embedState";
export function TextController({ configKey }: EmbedPreviewControllerProps) { export function TextController({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return ( return (
<TextField.Root <TextField.Root
variant="classic" variant="classic"
placeholder="Empty" placeholder="Empty"
value={EmbedState.use((state) => state[configKey] as string)} value={EmbedState.use((state) => state[configKey] as string)}
onChange={(event) => { onChange={(event) => {
mutateEmbedState((draft) => { EmbedState.mutate((draft) => {
draft[configKey] = event.target.value; draft[configKey] = event.target.value;
}); });
}} }}
@@ -7,17 +7,17 @@ import {
idToRegion, idToRegion,
sumCompositeStats, sumCompositeStats,
type IndividualTankStats, type IndividualTankStats,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { STRINGS } from '@blitzkit/i18n'; import { STRINGS } from "@blitzkit/i18n";
import { ReloadIcon } from '@radix-ui/react-icons'; import { ReloadIcon } from "@radix-ui/react-icons";
import { ContextMenu } from '@radix-ui/themes'; import { ContextMenu } from "@radix-ui/themes";
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from "react";
import { breakdownConfig } from '../../constants/embeds'; import { breakdownConfig } from "../../constants/embeds";
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions'; import { awaitableAverageDefinitions } from "../../core/awaitables/averageDefinitions";
import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../../core/awaitables/tankDefinitions";
import { EmbedBreakdownPersistent } from '../../stores/embedBreakdownPersistent'; import { EmbedBreakdown } from "../../stores/embedBreakdown";
import { useEmbedStateCurry } from '../../stores/embedState/utilities'; import { useEmbedStateCurry } from "../../stores/embedState/utilities";
import { BreakdownEmbedCard, BreakdownEmbedWrapper } from '../TanksEmbed'; import { BreakdownEmbedCard, BreakdownEmbedWrapper } from "../TanksEmbed";
export const compositeStatsKeysOptions = compositeStatsKeys.map((value) => ({ export const compositeStatsKeysOptions = compositeStatsKeys.map((value) => ({
value, value,
@@ -55,7 +55,7 @@ export const fakeCompositeStats = compositeStats(
wins: 30, wins: 30,
xp: 1230 * 50, xp: 1230 * 50,
}, },
averageDefinitions.averages[7297].mu, averageDefinitions.averages[7297].mu
); );
export function BreakdownPreview() { export function BreakdownPreview() {
@@ -64,11 +64,11 @@ export function BreakdownPreview() {
return ( return (
<BreakdownEmbedWrapper> <BreakdownEmbedWrapper>
{useEmbedState('showTotal') && ( {useEmbedState("showTotal") && (
<BreakdownEmbedCard composite={fakeCompositeStats} tank={null} /> <BreakdownEmbedCard composite={fakeCompositeStats} tank={null} />
)} )}
{tanks.slice(0, useEmbedState('listMaxTanks')).map((tank) => ( {tanks.slice(0, useEmbedState("listMaxTanks")).map((tank) => (
<BreakdownEmbedCard <BreakdownEmbedCard
composite={fakeCompositeStats} composite={fakeCompositeStats}
key={tank.id} key={tank.id}
@@ -80,26 +80,17 @@ export function BreakdownPreview() {
} }
export function BreakdownRenderer() { export function BreakdownRenderer() {
return (
<EmbedBreakdownPersistent.Provider>
<BreakdownRendererContent />
</EmbedBreakdownPersistent.Provider>
);
}
function BreakdownRendererContent() {
const { useEmbedState } = useEmbedStateCurry<typeof breakdownConfig>(); const { useEmbedState } = useEmbedStateCurry<typeof breakdownConfig>();
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const mutateEmbedBreakdownPersistent = EmbedBreakdownPersistent.useMutation(); const id = Number(params.get("id"));
const id = Number(params.get('id')); const tankStatsA = EmbedBreakdown.use((state) => state[id] ?? []);
const tankStatsA = EmbedBreakdownPersistent.use((state) => state[id] ?? []);
const [tankStatsB, setTankStatsB] = useState<IndividualTankStats[]>([]); const [tankStatsB, setTankStatsB] = useState<IndividualTankStats[]>([]);
const diff = useMemo( const diff = useMemo(
() => () =>
deltaTankStats(tankStatsA, tankStatsB).sort( deltaTankStats(tankStatsA, tankStatsB).sort(
(a, b) => b.last_battle_time - a.last_battle_time, (a, b) => b.last_battle_time - a.last_battle_time
), ),
[tankStatsB], [tankStatsB]
); );
const compositeStats = diff const compositeStats = diff
.map((diff) => { .map((diff) => {
@@ -109,14 +100,14 @@ function BreakdownRendererContent() {
const compositeStats = calculateCompositeStats( const compositeStats = calculateCompositeStats(
{ battle_life_time: diff.battle_life_time, ...diff.all }, { battle_life_time: diff.battle_life_time, ...diff.all },
average.mu, average.mu
); );
return { id: diff.tank_id, compositeStats }; return { id: diff.tank_id, compositeStats };
}) })
.filter((stats) => stats !== null); .filter((stats) => stats !== null);
const sum = sumCompositeStats( const sum = sumCompositeStats(
compositeStats.map(({ compositeStats }) => compositeStats), compositeStats.map(({ compositeStats }) => compositeStats)
); );
useEffect(() => { useEffect(() => {
@@ -135,12 +126,12 @@ function BreakdownRendererContent() {
<ContextMenu.Root> <ContextMenu.Root>
<ContextMenu.Trigger> <ContextMenu.Trigger>
<BreakdownEmbedWrapper> <BreakdownEmbedWrapper>
{useEmbedState('showTotal') && ( {useEmbedState("showTotal") && (
<BreakdownEmbedCard composite={sum} tank={null} /> <BreakdownEmbedCard composite={sum} tank={null} />
)} )}
{compositeStats {compositeStats
.slice(0, useEmbedState('listMaxTanks')) .slice(0, useEmbedState("listMaxTanks"))
.map(({ id, compositeStats }) => { .map(({ id, compositeStats }) => {
const tank = tankDefinitions.tanks[id]; const tank = tankDefinitions.tanks[id];
@@ -163,7 +154,7 @@ function BreakdownRendererContent() {
if (newA === null) return; if (newA === null) return;
mutateEmbedBreakdownPersistent((draft) => { EmbedBreakdown.mutate((draft) => {
draft[id] = newA; draft[id] = newA;
}); });
}} }}
@@ -1,15 +1,15 @@
import locales from '@blitzkit/i18n/locales.json'; import locales from "@blitzkit/i18n/locales.json";
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { Flex, Text } from '@radix-ui/themes'; import { Flex, Text } from "@radix-ui/themes";
import fuzzysort from 'fuzzysort'; import fuzzysort from "fuzzysort";
import { times } from 'lodash-es'; import { times } from "lodash-es";
import { useMemo, useState } from 'react'; import { useMemo, useState } from "react";
import { awaitableGallery } from '../../core/awaitables/gallery'; import { awaitableGallery } from "../../core/awaitables/gallery";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { GalleryEphemeral } from '../../stores/galleryEphemeral'; import { Gallery } from "../../stores/gallery";
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
import { InlineSkeleton } from '../InlineSkeleton'; import { InlineSkeleton } from "../InlineSkeleton";
import { GalleryCard } from './Card'; import { GalleryCard } from "./Card";
export interface Avatar { export interface Avatar {
name: string; name: string;
@@ -22,7 +22,7 @@ const DEFAULT_LOADED = 42;
const PREVIEW_COUNT = 28; const PREVIEW_COUNT = 28;
export function GalleryList({ skeleton }: MaybeSkeletonComponentProps) { export function GalleryList({ skeleton }: MaybeSkeletonComponentProps) {
const search = GalleryEphemeral.use((state) => state.search); const search = Gallery.use((state) => state.search);
const [loadedCards, setLoadedCards] = useState(DEFAULT_LOADED); const [loadedCards, setLoadedCards] = useState(DEFAULT_LOADED);
const { locale, strings } = useLocale(); const { locale, strings } = useLocale();
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -35,9 +35,9 @@ export function GalleryList({ skeleton }: MaybeSkeletonComponentProps) {
.go(search, gallery.avatars, { .go(search, gallery.avatars, {
keys: [ keys: [
...locales.supported.map( ...locales.supported.map(
(supported) => `name.locales.${supported.locale}`, (supported) => `name.locales.${supported.locale}`
), ),
'id', "id",
], ],
}) })
.map((result) => result.obj); .map((result) => result.obj);
@@ -72,7 +72,7 @@ export function GalleryList({ skeleton }: MaybeSkeletonComponentProps) {
skeleton skeleton
onIntersection={() => setLoadedCards((state) => state + 2)} onIntersection={() => setLoadedCards((state) => state + 2)}
/> />
), )
)} )}
</Flex> </Flex>
</> </>
@@ -1,13 +1,12 @@
import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { TextField } from '@radix-ui/themes'; import { TextField } from "@radix-ui/themes";
import { useRef } from 'react'; import { useRef } from "react";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { GalleryEphemeral } from '../../stores/galleryEphemeral'; import { Gallery } from "../../stores/gallery";
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
export function GallerySearch({ skeleton }: MaybeSkeletonComponentProps) { export function GallerySearch({ skeleton }: MaybeSkeletonComponentProps) {
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const mutateGalleryEphemeral = GalleryEphemeral.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -17,7 +16,7 @@ export function GallerySearch({ skeleton }: MaybeSkeletonComponentProps) {
placeholder={strings.website.tools.gallery.search.hint} placeholder={strings.website.tools.gallery.search.hint}
ref={input} ref={input}
onChange={(event) => { onChange={(event) => {
mutateGalleryEphemeral((draft) => { Gallery.mutate((draft) => {
const trimmed = event.target.value.trim(); const trimmed = event.target.value.trim();
draft.search = trimmed.length === 0 ? undefined : trimmed; draft.search = trimmed.length === 0 ? undefined : trimmed;
}); });
@@ -1,13 +1,13 @@
import { Box } from '@radix-ui/themes'; import { Box } from "@radix-ui/themes";
import { useEffect } from 'react'; import { useEffect } from "react";
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions'; import { awaitableModelDefinitions } from "../../core/awaitables/modelDefinitions";
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions'; import { awaitableProvisionDefinitions } from "../../core/awaitables/provisionDefinitions";
import { tankToDuelMember } from '../../core/blitzkit/tankToDuelMember'; import { tankToDuelMember } from "../../core/blitzkit/tankToDuelMember";
import { Var } from '../../core/radix/var'; import { Var } from "../../core/radix/var";
import { Duel } from '../../stores/duel'; import { Duel } from "../../stores/duel";
import { GuessEphemeral } from '../../stores/guessEphemeral'; import { Guess, GuessState } from "../../stores/guess";
import { TankopediaEphemeral } from '../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../stores/tankopedia";
import './index.css'; import "./index.css";
const [modelDefinitions, provisionDefinitions] = await Promise.all([ const [modelDefinitions, provisionDefinitions] = await Promise.all([
awaitableModelDefinitions, awaitableModelDefinitions,
@@ -15,17 +15,14 @@ const [modelDefinitions, provisionDefinitions] = await Promise.all([
]); ]);
export function GuessBackground() { export function GuessBackground() {
const guessState = GuessEphemeral.use((state) => state.guessState); const guessState = Guess.use((state) => state.guessState);
const tank = GuessEphemeral.use((state) => state.tank); const tank = Guess.use((state) => state.tank);
const mutateDuel = Duel.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
useEffect(() => { useEffect(() => {
mutateDuel((state) => { Duel.mutate((state) => {
const model = modelDefinitions.models[tank.id]; state.protagonist = tankToDuelMember(tank, provisionDefinitions);
state.protagonist = tankToDuelMember(tank, model, provisionDefinitions);
}); });
mutateTankopediaEphemeral((state) => { Tankopedia.mutate((state) => {
state.model = modelDefinitions.models[tank.id]; state.model = modelDefinitions.models[tank.id];
}); });
}, [tank]); }, [tank]);
@@ -39,13 +36,13 @@ export function GuessBackground() {
top="0" top="0"
left="0" left="0"
style={{ style={{
transitionDuration: '2s', transitionDuration: "2s",
backgroundColor: Var( backgroundColor: Var(
guessState === null guessState === GuessState.NotGuessed
? 'accent-2' ? "accent-2"
: guessState : guessState === GuessState.Correct
? 'jade-3' ? "jade-3"
: 'tomato-3', : "tomato-3"
), ),
}} }}
> >
@@ -53,7 +50,7 @@ export function GuessBackground() {
width="100%" width="100%"
height="100%" height="100%"
style={{ style={{
background: `radial-gradient(circle, ${Var('color-panel-translucent')}, ${Var('color-panel-solid')})`, background: `radial-gradient(circle, ${Var("color-panel-translucent")}, ${Var("color-panel-solid")})`,
}} }}
/> />
</Box> </Box>
@@ -67,13 +64,13 @@ export function GuessBackground() {
left="0" left="0"
style={{ style={{
backgroundImage: ` backgroundImage: `
radial-gradient(circle, ${Var('white-a1')} 20%, transparent 20%), radial-gradient(circle, ${Var("white-a1")} 20%, transparent 20%),
radial-gradient(circle at 0 0, ${Var('white-a1')} 10%, transparent 10%), radial-gradient(circle at 0 0, ${Var("white-a1")} 10%, transparent 10%),
radial-gradient(circle at 100% 0, ${Var('white-a1')} 10%, transparent 10%), radial-gradient(circle at 100% 0, ${Var("white-a1")} 10%, transparent 10%),
radial-gradient(circle at 0 100%, ${Var('white-a1')} 10%, transparent 10%), radial-gradient(circle at 0 100%, ${Var("white-a1")} 10%, transparent 10%),
radial-gradient(circle at 100% 100%, ${Var('white-a1')} 10%, transparent 10%) radial-gradient(circle at 100% 100%, ${Var("white-a1")} 10%, transparent 10%)
`, `,
backgroundSize: '4rem 4rem', backgroundSize: "4rem 4rem",
}} }}
/> />
</> </>
@@ -1,29 +1,27 @@
import { GuessEphemeral } from '../stores/guessEphemeral'; import { Guess, GuessState } from "../stores/guess";
import { TankopediaDisplay } from '../stores/tankopediaPersistent/constants'; import { SmartCanvas } from "./SmartCanvas";
import { SmartCanvas } from './SmartCanvas'; import { Controls } from "./Tankopedia/HeroSection/components/TankSandbox/components/Control";
import { Controls } from './Tankopedia/HeroSection/components/TankSandbox/components/Control'; import { Lighting } from "./Tankopedia/HeroSection/components/TankSandbox/components/Lighting";
import { Lighting } from './Tankopedia/HeroSection/components/TankSandbox/components/Lighting'; import { TankModel } from "./Tankopedia/HeroSection/components/TankSandbox/components/TankModel";
import { TankModel } from './Tankopedia/HeroSection/components/TankSandbox/components/TankModel';
const BRIGHTNESS = 2 ** -5; const BRIGHTNESS = 2 ** -5.5;
const CONTRAST = 2 ** 2; const CONTRAST = 2 ** 2;
export function GuessRenderer() { export function GuessRenderer() {
const guessState = GuessEphemeral.use((state) => state.guessState); const guessState = Guess.use((state) => state.guessState);
const isRevealed = guessState !== null; const isRevealed = guessState !== GuessState.NotGuessed;
return ( return (
<SmartCanvas <SmartCanvas
style={{ style={{
filter: `grayscale(${isRevealed ? 0 : 1}) contrast(${isRevealed ? 1 : CONTRAST}) brightness(${isRevealed ? 1 : BRIGHTNESS})`, filter: `grayscale(${isRevealed ? 0 : 1}) contrast(${isRevealed ? 1 : CONTRAST}) brightness(${isRevealed ? 1 : BRIGHTNESS})`,
// filter: `drop-shadow(0 0 1rem ${Var('black-a11')})`, transitionDuration: isRevealed ? "2s" : undefined,
transitionDuration: isRevealed ? '2s' : undefined, transitionProperty: "filter",
transitionProperty: 'filter',
}} }}
camera={{ position: [-5, 5, -5] }} camera={{ position: [-5, 5, -5] }}
> >
<Controls distanceScale={2} zoomable={false} /> <Controls zoomable={false} />
<Lighting display={TankopediaDisplay.Model} /> <Lighting />
<TankModel /> <TankModel />
</SmartCanvas> </SmartCanvas>
); );
+39 -33
View File
@@ -2,13 +2,13 @@ import {
SEARCH_KEYS, SEARCH_KEYS,
TankDefinition, TankDefinition,
TIER_ROMAN_NUMERALS, TIER_ROMAN_NUMERALS,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n'; import { literals } from "@blitzkit/i18n";
import { import {
ArrowRightIcon, ArrowRightIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
PaperPlaneIcon, PaperPlaneIcon,
} from '@radix-ui/react-icons'; } from "@radix-ui/react-icons";
import { import {
Box, Box,
Button, Button,
@@ -17,16 +17,16 @@ import {
Spinner, Spinner,
Text, Text,
TextField, TextField,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import fuzzysort from 'fuzzysort'; import fuzzysort from "fuzzysort";
import { debounce } from 'lodash-es'; import { debounce } from "lodash-es";
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from "react";
import { awaitableTankDefinitions } from '../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../core/awaitables/tankDefinitions";
import { awaitableTankNames } from '../core/awaitables/tankNames'; import { awaitableTankNames } from "../core/awaitables/tankNames";
import { useLocale } from '../hooks/useLocale'; import { useLocale } from "../hooks/useLocale";
import { GuessEphemeral } from '../stores/guessEphemeral'; import { Guess, GuessState } from "../stores/guess";
import { classIcons } from './ClassIcon'; import { classIcons } from "./ClassIcon";
import { SearchResults } from './SearchResults'; import { SearchResults } from "./SearchResults";
const { go } = fuzzysort; const { go } = fuzzysort;
@@ -38,16 +38,15 @@ const [tankNames, tankDefinitions] = await Promise.all([
const ids = Object.keys(tankDefinitions.tanks); const ids = Object.keys(tankDefinitions.tanks);
export function Guesser() { export function Guesser() {
const tank = GuessEphemeral.use((state) => state.tank); const tank = Guess.use((state) => state.tank);
const guessState = GuessEphemeral.use((state) => state.guessState); const guessState = Guess.use((state) => state.guessState);
const correctGuesses = GuessEphemeral.use((state) => state.correctGuesses); const correctGuesses = Guess.use((state) => state.correctGuesses);
const totalGuesses = GuessEphemeral.use((state) => state.totalGuesses); const totalGuesses = Guess.use((state) => state.totalGuesses);
const { strings, unwrap } = useLocale(); const { strings, unwrap } = useLocale();
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [results, setResults] = useState<TankDefinition[] | null>(null); const [results, setResults] = useState<TankDefinition[] | null>(null);
const [selected, setSelected] = useState<TankDefinition | null>(null); const [selected, setSelected] = useState<TankDefinition | null>(null);
const mutateGuessEphemeral = GuessEphemeral.useMutation();
const requestSearch = useCallback(() => { const requestSearch = useCallback(() => {
setSelected(null); setSelected(null);
@@ -72,14 +71,14 @@ export function Guesser() {
}); });
setResults( setResults(
searchResults.map((result) => tankDefinitions.tanks[result.obj.id]), searchResults.map((result) => tankDefinitions.tanks[result.obj.id])
); );
}, 500), }, 500),
[], []
); );
useEffect(() => { useEffect(() => {
if (guessState !== null) setSelected(null); if (guessState !== GuessState.NotGuessed) setSelected(null);
}, [guessState]); }, [guessState]);
return ( return (
@@ -88,7 +87,7 @@ export function Guesser() {
position="absolute" position="absolute"
bottom="0" bottom="0"
left="50%" left="50%"
style={{ transform: 'translateX(-50%)' }} style={{ transform: "translateX(-50%)" }}
width="100%" width="100%"
p="4" p="4"
maxWidth="25rem" maxWidth="25rem"
@@ -138,7 +137,7 @@ export function Guesser() {
</Card> </Card>
)} )}
<Flex justify="center"> <Flex justify="center" style={{ userSelect: "none" }}>
<Text> <Text>
{literals(strings.website.tools.guess.stats, [ {literals(strings.website.tools.guess.stats, [
`${correctGuesses}`, `${correctGuesses}`,
@@ -149,7 +148,7 @@ export function Guesser() {
<Flex gap="3"> <Flex gap="3">
<TextField.Root <TextField.Root
disabled={guessState !== null} disabled={guessState !== GuessState.NotGuessed}
ref={input} ref={input}
onChange={requestSearch} onChange={requestSearch}
style={{ flex: 1 }} style={{ flex: 1 }}
@@ -164,13 +163,19 @@ export function Guesser() {
<Button <Button
size="3" size="3"
color={guessState === null && selected === null ? 'red' : undefined} color={
guessState === GuessState.NotGuessed && selected === null
? "red"
: undefined
}
onClick={() => { onClick={() => {
if (guessState === null) { if (guessState === GuessState.NotGuessed) {
const correct = selected !== null && selected.id === tank.id; const correct = selected !== null && selected.id === tank.id;
mutateGuessEphemeral((draft) => { Guess.mutate((draft) => {
draft.guessState = correct; draft.guessState = correct
? GuessState.Correct
: GuessState.Incorrect;
draft.totalGuesses++; draft.totalGuesses++;
draft.correctGuesses += correct ? 1 : 0; draft.correctGuesses += correct ? 1 : 0;
}); });
@@ -178,21 +183,22 @@ export function Guesser() {
const id = Number(ids[Math.floor(Math.random() * ids.length)]); const id = Number(ids[Math.floor(Math.random() * ids.length)]);
const tank = tankDefinitions.tanks[id]; const tank = tankDefinitions.tanks[id];
mutateGuessEphemeral((draft) => { Guess.mutate((draft) => {
if (!input.current) return; if (!input.current) return;
draft.tank = tank; draft.tank = tank;
draft.guessState = null; draft.guessState = GuessState.NotGuessed;
input.current.value = '';
input.current.value = "";
}); });
} }
}} }}
> >
{guessState === null ? ( {guessState === GuessState.NotGuessed ? (
<> <>
{ {
strings.website.tools.guess.search[ strings.website.tools.guess.search[
selected === null ? 'skip' : 'guess' selected === null ? "skip" : "guess"
] ]
} }
{selected === null ? <ArrowRightIcon /> : <PaperPlaneIcon />} {selected === null ? <ArrowRightIcon /> : <PaperPlaneIcon />}
@@ -0,0 +1,21 @@
import type { SpotLightProps } from '@react-three/fiber';
import { memo } from 'react';
interface Props extends SpotLightProps {
debug?: boolean;
}
export const HelpingSpotLight = memo<Props>(({ debug, ...props }) => {
return (
<>
{debug && (
<mesh position={props.position}>
<icosahedronGeometry args={[0.1, 2]} />
<meshBasicMaterial color={props.color} />
</mesh>
)}
<spotLight {...props} />
</>
);
});
@@ -1,15 +1,14 @@
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { EyeOpenIcon } from '@radix-ui/react-icons'; import { EyeOpenIcon } from "@radix-ui/react-icons";
import { Box, Flex, Heading, Text } from '@radix-ui/themes'; import { Box, Flex, Heading, Text } from "@radix-ui/themes";
import { awaitablePopularTanks } from '../core/awaitables/popularTanks'; import { awaitablePopularTanks } from "../core/awaitables/popularTanks";
import { awaitableTankDefinitions } from '../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../core/awaitables/tankDefinitions";
import { import {
LocaleProvider, LocaleProvider,
useLocale, useLocale,
type LocaleAcceptorProps, type LocaleAcceptorProps,
} from '../hooks/useLocale'; } from "../hooks/useLocale";
import { TankopediaPersistent } from '../stores/tankopediaPersistent'; import { TankCard } from "./TankCard";
import { TankCard } from './TankCard';
const [tankDefinitions, popularTanks] = await Promise.all([ const [tankDefinitions, popularTanks] = await Promise.all([
awaitableTankDefinitions, awaitableTankDefinitions,
@@ -19,9 +18,7 @@ const [tankDefinitions, popularTanks] = await Promise.all([
export function HomePageHotTanks({ locale }: LocaleAcceptorProps) { export function HomePageHotTanks({ locale }: LocaleAcceptorProps) {
return ( return (
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<TankopediaPersistent.Provider> <Content />
<Content />
</TankopediaPersistent.Provider>
</LocaleProvider> </LocaleProvider>
); );
} }
@@ -35,7 +32,7 @@ function Content() {
{strings.website.home.hot_tanks.title} {strings.website.home.hot_tanks.title}
</Heading> </Heading>
<Text color="gray" align="center" mb="5" size="2"> <Text color="gray" align="center" mb="5" size="2">
{literals(strings.website.home.hot_tanks.subtitle, ['7'])} {literals(strings.website.home.hot_tanks.subtitle, ["7"])}
</Text> </Text>
<Flex justify="center" gap="4" wrap="wrap"> <Flex justify="center" gap="4" wrap="wrap">
@@ -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 { import {
Cross1Icon, Cross1Icon,
DiscordLogoIcon, DiscordLogoIcon,
GearIcon, GearIcon,
HamburgerMenuIcon, HamburgerMenuIcon,
} from '@radix-ui/react-icons'; } from "@radix-ui/react-icons";
import { Box, Code, Flex, IconButton, Link } from '@radix-ui/themes'; import { Box, Code, Flex, IconButton, Link } from "@radix-ui/themes";
import { TOOLS } from '../constants/tools'; import { resolveBranchName } from "../core/blitzkit/branches";
import { resolveBranchName } from '../core/blitzkit/branches'; import { getStrings } from "../core/i18n/getStrings";
import { getStrings } from '../core/i18n/getStrings'; import { Var } from "../core/radix/var";
import { Var } from '../core/radix/var'; import { LinkI18n } from "./LinkI18n";
import { LinkI18n } from './LinkI18n'; import { tools } from "../constants/tools";
export interface Props { export interface Props {
integrated?: boolean; integrated?: boolean;
} }
const { integrated = false } = Astro.props; const { integrated = false } = Astro.props;
const toolsFiltered = TOOLS.filter((tool) => !('href' in tool)); const toolsFiltered = Object.values(tools).filter(
(tool) => !tool.href && !tool.disabled
);
const { locale } = Astro.params; const { locale } = Astro.params;
const branchName = resolveBranchName(locale); const branchName = resolveBranchName(locale);
const isBranchNamed = branchName !== undefined; const isBranchNamed = branchName !== undefined;
@@ -39,49 +40,45 @@ const strings = getStrings(locale);
height: 0; height: 0;
} }
.navbar[data-expanded='false'] *[data-expanded] { .navbar[data-expanded="false"] *[data-expanded] {
display: none; display: none;
} }
.navbar[data-expanded='true'] { .navbar[data-expanded="true"] {
height: 100%; height: 100%;
} }
.navbar[data-expanded='false'][data-integrated='false'] .content { .navbar[data-expanded="false"][data-integrated="false"] .content {
background-color: var(--black-a8); background-color: var(--black-a8);
backdrop-filter: blur(4rem); backdrop-filter: blur(4rem);
box-shadow: var(--shadow-3); box-shadow: var(--shadow-3);
} }
.navbar[data-expanded='true'] .content { .navbar[data-expanded="true"] .content {
background-color: var(--gray-1); background-color: var(--gray-1);
} }
.navbar[data-expanded='true'] *[data-closed] { .navbar[data-expanded="true"] *[data-closed] {
display: none; display: none;
} }
</style> </style>
<script> <script>
const navbar = document.querySelector<HTMLDivElement>('.navbar'); const navbar = document.querySelector<HTMLDivElement>(".navbar");
const hamburger = document.querySelector<HTMLButtonElement>('.hamburger'); const hamburger = document.querySelector<HTMLButtonElement>(".hamburger");
navbar?.addEventListener('click', () => { navbar?.addEventListener("click", () => {
navbar.dataset.expanded = 'false'; navbar.dataset.expanded = "false";
}); });
hamburger?.addEventListener('click', (event) => { hamburger?.addEventListener("click", (event) => {
if (!navbar) return; if (!navbar) return;
event.stopPropagation(); event.stopPropagation();
navbar.dataset.expanded = `${navbar.dataset.expanded === 'false'}`; navbar.dataset.expanded = `${navbar.dataset.expanded === "false"}`;
}); });
</script> </script>
{ <Box className="padder" height={{ initial: "3.5rem", sm: "3rem" }} />
!integrated && (
<Box className="padder" height={{ initial: '3.5rem', sm: '3rem' }} />
)
}
<Flex <Flex
direction="column" direction="column"
@@ -94,16 +91,16 @@ const strings = getStrings(locale);
position="fixed" position="fixed"
style={{ style={{
zIndex: 1, zIndex: 1,
backdropFilter: 'blur(4rem)', backdropFilter: "blur(4rem)",
}} }}
> >
<Flex <Flex
className="content" className="content"
width="100%" width="100%"
p={{ initial: '4', sm: '3' }} p={{ initial: "4", sm: "3" }}
direction="column" direction="column"
align="center" align="center"
style={{ backgroundColor: integrated ? undefined : Var('gray-1') }} style={{ backgroundColor: integrated ? undefined : Var("gray-1") }}
> >
<Flex gap="4" direction="column" maxWidth="80rem" width="100%"> <Flex gap="4" direction="column" maxWidth="80rem" width="100%">
<Flex align="center"> <Flex align="center">
@@ -131,33 +128,27 @@ const strings = getStrings(locale);
<Flex <Flex
align="center" align="center"
gap={{ initial: '4', md: '6' }} gap={{ initial: "3", md: "6" }}
display={{ initial: 'none', sm: 'flex' }} display={{ initial: "none", sm: "flex" }}
justify="center" justify="center"
className="tools" className="tools"
> >
{ {
toolsFiltered.map((tool) => { toolsFiltered.map((tool) => {
const unavailableOnBranch = tool.branches?.every(
(branch) =>
branch !== assertSecret(import.meta.env.PUBLIC_BRANCH),
);
const selected = Astro.url.pathname.includes(`/${tool.id}`); const selected = Astro.url.pathname.includes(`/${tool.id}`);
const toolStrings = const toolStrings =
strings.website.tools[ strings.website.tools[
(tool.stringsId ?? (tool.strings ??
tool.id) as keyof typeof strings.website.tools tool.id) as keyof typeof strings.website.tools
]; ];
if (unavailableOnBranch) return null;
return ( return (
<LinkI18n <LinkI18n
{locale} {locale}
weight={selected ? 'medium' : 'regular'} weight={selected ? "medium" : "regular"}
color="gray" color="gray"
highContrast={selected} highContrast={selected}
size="2" size={{ initial: "1", md: "2" }}
href={`/${tool.id}`} href={`/${tool.id}`}
underline="hover" underline="hover"
> >
@@ -172,7 +163,7 @@ const strings = getStrings(locale);
<Flex align="center" gap="4"> <Flex align="center" gap="4">
<Link <Link
style={{ display: 'flex', alignContent: 'center' }} style={{ display: "flex", alignContent: "center" }}
color="gray" color="gray"
href="https://discord.gg/nDt7AjGJQH" href="https://discord.gg/nDt7AjGJQH"
underline="none" underline="none"
@@ -183,7 +174,7 @@ const strings = getStrings(locale);
<LinkI18n <LinkI18n
{locale} {locale}
style={{ display: 'flex', alignContent: 'center' }} style={{ display: "flex", alignContent: "center" }}
color="gray" color="gray"
href="/settings" href="/settings"
underline="none" underline="none"
@@ -193,7 +184,7 @@ const strings = getStrings(locale);
<!-- <NavbarAccount client:idle /> --> <!-- <NavbarAccount client:idle /> -->
<Flex align="center" display={{ initial: 'flex', sm: 'none' }}> <Flex align="center" display={{ initial: "flex", sm: "none" }}>
<IconButton variant="ghost" color="gray" className="hamburger"> <IconButton variant="ghost" color="gray" className="hamburger">
<Cross1Icon data-expanded /> <Cross1Icon data-expanded />
<HamburgerMenuIcon data-closed /> <HamburgerMenuIcon data-closed />
@@ -206,36 +197,28 @@ const strings = getStrings(locale);
direction="column" direction="column"
data-expanded data-expanded
gap="3" gap="3"
display={{ initial: 'flex', sm: 'none' }} display={{ initial: "flex", sm: "none" }}
> >
{ {
toolsFiltered.map((tool) => { toolsFiltered.map(( tool) => {
const unavailableOnBranch = tool.branches?.every(
(branch) =>
branch !== assertSecret(import.meta.env.PUBLIC_BRANCH),
);
if (unavailableOnBranch) return null;
const toolStrings = const toolStrings =
strings.website.tools[ strings.website.tools[
(tool.stringsId ?? (tool.strings ?? tool.id) as keyof typeof strings.website.tools
tool.id) as keyof typeof strings.website.tools
]; ];
return ( return (
<Link <Link
color="gray" color="gray"
highContrast highContrast
style={{ display: 'contents' }} style={{ display: "contents" }}
href={`/${tool.id}`} href={`/${tool.id}`}
> >
<Box <Box
style={{ style={{
borderRadius: Var('radius-2'), borderRadius: Var("radius-2"),
backgroundImage: `url(/assets/images/tools/${tool.id}.webp)`, backgroundImage: `url(/assets/images/tools/${tool.id}.webp)`,
backgroundSize: '200%', backgroundSize: "200%",
backgroundPosition: 'right center', backgroundPosition: "right center",
}} }}
> >
<Flex <Flex
@@ -243,7 +226,7 @@ const strings = getStrings(locale);
justify="end" justify="end"
flexGrow="1" flexGrow="1"
style={{ style={{
background: `linear-gradient(90deg, transparent, ${Var('gray-1')} 90%)`, background: `linear-gradient(90deg, transparent, ${Var("gray-1")} 90%)`,
}} }}
> >
{toolStrings.name} {toolStrings.name}
@@ -261,11 +244,11 @@ const strings = getStrings(locale);
data-expanded data-expanded
flexGrow="1" flexGrow="1"
width="100%" width="100%"
display={{ initial: 'block', sm: 'none' }} display={{ initial: "block", sm: "none" }}
style={{ style={{
backgroundColor: Var('black-a7'), backgroundColor: Var("black-a7"),
backdropFilter: 'blur(0.5rem)', backdropFilter: "blur(0.5rem)",
WebkitBackdropFilter: 'blur(0.5rem)', WebkitBackdropFilter: "blur(0.5rem)",
}} }}
/> />
</Flex> </Flex>
@@ -1,4 +1,4 @@
import { PersonIcon } from '@radix-ui/react-icons'; import { PersonIcon } from "@radix-ui/react-icons";
import { import {
Button, Button,
Flex, Flex,
@@ -6,91 +6,78 @@ import {
Popover, Popover,
Skeleton, Skeleton,
Text, Text,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { Suspense } from 'react'; import { Suspense } from "react";
import { App } from '../../stores/app'; import { App } from "../../stores/app";
import { BlitzKitTheme } from '../BlitzKitTheme'; import { BlitzKitTheme } from "../BlitzKitTheme";
import { WargamingAccountName } from '../WargamingAccountName'; import { WargamingAccountName } from "../WargamingAccountName";
import { WargamingIcon } from '../WargamingIcon'; import { WargamingIcon } from "../WargamingIcon";
import { WargamingLoginButton } from '../WargamingLoginButton'; import { WargamingLoginButton } from "../WargamingLoginButton";
export function NavbarAccount() { export function NavbarAccount() {
return (
<App.Provider>
<Content />
</App.Provider>
);
}
function Content() {
const logins = App.use((state) => state.logins); const logins = App.use((state) => state.logins);
const mutateApp = App.useMutation();
return ( return (
<App.Provider> <Popover.Root>
<Popover.Root> <Popover.Trigger>
<Popover.Trigger> <IconButton variant="ghost" color="gray">
<IconButton variant="ghost" color="gray"> <PersonIcon />
<PersonIcon /> </IconButton>
</IconButton> </Popover.Trigger>
</Popover.Trigger>
<BlitzKitTheme style={{ display: 'contents' }}> <BlitzKitTheme style={{ display: "contents" }}>
<Popover.Content <Popover.Content
align="end" align="end"
width="320px" width="320px"
onOpenAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()}
> >
{logins.wargaming && ( {logins.wargaming && (
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Text align="center" color="gray"> <Text align="center" color="gray">
Logged in accounts Logged in accounts
</Text> </Text>
{logins.wargaming && ( {logins.wargaming && (
<Flex align="center" gap="2" justify="center"> <Flex align="center" gap="2" justify="center">
<WargamingIcon width={15} height={15} /> <WargamingIcon width={15} height={15} />
<Text> <Text>
<Suspense <Suspense fallback={<Skeleton height="1em" width="5rem" />}>
fallback={<Skeleton height="1em" width="5rem" />} <WargamingAccountName />
> </Suspense>
<WargamingAccountName /> </Text>
</Suspense> <Button
</Text> color="red"
<Button variant="ghost"
color="red" onClick={() => {
variant="ghost" App.mutate((draft) => {
onClick={() => { draft.logins.wargaming = undefined;
mutateApp((draft) => { });
draft.logins.wargaming = undefined; }}
}); >
}} Logout
> </Button>
Logout </Flex>
</Button> )}
</Flex> </Flex>
)} )}
</Flex>
)}
{/* {!logins. !== !logins.wargaming && ( {/* {!logins. !== !logins.wargaming && (
<Inset side="x"> <Inset side="x">
<Separator my="4" size="4" /> <Separator my="4" size="4" />
</Inset> </Inset>
)} */} )} */}
{!logins.wargaming && ( {!logins.wargaming && (
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Text align="center" color="gray"> <Text align="center" color="gray">
Log in with... Log in with...
</Text> </Text>
{!logins.wargaming && <WargamingLoginButton />} {!logins.wargaming && <WargamingLoginButton />}
</Flex> </Flex>
)} )}
</Popover.Content> </Popover.Content>
</BlitzKitTheme> </BlitzKitTheme>
</Popover.Root> </Popover.Root>
</App.Provider>
); );
} }
+12 -12
View File
@@ -1,11 +1,11 @@
import { Flex, type FlexProps, Theme } from '@radix-ui/themes'; import { Flex, type FlexProps, Theme } from "@radix-ui/themes";
import type { ComponentProps } from 'react'; import type { ComponentProps } from "react";
import { NAVBAR_HEIGHT } from '../constants/navbar'; import { NAVBAR_HEIGHT } from "../constants/navbar";
import type { RadixSize } from '../stores/embedState'; import type { RadixSize } from "../stores/embedState";
import { BlitzKitTheme } from './BlitzKitTheme'; import { BlitzKitTheme } from "./BlitzKitTheme";
type PageWrapperProps = FlexProps & { type PageWrapperProps = FlexProps & {
color?: ComponentProps<typeof Theme>['accentColor']; color?: ComponentProps<typeof Theme>["accentColor"];
containerProps?: ComponentProps<typeof Theme>; containerProps?: ComponentProps<typeof Theme>;
noFlex1?: boolean; noFlex1?: boolean;
padding?: RadixSize; padding?: RadixSize;
@@ -19,7 +19,7 @@ export function PageWrapper({
noFlex1 = false, noFlex1 = false,
containerProps, containerProps,
noMinHeight = false, noMinHeight = false,
padding = '4', padding = "4",
...props ...props
}: PageWrapperProps) { }: PageWrapperProps) {
return ( return (
@@ -27,9 +27,9 @@ export function PageWrapper({
accentColor={color} accentColor={color}
style={{ style={{
flex: noFlex1 ? undefined : 1, flex: noFlex1 ? undefined : 1,
display: 'flex', display: "flex",
minHeight: noMinHeight ? 'unset' : `calc(100svh - ${NAVBAR_HEIGHT}px)`, minHeight: noMinHeight ? "unset" : `calc(100svh - ${NAVBAR_HEIGHT}px)`,
flexDirection: 'column', flexDirection: "column",
...containerProps?.style, ...containerProps?.style,
}} }}
{...containerProps} {...containerProps}
@@ -41,8 +41,8 @@ export function PageWrapper({
width="100%" width="100%"
maxWidth="80rem" maxWidth="80rem"
style={{ style={{
margin: 'auto', margin: "auto",
boxSizing: 'border-box', boxSizing: "border-box",
flex: 1, flex: 1,
...style, ...style,
}} }}
@@ -2,16 +2,15 @@ import {
CaretDownIcon, CaretDownIcon,
CaretSortIcon, CaretSortIcon,
CaretUpIcon, CaretUpIcon,
} from '@radix-ui/react-icons'; } from "@radix-ui/react-icons";
import { Button, Flex, Table } from '@radix-ui/themes'; import { Button, Flex, Table } from "@radix-ui/themes";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { TankPerformanceSort } from '../../stores/tankPerformanceSort'; import { TankPerformanceSort } from "../../stores/tankPerformanceSort";
import { TankPerformanceSortTypeNamesArray } from '../../stores/tankPerformanceSort/constants'; import { TankPerformanceSortTypeNamesArray } from "../../stores/tankPerformanceSort/constants";
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell'; import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
export function Header() { export function Header() {
const sort = TankPerformanceSort.use(); const sort = TankPerformanceSort.use();
const store = TankPerformanceSort.useStore();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -34,13 +33,18 @@ export function Header() {
<Flex align="center" gap="1"> <Flex align="center" gap="1">
<Button <Button
variant="ghost" variant="ghost"
color={isSelected ? undefined : 'gray'} color={isSelected ? undefined : "gray"}
highContrast={!isSelected} highContrast={!isSelected}
onClick={() => { onClick={() => {
if (isSelected) { if (isSelected) {
store.setState({ direction: -sort.direction as 1 | -1 }); TankPerformanceSort.mutate((draft) => {
draft.direction *= -1;
});
} else { } else {
store.setState({ type, direction: -1 }); TankPerformanceSort.mutate((draft) => {
draft.type = type;
draft.direction = -1;
});
} }
}} }}
> >
@@ -1,15 +1,9 @@
import { Flex, Select, Text } from '@radix-ui/themes'; import { Flex, Select, Text } from '@radix-ui/themes';
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from '../../hooks/useLocale';
import { import { Performance, type PlayerCountPeriod } from '../../stores/performance';
TankPerformanceEphemeral,
type PlayerCountPeriod,
} from '../../stores/tankPerformanceEphemeral';
export function PlayerCountControl() { export function PlayerCountControl() {
const playerCountPeriod = TankPerformanceEphemeral.use( const playerCountPeriod = Performance.use((state) => state.playerCountPeriod);
(state) => state.playerCountPeriod,
);
const mutateTankPerformanceSort = TankPerformanceEphemeral.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -18,7 +12,7 @@ export function PlayerCountControl() {
<Select.Root <Select.Root
value={playerCountPeriod} value={playerCountPeriod}
onValueChange={(period) => { onValueChange={(period) => {
mutateTankPerformanceSort((draft) => { Performance.mutate((draft) => {
draft.playerCountPeriod = period as PlayerCountPeriod; draft.playerCountPeriod = period as PlayerCountPeriod;
}); });
}} }}
@@ -4,7 +4,7 @@ import { memo } from 'react';
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions'; import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions';
import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio'; import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio';
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from '../../hooks/useLocale';
import { TankPerformanceEphemeral } from '../../stores/tankPerformanceEphemeral'; import { Performance } from '../../stores/performance';
import { TankRowHeaderCell } from '../TankRowHeaderCell'; import { TankRowHeaderCell } from '../TankRowHeaderCell';
interface TankRowProps { interface TankRowProps {
@@ -17,7 +17,7 @@ export const TankRow = memo<TankRowProps>(
({ tank }) => { ({ tank }) => {
const averages = averageDefinitions.averages[tank.id]; const averages = averageDefinitions.averages[tank.id];
const ratio = useAveragesExclusionRatio(); const ratio = useAveragesExclusionRatio();
const playerCountPeriod = TankPerformanceEphemeral.use( const playerCountPeriod = Performance.use(
(state) => state.playerCountPeriod, (state) => state.playerCountPeriod,
); );
const { locale } = useLocale(); const { locale } = useLocale();
@@ -1,17 +1,16 @@
import { useStore } from '@nanostores/react'; import { Table } from "@radix-ui/themes";
import { Table } from '@radix-ui/themes'; import { times } from "lodash-es";
import { times } from 'lodash-es'; import { Fragment, Suspense, useEffect, useMemo, useState } from "react";
import { Fragment, Suspense, useEffect, useMemo, useState } from 'react'; import { awaitableAverageDefinitions } from "../../core/awaitables/averageDefinitions";
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions'; import { awaitableTankDefinitions } from "../../core/awaitables/tankDefinitions";
import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions'; import { filterTank } from "../../core/blitzkit/filterTank";
import { filterTank } from '../../core/blitzkit/filterTank'; import { Performance } from "../../stores/performance";
import { $tankFilters } from '../../stores/tankFilters'; import { TankFilters } from "../../stores/tankFilters";
import { TankPerformanceEphemeral } from '../../stores/tankPerformanceEphemeral'; import { TankPerformanceSort } from "../../stores/tankPerformanceSort";
import { TankPerformanceSort } from '../../stores/tankPerformanceSort'; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import { RowLoader } from "./RowLoader";
import { RowLoader } from './RowLoader'; import { TankRow } from "./TankRow";
import { TankRow } from './TankRow'; import { Total } from "./Total";
import { Total } from './Total';
const PREVIEW_COUNT = 10; const PREVIEW_COUNT = 10;
const DEFAULT_LOADED_ROWS = 25; const DEFAULT_LOADED_ROWS = 25;
@@ -25,7 +24,7 @@ const averageDefinitionsArray = Object.entries(averageDefinitions.averages).map(
([id, average]) => ({ ([id, average]) => ({
id: Number(id), id: Number(id),
...average, ...average,
}), })
); );
export function Tanks({ skeleton }: MaybeSkeletonComponentProps) { export function Tanks({ skeleton }: MaybeSkeletonComponentProps) {
@@ -40,98 +39,97 @@ export function Tanks({ skeleton }: MaybeSkeletonComponentProps) {
} }
const sort = TankPerformanceSort.use(); const sort = TankPerformanceSort.use();
const tankPerformancePersistentStore = TankPerformanceEphemeral.useStore();
const filters = useStore($tankFilters);
const tanksSorted = useMemo(() => { const tanksSorted = useMemo(() => {
const { playerCountPeriod } = tankPerformancePersistentStore.getState(); const { playerCountPeriod } = Performance.state;
switch (sort.type) { switch (sort.type) {
case 'accuracy': case "accuracy":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * (a.mu.hits / a.mu.shots - b.mu.hits / b.mu.shots), sort.direction * (a.mu.hits / a.mu.shots - b.mu.hits / b.mu.shots)
); );
case 'capture_points': case "capture_points":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.capture_points / a.mu.battles - (a.mu.capture_points / a.mu.battles -
b.mu.capture_points / b.mu.battles), b.mu.capture_points / b.mu.battles)
); );
case 'damage': case "damage":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.damage_dealt / a.mu.battles - (a.mu.damage_dealt / a.mu.battles -
b.mu.damage_dealt / b.mu.battles), b.mu.damage_dealt / b.mu.battles)
); );
case 'damage_ratio': case "damage_ratio":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.damage_dealt / a.mu.damage_received - (a.mu.damage_dealt / a.mu.damage_received -
b.mu.damage_dealt / b.mu.damage_received), b.mu.damage_dealt / b.mu.damage_received)
); );
case 'damage_taken': case "damage_taken":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.damage_received / a.mu.battles - (a.mu.damage_received / a.mu.battles -
b.mu.damage_received / b.mu.battles), b.mu.damage_received / b.mu.battles)
); );
case 'hits': case "hits":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.hits / a.mu.battles - b.mu.hits / b.mu.battles), (a.mu.hits / a.mu.battles - b.mu.hits / b.mu.battles)
); );
case 'kills': case "kills":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.frags / a.mu.battles - b.mu.frags / b.mu.battles), (a.mu.frags / a.mu.battles - b.mu.frags / b.mu.battles)
); );
case 'shots': case "shots":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.shots / a.mu.battles - b.mu.shots / b.mu.battles), (a.mu.shots / a.mu.battles - b.mu.shots / b.mu.battles)
); );
case 'players': case "players":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.samples[playerCountPeriod] - b.samples[playerCountPeriod]), (a.samples[playerCountPeriod] - b.samples[playerCountPeriod])
); );
case 'spots': case "spots":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.spotted / a.mu.battles - b.mu.spotted / b.mu.battles), (a.mu.spotted / a.mu.battles - b.mu.spotted / b.mu.battles)
); );
case 'survival': case "survival":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.survived_battles / a.mu.battles - (a.mu.survived_battles / a.mu.battles -
b.mu.survived_battles / b.mu.battles), b.mu.survived_battles / b.mu.battles)
); );
case 'winrate': case "winrate":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.wins / a.mu.battles - b.mu.wins / b.mu.battles), (a.mu.wins / a.mu.battles - b.mu.wins / b.mu.battles)
); );
case 'xp': case "xp":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * (a.mu.xp / a.mu.battles - b.mu.xp / b.mu.battles), sort.direction * (a.mu.xp / a.mu.battles - b.mu.xp / b.mu.battles)
); );
} }
}, [sort]); }, [sort]);
const filters = TankFilters.use();
const tanks = useMemo(() => { const tanks = useMemo(() => {
return tanksSorted.filter((tank) => return tanksSorted.filter((tank) =>
filterTank(filters, tankDefinitions.tanks[tank.id]), filterTank(filters, tankDefinitions.tanks[tank.id])
); );
}, [filters, sort]); }, [filters, sort]);
const [loadedRows, setLoadedRows] = useState(DEFAULT_LOADED_ROWS); const [loadedRows, setLoadedRows] = useState(DEFAULT_LOADED_ROWS);
@@ -8,7 +8,7 @@ import { memo, useCallback } from 'react';
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions'; import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions';
import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio'; import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio';
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from '../../hooks/useLocale';
import { TankPerformanceEphemeral } from '../../stores/tankPerformanceEphemeral'; import { Performance } from '../../stores/performance';
import { StickyRowHeaderCell } from '../StickyRowHeaderCell'; import { StickyRowHeaderCell } from '../StickyRowHeaderCell';
interface AverageDefinitionsEntryWithId extends AverageDefinitionsEntry { interface AverageDefinitionsEntryWithId extends AverageDefinitionsEntry {
@@ -23,7 +23,7 @@ const averageDefinitions = await awaitableAverageDefinitions;
export const Total = memo<TotalProps>( export const Total = memo<TotalProps>(
({ tanks }) => { ({ tanks }) => {
const playerCountPeriod = TankPerformanceEphemeral.use( const playerCountPeriod = Performance.use(
(state) => state.playerCountPeriod, (state) => state.playerCountPeriod,
); );
const { strings, locale } = useLocale(); const { strings, locale } = useLocale();
+8 -17
View File
@@ -1,20 +1,11 @@
import { assertSecret } from '@blitzkit/core'; import { assertSecret } from "@blitzkit/core";
import { ArrowRightIcon } from '@radix-ui/react-icons'; import { ArrowRightIcon } from "@radix-ui/react-icons";
import { Button, Code, Flex, Link, Text } from '@radix-ui/themes'; import { Button, Code, Flex, Link, Text } from "@radix-ui/themes";
import { App } from '../stores/app';
export function Plugs() { export function Plugs() {
return (
<App.Provider>
<Content />
</App.Provider>
);
}
function Content() {
const promoteOpentest = const promoteOpentest =
assertSecret(import.meta.env.PUBLIC_PROMOTE_OPENTEST) === 'true' && assertSecret(import.meta.env.PUBLIC_PROMOTE_OPENTEST) === "true" &&
assertSecret(import.meta.env.PUBLIC_BRANCH) !== 'opentest'; assertSecret(import.meta.env.PUBLIC_BRANCH) !== "opentest";
const promotions = [promoteOpentest]; const promotions = [promoteOpentest];
if (promotions.every((promotion) => !promotion)) return null; if (promotions.every((promotion) => !promotion)) return null;
@@ -31,11 +22,11 @@ function Content() {
{promoteOpentest && ( {promoteOpentest && (
<Flex direction="column" gap="3" align="center"> <Flex direction="column" gap="3" align="center">
<Text size="5"> <Text size="5">
BlitzKit{' '} BlitzKit{" "}
<Code variant="outline" size="4" color="gray"> <Code variant="outline" size="4" color="gray">
opentest opentest
</Code>{' '} </Code>{" "}
is available now{' '} is available now{" "}
</Text> </Text>
<Link href="https://opentest.blitzkit.app/" target="_blank"> <Link href="https://opentest.blitzkit.app/" target="_blank">
@@ -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>
);
}
@@ -3,16 +3,16 @@ import {
fetchTankDefinitions, fetchTankDefinitions,
TankType, TankType,
type TankDefinition, type TankDefinition,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { Flex, Text, type TextProps } from '@radix-ui/themes'; import { Flex, Text, type TextProps } from "@radix-ui/themes";
import { uniq } from 'lodash-es'; import { uniq } from "lodash-es";
import { forwardRef, type ReactNode } from 'react'; import { forwardRef, type ReactNode } from "react";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { TankopediaPersistent } from '../../stores/tankopediaPersistent'; import { TankopediaPersistent } from "../../stores/tankopediaPersistent";
import { classIcons } from '../ClassIcon'; import { classIcons } from "../ClassIcon";
import { LinkI18n } from '../LinkI18n'; import { LinkI18n } from "../LinkI18n";
import { MAX_RECENTLY_VIEWED } from '../TankSearch/constants'; import { MAX_RECENTLY_VIEWED } from "../TankSearch/constants";
import './index.css'; import "./index.css";
type TankCardProps = TextProps & { type TankCardProps = TextProps & {
tank: TankDefinition; tank: TankDefinition;
@@ -33,11 +33,11 @@ export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
style, style,
...props ...props
}: TankCardProps, }: TankCardProps,
ref, ref
) => { ) => {
const { unwrap, locale } = useLocale(); const { unwrap, locale } = useLocale();
const provideLink = !noLink && onSelect === undefined; const provideLink = !noLink && onSelect === undefined;
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const Icon = classIcons[tank.class]; const Icon = classIcons[tank.class];
return ( return (
@@ -47,15 +47,15 @@ export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
size="2" size="2"
color={ color={
tank.type === TankType.COLLECTOR tank.type === TankType.COLLECTOR
? 'blue' ? "blue"
: tank.type === TankType.PREMIUM : tank.type === TankType.PREMIUM
? 'amber' ? "amber"
: 'gray' : "gray"
} }
highContrast={tank.type === TankType.RESEARCHABLE} highContrast={tank.type === TankType.RESEARCHABLE}
onClick={() => { onClick={() => {
onSelect?.(tank); onSelect?.(tank);
mutateTankopediaPersistent((draft) => { TankopediaPersistent.mutate((draft) => {
draft.recentlyViewed = uniq([tank.id, ...draft.recentlyViewed]) draft.recentlyViewed = uniq([tank.id, ...draft.recentlyViewed])
.filter((id) => id in tankDefinitions.tanks) .filter((id) => id in tankDefinitions.tanks)
.slice(0, MAX_RECENTLY_VIEWED); .slice(0, MAX_RECENTLY_VIEWED);
@@ -73,7 +73,7 @@ export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
locale={locale} locale={locale}
className="link" className="link"
underline="hover" underline="hover"
href={provideLink ? `/tanks/${tank.slug}` : '#'} href={provideLink ? `/tanks/${tank.slug}` : "#"}
onClick={(event) => { onClick={(event) => {
if (!provideLink) event.preventDefault(); if (!provideLink) event.preventDefault();
}} }}
@@ -104,9 +104,9 @@ export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
color="gray" color="gray"
align="center" align="center"
style={{ style={{
whiteSpace: 'nowrap', whiteSpace: "nowrap",
overflow: 'hidden', overflow: "hidden",
textOverflow: 'ellipsis', textOverflow: "ellipsis",
}} }}
> >
{discriminator} {discriminator}
@@ -115,5 +115,5 @@ export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
</LinkI18n> </LinkI18n>
</Text> </Text>
); );
}, }
); );
@@ -4,11 +4,10 @@ import {
unionBoundingBox, unionBoundingBox,
type TankDefinition, type TankDefinition,
} from '@blitzkit/core'; } from '@blitzkit/core';
import { useStore } from '@nanostores/react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { awaitableModelDefinitions } from '../../../core/awaitables/modelDefinitions'; import { awaitableModelDefinitions } from '../../../core/awaitables/modelDefinitions';
import { resolveReload } from '../../../core/blitzkit/resolveReload'; import { resolveReload } from '../../../core/blitzkit/resolveReload';
import { $tankopediaSort } from '../../../stores/tankopediaSort'; import { TankSort } from '../../../stores/tankopediaSort';
import { TankCard } from '../../TankCard'; import { TankCard } from '../../TankCard';
interface TankSearchCardProps { interface TankSearchCardProps {
@@ -19,9 +18,9 @@ interface TankSearchCardProps {
const modelDefinitions = await awaitableModelDefinitions; const modelDefinitions = await awaitableModelDefinitions;
export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) { export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
const tankopediaSort = useStore($tankopediaSort); const by = TankSort.use((state) => state.by);
const discriminator = useMemo(() => { const discriminator = useMemo(() => {
if (tankopediaSort.by.startsWith('meta')) return undefined; if (by.startsWith('meta')) return undefined;
const turret = tank.turrets.at(-1)!; const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!; const gun = turret.guns.at(-1)!;
@@ -34,7 +33,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
const turretModelDefinition = tankModelDefinition.turrets[turret.id]; const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id]; const gunModelDefinition = turretModelDefinition.guns[gun.id];
switch (tankopediaSort.by) { switch (by) {
case 'fire.aimTime': case 'fire.aimTime':
return gun.aim_time.toFixed(2); return gun.aim_time.toFixed(2);
case 'fire.caliber': case 'fire.caliber':
@@ -133,7 +132,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
return Math.max(bounds.x, bounds.y, bounds.z).toFixed(0); return Math.max(bounds.x, bounds.y, bounds.z).toFixed(0);
} }
} }
}, [tankopediaSort.by]); }, [by]);
return ( return (
<TankCard <TankCard
@@ -4,8 +4,7 @@ import {
TANK_CLASSES, TANK_CLASSES,
TankType, TankType,
TIER_ROMAN_NUMERALS, TIER_ROMAN_NUMERALS,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { useStore } from '@nanostores/react';
import { import {
Box, Box,
Flex, Flex,
@@ -14,29 +13,22 @@ import {
Link, Link,
Popover, Popover,
Text, Text,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { isEqual, times } from 'lodash-es'; import { isEqual, times } from "lodash-es";
import { awaitableGameDefinitions } from '../../../core/awaitables/gameDefinitions'; import { awaitableGameDefinitions } from "../../../core/awaitables/gameDefinitions";
import { Var } from '../../../core/radix/var'; import { Var } from "../../../core/radix/var";
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from "../../../hooks/useLocale";
import { import { TankFilters } from "../../../stores/tankFilters";
$tankFilters, import { TankSort } from "../../../stores/tankopediaSort";
initialTankFilters, import { classIcons } from "../../ClassIcon";
type TankFilters, import { ExperimentIcon } from "../../ExperimentIcon";
} from '../../../stores/tankFilters'; import { GunAutoloaderIcon } from "../../GunAutoloaderIcon";
import { import { GunAutoreloaderIcon } from "../../GunAutoreloaderIcon";
$tankopediaSort, import { GunRegularIcon } from "../../GunRegularIcon";
initialTankopediaSort, import { MissingShellIcon } from "../../MissingShellIcon";
} from '../../../stores/tankopediaSort'; import { ResearchedIcon } from "../../ResearchedIcon";
import { classIcons } from '../../ClassIcon'; import { ScienceIcon } from "../../ScienceIcon";
import { ExperimentIcon } from '../../ExperimentIcon'; import { ScienceOffIcon } from "../../ScienceOffIcon";
import { GunAutoloaderIcon } from '../../GunAutoloaderIcon';
import { GunAutoreloaderIcon } from '../../GunAutoreloaderIcon';
import { GunRegularIcon } from '../../GunRegularIcon';
import { MissingShellIcon } from '../../MissingShellIcon';
import { ResearchedIcon } from '../../ResearchedIcon';
import { ScienceIcon } from '../../ScienceIcon';
import { ScienceOffIcon } from '../../ScienceOffIcon';
interface FilterControlProps { interface FilterControlProps {
compact?: boolean; compact?: boolean;
@@ -45,29 +37,29 @@ interface FilterControlProps {
const gameDefinitions = await awaitableGameDefinitions; const gameDefinitions = await awaitableGameDefinitions;
const shellTypeIcons: Record<ShellType, string> = { const shellTypeIcons: Record<ShellType, string> = {
[ShellType.AP]: 'ap', [ShellType.AP]: "ap",
[ShellType.APCR]: 'ap_cr', [ShellType.APCR]: "ap_cr",
[ShellType.HE]: 'he', [ShellType.HE]: "he",
[ShellType.HEAT]: 'hc', [ShellType.HEAT]: "hc",
}; };
function ShellFilter({ index, premium }: { index: number; premium?: boolean }) { function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
const tankFilters = useStore($tankFilters); const shells = TankFilters.use((state) => state.shells);
return ( return (
<Popover.Root> <Popover.Root>
<Popover.Trigger> <Popover.Trigger>
<IconButton variant="soft" radius="none" color="gray" highContrast> <IconButton variant="soft" radius="none" color="gray" highContrast>
{tankFilters.shells[index] === null && ( {shells[index] === null && (
<Text color="gray" style={{ display: 'contents' }}> <Text color="gray" style={{ display: "contents" }}>
<MissingShellIcon width="1em" height="1em" /> <MissingShellIcon width="1em" height="1em" />
</Text> </Text>
)} )}
{tankFilters.shells[index] !== null && ( {shells[index] !== null && (
<img <img
style={{ width: '1em', height: '1em' }} style={{ width: "1em", height: "1em" }}
src={asset( src={asset(
`icons/shells/${shellTypeIcons[tankFilters.shells[index]]}${premium ? '_premium' : ''}.webp`, `icons/shells/${shellTypeIcons[shells[index]]}${premium ? "_premium" : ""}.webp`
)} )}
/> />
)} )}
@@ -78,35 +70,33 @@ function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
<Inset> <Inset>
<Flex> <Flex>
{Object.values(ShellType).map((shellType) => { {Object.values(ShellType).map((shellType) => {
if (typeof shellType === 'string') return null; if (typeof shellType === "string") return null;
const selected = tankFilters.shells[index] === shellType; const selected = shells[index] === shellType;
return ( return (
<IconButton <IconButton
key={shellType} key={shellType}
value={`${shellType}`} value={`${shellType}`}
radius="none" radius="none"
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
onClick={() => { onClick={() => {
const mutated = [ const mutated = [...shells] as TankFilters["shells"];
tankFilters.shells[0],
tankFilters.shells[1],
tankFilters.shells[2],
] as TankFilters['shells'];
mutated[index] = selected ? null : shellType; mutated[index] = selected ? null : shellType;
$tankFilters.setKey('shells', mutated); TankFilters.mutate((draft) => {
draft.shells = mutated;
});
}} }}
highContrast={selected} highContrast={selected}
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
> >
<img <img
src={asset( src={asset(
`icons/shells/${shellTypeIcons[shellType]}${premium ? '_premium' : ''}.webp`, `icons/shells/${shellTypeIcons[shellType]}${premium ? "_premium" : ""}.webp`
)} )}
style={{ width: '1em', height: '1em' }} style={{ width: "1em", height: "1em" }}
/> />
</IconButton> </IconButton>
); );
@@ -119,26 +109,26 @@ function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
} }
export function FilterControl({ compact }: FilterControlProps) { export function FilterControl({ compact }: FilterControlProps) {
const tankFilters = useStore($tankFilters); const tankFilters = TankFilters.use();
const { strings } = useLocale(); const { strings } = useLocale();
const clearable = !isEqual(tankFilters, initialTankFilters); const clearable = !isEqual(tankFilters, TankFilters.initial);
return ( return (
<Flex direction="column" align="center"> <Flex direction="column" align="center">
<Flex <Flex
height="fit-content" height="fit-content"
gap="2" gap="2"
align={{ initial: 'start', md: 'center' }} align={{ initial: "start", md: "center" }}
justify="center" justify="center"
> >
<Flex <Flex
flexShrink="0" flexShrink="0"
direction={compact ? 'row' : { initial: 'row', md: 'column' }} direction={compact ? "row" : { initial: "row", md: "column" }}
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-5)' }} style={{ borderRadius: "var(--radius-5)" }}
> >
<Flex <Flex
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{times(5, (index) => { {times(5, (index) => {
const tier = index + 1; const tier = index + 1;
@@ -147,21 +137,19 @@ export function FilterControl({ compact }: FilterControlProps) {
return ( return (
<IconButton <IconButton
key={tier} key={tier}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
radius="none" radius="none"
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.tiers.includes(tier)) { if (tankFilters.tiers.includes(tier)) {
$tankFilters.setKey( TankFilters.mutate((draft) => {
'tiers', draft.tiers = draft.tiers.filter((t) => t !== tier);
tankFilters.tiers.filter((t) => t !== tier), });
);
} else { } else {
$tankFilters.setKey('tiers', [ TankFilters.mutate((draft) => {
...tankFilters.tiers, draft.tiers = [...draft.tiers, tier];
tier, });
]);
} }
}} }}
> >
@@ -172,15 +160,15 @@ export function FilterControl({ compact }: FilterControlProps) {
</Flex> </Flex>
<Box <Box
display={{ initial: 'none', md: 'block' }} display={{ initial: "none", md: "block" }}
style={{ style={{
height: Var('space-1'), height: Var("space-1"),
backgroundColor: Var('gray-a3'), backgroundColor: Var("gray-a3"),
}} }}
/> />
<Flex <Flex
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{times(5, (index) => { {times(5, (index) => {
const tier = index + 6; const tier = index + 6;
@@ -189,21 +177,19 @@ export function FilterControl({ compact }: FilterControlProps) {
return ( return (
<IconButton <IconButton
key={tier} key={tier}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
radius="none" radius="none"
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.tiers.includes(tier)) { if (tankFilters.tiers.includes(tier)) {
$tankFilters.setKey( TankFilters.mutate((draft) => {
'tiers', draft.tiers = draft.tiers.filter((t) => t !== tier);
tankFilters.tiers.filter((t) => t !== tier), });
);
} else { } else {
$tankFilters.setKey('tiers', [ TankFilters.mutate((draft) => {
...tankFilters.tiers, draft.tiers = [...draft.tiers, tier];
tier, });
]);
} }
}} }}
> >
@@ -216,12 +202,12 @@ export function FilterControl({ compact }: FilterControlProps) {
<Flex <Flex
flexShrink="0" flexShrink="0"
direction={compact ? 'row' : { initial: 'row', md: 'column' }} direction={compact ? "row" : { initial: "row", md: "column" }}
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-5)' }} style={{ borderRadius: "var(--radius-5)" }}
> >
<Flex <Flex
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{gameDefinitions.nations {gameDefinitions.nations
.slice(0, Math.ceil(gameDefinitions.nations.length / 2)) .slice(0, Math.ceil(gameDefinitions.nations.length / 2))
@@ -231,26 +217,24 @@ export function FilterControl({ compact }: FilterControlProps) {
return ( return (
<IconButton <IconButton
key={nation} key={nation}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
radius="none" radius="none"
onClick={() => { onClick={() => {
if (tankFilters.nations.includes(nation)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.nations.includes(nation)) {
'nations', draft.nations = draft.nations.filter(
tankFilters.nations.filter((n) => n !== nation), (n) => n !== nation
); );
} else { } else {
$tankFilters.setKey('nations', [ draft.nations = [...draft.nations, nation];
...tankFilters.nations, }
nation, });
]);
}
}} }}
> >
<img <img
style={{ width: '1em', height: '1em' }} style={{ width: "1em", height: "1em" }}
src={asset(`flags/circle/${nation}.webp`)} src={asset(`flags/circle/${nation}.webp`)}
/> />
</IconButton> </IconButton>
@@ -259,15 +243,15 @@ export function FilterControl({ compact }: FilterControlProps) {
</Flex> </Flex>
<Box <Box
display={{ initial: 'none', md: 'block' }} display={{ initial: "none", md: "block" }}
style={{ style={{
height: Var('space-1'), height: Var("space-1"),
backgroundColor: Var('gray-a3'), backgroundColor: Var("gray-a3"),
}} }}
/> />
<Flex <Flex
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{gameDefinitions.nations {gameDefinitions.nations
.slice(Math.ceil(gameDefinitions.nations.length / 2)) .slice(Math.ceil(gameDefinitions.nations.length / 2))
@@ -278,26 +262,24 @@ export function FilterControl({ compact }: FilterControlProps) {
<IconButton <IconButton
key={nation} key={nation}
style={{ flex: 1 }} style={{ flex: 1 }}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
radius="none" radius="none"
onClick={() => { onClick={() => {
if (tankFilters.nations.includes(nation)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.nations.includes(nation)) {
'nations', draft.nations = draft.nations.filter(
tankFilters.nations.filter((n) => n !== nation), (n) => n !== nation
); );
} else { } else {
$tankFilters.setKey('nations', [ draft.nations = [...draft.nations, nation];
...tankFilters.nations, }
nation, });
]);
}
}} }}
> >
<img <img
style={{ width: '1em', height: '1em' }} style={{ width: "1em", height: "1em" }}
src={asset(`flags/circle/${nation}.webp`)} src={asset(`flags/circle/${nation}.webp`)}
/> />
</IconButton> </IconButton>
@@ -307,15 +289,15 @@ export function FilterControl({ compact }: FilterControlProps) {
</Flex> </Flex>
<Flex <Flex
direction={{ initial: 'row', md: 'column' }} direction={compact ? "row" : { initial: "row", md: "column" }}
align="start" align="start"
gap={{ initial: '2', md: '1' }} gap={{ initial: "2", md: "1" }}
> >
<Flex gap="2" align="start"> <Flex gap="2" align="start">
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{TANK_CLASSES.map((tankClass) => { {TANK_CLASSES.map((tankClass) => {
const Icon = classIcons[tankClass]; const Icon = classIcons[tankClass];
@@ -324,25 +306,23 @@ export function FilterControl({ compact }: FilterControlProps) {
return ( return (
<IconButton <IconButton
key={tankClass} key={tankClass}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
radius="none" radius="none"
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.classes.includes(tankClass)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.classes.includes(tankClass)) {
'classes', draft.classes = draft.classes.filter(
tankFilters.classes.filter((c) => c !== tankClass), (c) => c !== tankClass
); );
} else { } else {
$tankFilters.setKey('classes', [ draft.classes = [...draft.classes, tankClass];
...tankFilters.classes, }
tankClass, });
]);
}
}} }}
> >
<Icon style={{ width: '1em', height: '1em' }} /> <Icon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
); );
})} })}
@@ -350,96 +330,90 @@ export function FilterControl({ compact }: FilterControlProps) {
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
<IconButton <IconButton
variant={ variant={
tankFilters.gunType.includes('regular') ? 'solid' : 'soft' tankFilters.gunType.includes("regular") ? "solid" : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.gunType.includes('regular') ? undefined : 'gray' tankFilters.gunType.includes("regular") ? undefined : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.gunType.includes('regular')) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.gunType.includes("regular")) {
'gunType', draft.gunType = draft.gunType.filter(
tankFilters.gunType.filter((t) => t !== 'regular'), (t) => t !== "regular"
); );
} else { } else {
$tankFilters.setKey('gunType', [ draft.gunType = [...draft.gunType, "regular"];
...tankFilters.gunType, }
'regular', });
]);
}
}} }}
> >
<GunRegularIcon style={{ width: '1em', height: '1em' }} /> <GunRegularIcon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
<IconButton <IconButton
variant={ variant={
tankFilters.gunType.includes('auto_loader') ? 'solid' : 'soft' tankFilters.gunType.includes("auto_loader") ? "solid" : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.gunType.includes('auto_loader') tankFilters.gunType.includes("auto_loader")
? undefined ? undefined
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.gunType.includes('auto_loader')) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.gunType.includes("auto_loader")) {
'gunType', draft.gunType = draft.gunType.filter(
tankFilters.gunType.filter((t) => t !== 'auto_loader'), (t) => t !== "auto_loader"
); );
} else { } else {
$tankFilters.setKey('gunType', [ draft.gunType = [...draft.gunType, "auto_loader"];
...tankFilters.gunType, }
'auto_loader', });
]);
}
}} }}
> >
<GunAutoloaderIcon style={{ width: '1em', height: '1em' }} /> <GunAutoloaderIcon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
<IconButton <IconButton
variant={ variant={
tankFilters.gunType.includes('auto_reloader') tankFilters.gunType.includes("auto_reloader")
? 'solid' ? "solid"
: 'soft' : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.gunType.includes('auto_reloader') tankFilters.gunType.includes("auto_reloader")
? undefined ? undefined
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.gunType.includes('auto_reloader')) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.gunType.includes("auto_reloader")) {
'gunType', draft.gunType = draft.gunType.filter(
tankFilters.gunType.filter((t) => t !== 'auto_reloader'), (t) => t !== "auto_reloader"
); );
} else { } else {
$tankFilters.setKey('gunType', [ draft.gunType = [...draft.gunType, "auto_reloader"];
...tankFilters.gunType, }
'auto_reloader', });
]);
}
}} }}
> >
<GunAutoreloaderIcon style={{ width: '1em', height: '1em' }} /> <GunAutoreloaderIcon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
</Flex> </Flex>
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
<ShellFilter index={0} /> <ShellFilter index={0} />
<ShellFilter index={1} premium /> <ShellFilter index={1} premium />
@@ -450,160 +424,158 @@ export function FilterControl({ compact }: FilterControlProps) {
<Flex gap="2" align="start"> <Flex gap="2" align="start">
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
<IconButton <IconButton
variant={ variant={
tankFilters.types?.includes(TankType.RESEARCHABLE) tankFilters.types?.includes(TankType.RESEARCHABLE)
? 'solid' ? "solid"
: 'soft' : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.types?.includes(TankType.RESEARCHABLE) tankFilters.types?.includes(TankType.RESEARCHABLE)
? undefined ? undefined
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.types.includes(TankType.RESEARCHABLE)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.types.includes(TankType.RESEARCHABLE)) {
'types', draft.types = draft.types.filter(
tankFilters.types.filter( (t) => t !== TankType.RESEARCHABLE
(t) => t !== TankType.RESEARCHABLE, );
), } else {
); draft.types = [...draft.types, TankType.RESEARCHABLE];
} else { }
$tankFilters.setKey('types', [ });
...tankFilters.types,
TankType.RESEARCHABLE,
]);
}
}} }}
> >
<ResearchedIcon style={{ width: '1em', height: '1em' }} /> <ResearchedIcon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
<IconButton <IconButton
variant={ variant={
tankFilters.types?.includes(TankType.PREMIUM) tankFilters.types?.includes(TankType.PREMIUM)
? 'solid' ? "solid"
: 'soft' : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.types?.includes(TankType.PREMIUM) tankFilters.types?.includes(TankType.PREMIUM)
? 'amber' ? "amber"
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.types.includes(TankType.PREMIUM)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.types.includes(TankType.PREMIUM)) {
'types', draft.types = draft.types.filter(
tankFilters.types.filter((t) => t !== TankType.PREMIUM), (t) => t !== TankType.PREMIUM
); );
} else { } else {
$tankFilters.setKey('types', [ draft.types = [...draft.types, TankType.PREMIUM];
...tankFilters.types, }
TankType.PREMIUM, });
]);
}
}} }}
> >
<Text <Text
color={ color={
tankFilters.types?.includes(TankType.PREMIUM) tankFilters.types?.includes(TankType.PREMIUM)
? undefined ? undefined
: 'amber' : "amber"
} }
style={{ display: 'flex', justifyContent: 'center' }} style={{ display: "flex", justifyContent: "center" }}
> >
<ResearchedIcon style={{ width: '1em', height: '1em' }} /> <ResearchedIcon style={{ width: "1em", height: "1em" }} />
</Text> </Text>
</IconButton> </IconButton>
<IconButton <IconButton
variant={ variant={
tankFilters.types?.includes(TankType.COLLECTOR) tankFilters.types?.includes(TankType.COLLECTOR)
? 'solid' ? "solid"
: 'soft' : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.types?.includes(TankType.COLLECTOR) tankFilters.types?.includes(TankType.COLLECTOR)
? 'blue' ? "blue"
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.types.includes(TankType.COLLECTOR)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.types.includes(TankType.COLLECTOR)) {
'types', draft.types = draft.types.filter(
tankFilters.types.filter((t) => t !== TankType.COLLECTOR), (t) => t !== TankType.COLLECTOR
); );
} else { } else {
$tankFilters.setKey('types', [ draft.types = [...draft.types, TankType.COLLECTOR];
...tankFilters.types, }
TankType.COLLECTOR, });
]);
}
}} }}
> >
<Text <Text
color={ color={
tankFilters.types?.includes(TankType.COLLECTOR) tankFilters.types?.includes(TankType.COLLECTOR)
? undefined ? undefined
: 'blue' : "blue"
} }
style={{ display: 'flex', justifyContent: 'center' }} style={{ display: "flex", justifyContent: "center" }}
> >
<ResearchedIcon style={{ width: '1em', height: '1em' }} /> <ResearchedIcon style={{ width: "1em", height: "1em" }} />
</Text> </Text>
</IconButton> </IconButton>
</Flex> </Flex>
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
<IconButton <IconButton
variant={tankFilters.testing === 'include' ? 'solid' : 'soft'} variant={tankFilters.testing === "include" ? "solid" : "soft"}
radius="none" radius="none"
color={tankFilters.testing === 'include' ? undefined : 'gray'} color={tankFilters.testing === "include" ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
$tankFilters.setKey('testing', 'include'); TankFilters.mutate((draft) => {
draft.testing = "include";
});
}} }}
> >
<ExperimentIcon <ExperimentIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }} style={{ width: "1em", height: "1em", color: "currentColor" }}
/> />
</IconButton> </IconButton>
<IconButton <IconButton
variant={tankFilters.testing === 'exclude' ? 'solid' : 'soft'} variant={tankFilters.testing === "exclude" ? "solid" : "soft"}
radius="none" radius="none"
color={tankFilters.testing === 'exclude' ? undefined : 'gray'} color={tankFilters.testing === "exclude" ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
$tankFilters.setKey('testing', 'exclude'); TankFilters.mutate((draft) => {
draft.testing = "exclude";
});
}} }}
> >
<ScienceOffIcon <ScienceOffIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }} style={{ width: "1em", height: "1em", color: "currentColor" }}
/> />
</IconButton> </IconButton>
<IconButton <IconButton
variant={tankFilters.testing === 'only' ? 'solid' : 'soft'} variant={tankFilters.testing === "only" ? "solid" : "soft"}
radius="none" radius="none"
color={tankFilters.testing === 'only' ? undefined : 'gray'} color={tankFilters.testing === "only" ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
$tankFilters.setKey('testing', 'only'); TankFilters.mutate((draft) => {
draft.testing = "only";
});
}} }}
> >
<ScienceIcon <ScienceIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }} style={{ width: "1em", height: "1em", color: "currentColor" }}
/> />
</IconButton> </IconButton>
</Flex> </Flex>
@@ -619,8 +591,8 @@ export function FilterControl({ compact }: FilterControlProps) {
href="#" href="#"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
$tankFilters.set(initialTankFilters); TankFilters.set(TankFilters.initial);
$tankopediaSort.set(initialTankopediaSort); TankSort.set(TankSort.initial);
}} }}
> >
{strings.website.common.tank_search.clear_filters} {strings.website.common.tank_search.clear_filters}
@@ -1,27 +1,27 @@
import { Flex, Link, Text } from '@radix-ui/themes'; import { Flex, Link, Text } from "@radix-ui/themes";
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from "../../../hooks/useLocale";
import { $tankFilters, initialTankFilters } from '../../../stores/tankFilters'; import { TankFilters } from "../../../stores/tankFilters";
interface NoResultsProps { interface NoResultsProps {
type?: 'filters' | 'search'; type?: "filters" | "search";
} }
export function NoResults({ type = 'filters' }: NoResultsProps) { export function NoResults({ type = "filters" }: NoResultsProps) {
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
<Flex flexGrow="1" align="center" justify="center"> <Flex flexGrow="1" align="center" justify="center">
<Text color="gray"> <Text color="gray">
{strings.website.common.tank_search.no_tanks_found.body}{' '} {strings.website.common.tank_search.no_tanks_found.body}{" "}
<Link <Link
href="#" href="#"
underline="always" underline="always"
color="red" color="red"
onClick={() => { onClick={() => {
$tankFilters.set(initialTankFilters); TankFilters.set(TankFilters.initial);
}} }}
> >
{type === 'filters' {type === "filters"
? strings.website.common.tank_search.no_tanks_found.clear_filters ? strings.website.common.tank_search.no_tanks_found.clear_filters
: strings.website.common.tank_search.no_tanks_found.search_again} : strings.website.common.tank_search.no_tanks_found.search_again}
</Link> </Link>
@@ -1,20 +1,20 @@
import { type TankDefinition } from '@blitzkit/core'; import { type TankDefinition } from "@blitzkit/core";
import { useStore } from '@nanostores/react'; import { CaretRightIcon } from "@radix-ui/react-icons";
import { CaretRightIcon } from '@radix-ui/react-icons'; import { Button, TextField } from "@radix-ui/themes";
import { Button, TextField } from '@radix-ui/themes'; import { useLocale } from "../../../hooks/useLocale";
import { useLocale } from '../../../hooks/useLocale'; import { TankFilters } from "../../../stores/tankFilters";
import { $tankFilters } from '../../../stores/tankFilters'; import { LinkI18n } from "../../LinkI18n";
import { LinkI18n } from '../../LinkI18n';
interface QuickLinkProps { interface QuickLinkProps {
topResult?: TankDefinition; topResult?: TankDefinition;
} }
export function QuickLink({ topResult }: QuickLinkProps) { export function QuickLink({ topResult }: QuickLinkProps) {
const tankFilters = useStore($tankFilters);
const { locale, unwrap } = useLocale(); const { locale, unwrap } = useLocale();
const search = TankFilters.use((state) => state.search);
const searching = TankFilters.use((state) => state.searching);
if (!tankFilters.search || !topResult || tankFilters.searching) return null; if (!search || !topResult || searching) return null;
return ( return (
<TextField.Slot> <TextField.Slot>
@@ -1,38 +1,36 @@
import { useStore } from '@nanostores/react'; import { Flex, Separator, Text } from "@radix-ui/themes";
import { Flex, Separator, Text } from '@radix-ui/themes'; import { isEqual } from "lodash-es";
import { isEqual } from 'lodash-es'; import { useMemo } from "react";
import { useMemo } from 'react'; import { awaitableTankDefinitions } from "../../../core/awaitables/tankDefinitions";
import { awaitableTankDefinitions } from '../../../core/awaitables/tankDefinitions'; import { useLocale } from "../../../hooks/useLocale";
import { useLocale } from '../../../hooks/useLocale'; import { TankFilters } from "../../../stores/tankFilters";
import { $tankFilters, initialTankFilters } from '../../../stores/tankFilters'; import { TankopediaPersistent } from "../../../stores/tankopediaPersistent";
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent'; import { TankSort } from "../../../stores/tankopediaSort";
import { $tankopediaSort } from '../../../stores/tankopediaSort'; import { TankCard } from "../../TankCard";
import { TankCard } from '../../TankCard'; import { TankCardWrapper } from "./TankCardWrapper";
import { TankCardWrapper } from './TankCardWrapper';
const tankDefinitions = await awaitableTankDefinitions; const tankDefinitions = await awaitableTankDefinitions;
export function RecentlyViewed() { export function RecentlyViewed() {
const tankopediaPersistentStore = TankopediaPersistent.useStore(); const filters = TankFilters.use();
const filters = useStore($tankFilters);
// non-reactive because it is a little weird that it updates instantly even before the page loads // non-reactive because it is a little weird that it updates instantly even before the page loads
const recentlyViewed = tankopediaPersistentStore const recentlyViewed = TankopediaPersistent.state.recentlyViewed.filter(
.getState() (id) => id in tankDefinitions.tanks
.recentlyViewed.filter((id) => id in tankDefinitions.tanks); );
const sort = useStore($tankopediaSort);
const hasFilters = useMemo( const hasFilters = useMemo(
() => () =>
Object.entries(filters).some(([key, value]) => { Object.entries(filters).some(([key, value]) => {
return !isEqual( return !isEqual(
value, value,
initialTankFilters[key as keyof typeof initialTankFilters], TankFilters.initial[key as keyof typeof TankFilters.initial]
); );
}), }),
[filters], [filters]
); );
const { strings } = useLocale(); const { strings } = useLocale();
const by = TankSort.use((state) => state.by);
if (recentlyViewed.length === 0 || sort.by !== 'meta.none' || hasFilters) { if (recentlyViewed.length === 0 || by !== "meta.none" || hasFilters) {
return null; return null;
} }
@@ -1,14 +1,13 @@
import type { TankDefinition } from '@blitzkit/core'; import type { TankDefinition } from "@blitzkit/core";
import { useStore } from '@nanostores/react'; import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; import { Flex, Spinner, TextField } from "@radix-ui/themes";
import { Flex, Spinner, TextField } from '@radix-ui/themes'; import { debounce } from "lodash-es";
import { debounce } from 'lodash-es'; import { type KeyboardEventHandler, useCallback, useRef } from "react";
import { type KeyboardEventHandler, useCallback, useRef } from 'react'; import { useLocale } from "../../../hooks/useLocale";
import { useLocale } from '../../../hooks/useLocale'; import { TankFilters } from "../../../stores/tankFilters";
import { $tankFilters } from '../../../stores/tankFilters'; import type { MaybeSkeletonComponentProps } from "../../../types/maybeSkeletonComponentProps";
import type { MaybeSkeletonComponentProps } from '../../../types/maybeSkeletonComponentProps'; import { QuickLink } from "./QuickLink";
import { QuickLink } from './QuickLink'; import { Sort } from "./Sort";
import { Sort } from './Sort';
type SearchBarProps = MaybeSkeletonComponentProps & { type SearchBarProps = MaybeSkeletonComponentProps & {
topResult?: TankDefinition; topResult?: TankDefinition;
@@ -17,30 +16,37 @@ type SearchBarProps = MaybeSkeletonComponentProps & {
export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) { export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) {
const { strings } = useLocale(); const { strings } = useLocale();
const tankFilters = useStore($tankFilters); const search = TankFilters.use((state) => state.search);
const searching = TankFilters.use((state) => state.searching);
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const performSearch = useCallback( const performSearch = useCallback(
debounce(() => { debounce(() => {
$tankFilters.setKey('searching', false); TankFilters.mutate((draft) => {
draft.searching = false;
});
if (!input.current) return; if (!input.current) return;
const sanitized = input.current.value.trim(); const sanitized = input.current.value.trim();
$tankFilters.setKey('search', sanitized.length === 0 ? null : sanitized); TankFilters.mutate((draft) => {
draft.search = sanitized.length === 0 ? null : sanitized;
});
}, 500), }, 500),
[], []
); );
const handleChange = useCallback(() => { const handleChange = useCallback(() => {
if (!tankFilters.searching) { if (!searching) {
$tankFilters.setKey('searching', true); TankFilters.mutate((draft) => {
draft.searching = true;
});
} }
performSearch(); performSearch();
}, [tankFilters]); }, [searching]);
const handleKeyDown = useCallback<KeyboardEventHandler>( const handleKeyDown = useCallback<KeyboardEventHandler>(
(event) => { (event) => {
if (event.key !== 'Enter' || !topResult || tankFilters.searching) return; if (event.key !== "Enter" || !topResult || searching) return;
event.preventDefault(); event.preventDefault();
@@ -50,7 +56,7 @@ export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) {
window.location.href = `/tanks/${topResult.slug}`; window.location.href = `/tanks/${topResult.slug}`;
} }
}, },
[topResult], [topResult]
); );
return ( return (
@@ -59,7 +65,7 @@ export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) {
<TextField.Root <TextField.Root
variant="classic" variant="classic"
disabled={skeleton} disabled={skeleton}
defaultValue={tankFilters.search ?? undefined} defaultValue={search ?? undefined}
style={{ flex: 1 }} style={{ flex: 1 }}
ref={input} ref={input}
placeholder={strings.website.common.tank_search.search_bar_hint} placeholder={strings.website.common.tank_search.search_bar_hint}
@@ -67,7 +73,7 @@ export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<TextField.Slot> <TextField.Slot>
{tankFilters.searching ? <Spinner /> : <MagnifyingGlassIcon />} {searching ? <Spinner /> : <MagnifyingGlassIcon />}
</TextField.Slot> </TextField.Slot>
<QuickLink topResult={topResult} /> <QuickLink topResult={topResult} />
@@ -1,4 +1,3 @@
import { useStore } from '@nanostores/react';
import { CaretDownIcon } from '@radix-ui/react-icons'; import { CaretDownIcon } from '@radix-ui/react-icons';
import { Button, DropdownMenu } from '@radix-ui/themes'; import { Button, DropdownMenu } from '@radix-ui/themes';
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from '../../../hooks/useLocale';
@@ -6,7 +5,7 @@ import type {
TankopediaSortBy, TankopediaSortBy,
TankopediaSortDirection, TankopediaSortDirection,
} from '../../../stores/tankopediaPersistent'; } from '../../../stores/tankopediaPersistent';
import { $tankopediaSort } from '../../../stores/tankopediaSort'; import { TankSort } from '../../../stores/tankopediaSort';
interface ItemProps { interface ItemProps {
by: TankopediaSortBy; by: TankopediaSortBy;
@@ -18,13 +17,15 @@ interface ItemProps {
function Item({ by }: ItemProps) { function Item({ by }: ItemProps) {
const { strings } = useLocale(); const { strings } = useLocale();
const tankopediaSort = useStore($tankopediaSort); const _by = TankSort.use((state) => state.by);
return ( return (
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={tankopediaSort.by === by} checked={_by === by}
onClick={() => { onClick={() => {
$tankopediaSort.setKey('by', by); TankSort.mutate((draft) => {
draft.by = by;
});
}} }}
> >
{strings.website.common.tank_search.sort[by]} {strings.website.common.tank_search.sort[by]}
@@ -33,7 +34,7 @@ function Item({ by }: ItemProps) {
} }
export function Sort() { export function Sort() {
const tankopediaSort = useStore($tankopediaSort); const direction = TankSort.use((state) => state.direction);
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -119,12 +120,11 @@ export function Sort() {
} }
</DropdownMenu.Label> </DropdownMenu.Label>
<DropdownMenu.RadioGroup <DropdownMenu.RadioGroup
value={tankopediaSort.direction} value={direction}
onValueChange={(value) => { onValueChange={(value) => {
$tankopediaSort.setKey( TankSort.mutate((draft) => {
'direction', draft.direction = value as TankopediaSortDirection;
value as TankopediaSortDirection, });
);
}} }}
> >
<DropdownMenu.RadioItem value="ascending"> <DropdownMenu.RadioItem value="ascending">
@@ -1,13 +1,10 @@
import { Grid, type GridProps } from '@radix-ui/themes'; import { Grid, type GridProps } from "@radix-ui/themes";
export function TankCardWrapper(props: GridProps) { export function TankCardWrapper(props: GridProps) {
return ( return (
<Grid <Grid
py="4" py="4"
columns={{ columns="repeat(auto-fill, minmax(7rem, 1fr))"
initial: 'repeat(auto-fill, minmax(6rem, 1fr))',
sm: 'repeat(auto-fill, minmax(10rem, 1fr))',
}}
flow="dense" flow="dense"
gap="4" gap="4"
gapY="8" gapY="8"
@@ -1,44 +1,43 @@
import { TIER_ROMAN_NUMERALS } from '@blitzkit/core'; import { TIER_ROMAN_NUMERALS } from "@blitzkit/core";
import { useStore } from '@nanostores/react'; import { Flex, IconButton, Text } from "@radix-ui/themes";
import { Flex, IconButton, Text } from '@radix-ui/themes'; import { times } from "lodash-es";
import { times } from 'lodash-es'; import { memo } from "react";
import { memo } from 'react'; import { TankFilters } from "../../../stores/tankFilters";
import { $tankFilters } from '../../../stores/tankFilters'; import { TankSort } from "../../../stores/tankopediaSort";
import { $tankopediaSort } from '../../../stores/tankopediaSort';
export const TierFilter = memo(() => { export const TierFilter = memo(() => {
const tankFilters = useStore($tankFilters); const by = TankSort.use((state) => state.by);
const tankopediaSort = useStore($tankopediaSort); const search = TankFilters.use((state) => state.search);
const tiers = TankFilters.use((state) => state.tiers);
if (tankopediaSort.by !== 'meta.none' || tankFilters.search) return null; if (by !== "meta.none" || search) return null;
return ( return (
<Flex justify="center"> <Flex justify="center">
<Flex <Flex
direction="row" direction="row"
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
> >
{times(10, (index) => { {times(10, (index) => {
const tier = 10 - index; const tier = 10 - index;
const selected = tankFilters.tiers.includes(tier); const selected = tiers.includes(tier);
return ( return (
<IconButton <IconButton
key={tier} key={tier}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
radius="none" radius="none"
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.tiers.includes(tier)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tiers.includes(tier)) {
'tiers', draft.tiers = tiers.filter((t) => t !== tier);
tankFilters.tiers.filter((t) => t !== tier), } else {
); draft.tiers = [...tiers, tier];
} else { }
$tankFilters.setKey('tiers', [...tankFilters.tiers, tier]); });
}
}} }}
> >
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text> <Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
@@ -5,37 +5,36 @@ import {
SEARCH_KEYS, SEARCH_KEYS,
unionBoundingBox, unionBoundingBox,
type TankDefinition, type TankDefinition,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { useStore } from '@nanostores/react'; import { Callout, Flex, Link, Text, type FlexProps } from "@radix-ui/themes";
import { Callout, Flex, Link, Text, type FlexProps } from '@radix-ui/themes'; import fuzzysort from "fuzzysort";
import fuzzysort from 'fuzzysort'; import { times, uniq } from "lodash-es";
import { times, uniq } from 'lodash-es'; import { memo, useEffect, useMemo, useState } from "react";
import { memo, useEffect, useMemo, useState } from 'react'; import { awaitableGameDefinitions } from "../../core/awaitables/gameDefinitions";
import { awaitableGameDefinitions } from '../../core/awaitables/gameDefinitions'; import { awaitableModelDefinitions } from "../../core/awaitables/modelDefinitions";
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions'; import { awaitableTankDefinitions } from "../../core/awaitables/tankDefinitions";
import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions'; import { awaitableTankNames } from "../../core/awaitables/tankNames";
import { awaitableTankNames } from '../../core/awaitables/tankNames'; import { filterTank } from "../../core/blitzkit/filterTank";
import { filterTank } from '../../core/blitzkit/filterTank'; import { resolveReload } from "../../core/blitzkit/resolveReload";
import { resolveReload } from '../../core/blitzkit/resolveReload'; import { useLocale } from "../../hooks/useLocale";
import { useLocale } from '../../hooks/useLocale'; import { TankFilters } from "../../stores/tankFilters";
import { $tankFilters } from '../../stores/tankFilters'; import { TankopediaPersistent } from "../../stores/tankopediaPersistent";
import { TankopediaPersistent } from '../../stores/tankopediaPersistent'; import { SORT_UNITS } from "../../stores/tankopediaPersistent/constants";
import { SORT_UNITS } from '../../stores/tankopediaPersistent/constants'; import { TankSort } from "../../stores/tankopediaSort";
import { $tankopediaSort } from '../../stores/tankopediaSort'; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import { ExperimentIcon } from "../ExperimentIcon";
import { ExperimentIcon } from '../ExperimentIcon'; import { TankSearchCard } from "./components/Card";
import { TankSearchCard } from './components/Card'; import { FilterControl } from "./components/FilterControl";
import { FilterControl } from './components/FilterControl'; import { NoResults } from "./components/NoResults";
import { NoResults } from './components/NoResults'; import { RecentlyViewed } from "./components/RecentlyViewed";
import { RecentlyViewed } from './components/RecentlyViewed'; import { SearchBar } from "./components/SearchBar";
import { SearchBar } from './components/SearchBar'; import { SkeletonTankCard } from "./components/SkeletonTankCard";
import { SkeletonTankCard } from './components/SkeletonTankCard'; import { TankCardWrapper } from "./components/TankCardWrapper";
import { TankCardWrapper } from './components/TankCardWrapper'; import { MAX_RECENTLY_VIEWED } from "./constants";
import { MAX_RECENTLY_VIEWED } from './constants';
type TankSearchProps = MaybeSkeletonComponentProps & export type TankSearchProps = MaybeSkeletonComponentProps &
Omit<FlexProps, 'onSelect'> & { Omit<FlexProps, "onSelect"> & {
compact?: boolean; compact?: boolean;
onSelect?: (tank: TankDefinition) => void; onSelect?: (tank: TankDefinition) => void;
onSelectAll?: (tanks: TankDefinition[]) => void; onSelectAll?: (tanks: TankDefinition[]) => void;
@@ -55,23 +54,23 @@ const [gameDefinitions, modelDefinitions, tankDefinitions, tankNames] =
export const TankSearch = memo<TankSearchProps>( export const TankSearch = memo<TankSearchProps>(
({ compact, onSelect, onSelectAll, skeleton, ...props }) => { ({ compact, onSelect, onSelectAll, skeleton, ...props }) => {
const { strings, locale } = useLocale(); const { strings, locale } = useLocale();
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const awaitedTanksDefinitionsArray = Object.values(tankDefinitions.tanks); const awaitedTanksDefinitionsArray = Object.values(tankDefinitions.tanks);
const tankFilters = useStore($tankFilters); const tankopediaSort = TankSort.use();
const tankopediaSort = useStore($tankopediaSort); const tankFilters = TankFilters.use();
const tanksFiltered = useMemo(() => { const tanksFiltered = useMemo(() => {
if (tankFilters.search === null) { if (tankFilters.search === null) {
const filtered = awaitedTanksDefinitionsArray.filter((tank) => const filtered = awaitedTanksDefinitionsArray.filter((tank) =>
filterTank(tankFilters, tank), filterTank(tankFilters, tank)
); );
let sorted: TankDefinition[]; let sorted: TankDefinition[];
switch (tankopediaSort.by) { switch (tankopediaSort.by) {
case 'meta.none': case "meta.none":
sorted = metaSortTank(filtered, gameDefinitions); sorted = metaSortTank(filtered, gameDefinitions);
break; break;
case 'survivability.health': { case "survivability.health": {
sorted = filtered.sort((a, b) => { sorted = filtered.sort((a, b) => {
const aHealth = a.health + a.turrets.at(-1)!.health; const aHealth = a.health + a.turrets.at(-1)!.health;
const bHealth = b.health + b.turrets.at(-1)!.health; const bHealth = b.health + b.turrets.at(-1)!.health;
@@ -81,36 +80,36 @@ export const TankSearch = memo<TankSearchProps>(
break; break;
} }
case 'survivability.viewRange': case "survivability.viewRange":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.view_range - b.turrets.at(-1)!.view_range, a.turrets.at(-1)!.view_range - b.turrets.at(-1)!.view_range
); );
break; break;
case 'survivability.camouflageStill': case "survivability.camouflageStill":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.camouflage_still - b.camouflage_still, (a, b) => a.camouflage_still - b.camouflage_still
); );
break; break;
case 'survivability.camouflageMoving': case "survivability.camouflageMoving":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.camouflage_moving - b.camouflage_moving, (a, b) => a.camouflage_moving - b.camouflage_moving
); );
break; break;
case 'survivability.camouflageShooting': case "survivability.camouflageShooting":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.camouflage_still * a.camouflage_still *
a.turrets.at(-1)!.guns.at(-1)!.camouflage_loss - a.turrets.at(-1)!.guns.at(-1)!.camouflage_loss -
b.camouflage_still * b.camouflage_still *
b.turrets.at(-1)!.guns.at(-1)!.camouflage_loss, b.turrets.at(-1)!.guns.at(-1)!.camouflage_loss
); );
break; break;
case 'survivability.volume': case "survivability.volume":
sorted = filtered.sort((a, b) => { sorted = filtered.sort((a, b) => {
const aTankModelDefinition = modelDefinitions.models[a.id]; const aTankModelDefinition = modelDefinitions.models[a.id];
const bTankModelDefinition = modelDefinitions.models[b.id]; const bTankModelDefinition = modelDefinitions.models[b.id];
@@ -121,14 +120,14 @@ export const TankSearch = memo<TankSearchProps>(
const aSize = normalizeBoundingBox( const aSize = normalizeBoundingBox(
unionBoundingBox( unionBoundingBox(
aTankModelDefinition.bounding_box, aTankModelDefinition.bounding_box,
aTurretModelDefinition.bounding_box, aTurretModelDefinition.bounding_box
), )
); );
const bSize = normalizeBoundingBox( const bSize = normalizeBoundingBox(
unionBoundingBox( unionBoundingBox(
bTankModelDefinition.bounding_box, bTankModelDefinition.bounding_box,
bTurretModelDefinition.bounding_box, bTurretModelDefinition.bounding_box
), )
); );
const aVolume = aSize.x * aSize.y * aSize.z; const aVolume = aSize.x * aSize.y * aSize.z;
const bVolume = bSize.x * bSize.y * bSize.z; const bVolume = bSize.x * bSize.y * bSize.z;
@@ -137,7 +136,7 @@ export const TankSearch = memo<TankSearchProps>(
}); });
break; break;
case 'survivability.length': case "survivability.length":
sorted = filtered.sort((a, b) => { sorted = filtered.sort((a, b) => {
const aTankModelDefinition = modelDefinitions.models[a.id]; const aTankModelDefinition = modelDefinitions.models[a.id];
const bTankModelDefinition = modelDefinitions.models[b.id]; const bTankModelDefinition = modelDefinitions.models[b.id];
@@ -148,14 +147,14 @@ export const TankSearch = memo<TankSearchProps>(
const aSize = normalizeBoundingBox( const aSize = normalizeBoundingBox(
unionBoundingBox( unionBoundingBox(
aTankModelDefinition.bounding_box, aTankModelDefinition.bounding_box,
aTurretModelDefinition.bounding_box, aTurretModelDefinition.bounding_box
), )
); );
const bSize = normalizeBoundingBox( const bSize = normalizeBoundingBox(
unionBoundingBox( unionBoundingBox(
bTankModelDefinition.bounding_box, bTankModelDefinition.bounding_box,
bTurretModelDefinition.bounding_box, bTurretModelDefinition.bounding_box
), )
); );
const aLength = Math.max(aSize.x, aSize.y, aSize.z); const aLength = Math.max(aSize.x, aSize.y, aSize.z);
const bLength = Math.max(bSize.x, bSize.y, bSize.z); const bLength = Math.max(bSize.x, bSize.y, bSize.z);
@@ -164,147 +163,147 @@ export const TankSearch = memo<TankSearchProps>(
}); });
break; break;
case 'fire.dpm': case "fire.dpm":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
resolveDpm( resolveDpm(
a.turrets.at(-1)!.guns.at(-1)!, a.turrets.at(-1)!.guns.at(-1)!,
a.turrets.at(-1)!.guns.at(-1)!.shells[0], a.turrets.at(-1)!.guns.at(-1)!.shells[0]
) - ) -
resolveDpm( resolveDpm(
b.turrets.at(-1)!.guns.at(-1)!, b.turrets.at(-1)!.guns.at(-1)!,
b.turrets.at(-1)!.guns.at(-1)!.shells[0], b.turrets.at(-1)!.guns.at(-1)!.shells[0]
), )
); );
break; break;
case 'fire.dpmPremium': case "fire.dpmPremium":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
(a.turrets.at(-1)!.guns.at(-1)!.shells[1] (a.turrets.at(-1)!.guns.at(-1)!.shells[1]
? resolveDpm( ? resolveDpm(
a.turrets.at(-1)!.guns.at(-1)!, a.turrets.at(-1)!.guns.at(-1)!,
a.turrets.at(-1)!.guns.at(-1)!.shells[1], a.turrets.at(-1)!.guns.at(-1)!.shells[1]
) )
: 0) - : 0) -
(b.turrets.at(-1)!.guns.at(-1)!.shells[1] (b.turrets.at(-1)!.guns.at(-1)!.shells[1]
? resolveDpm( ? resolveDpm(
b.turrets.at(-1)!.guns.at(-1)!, b.turrets.at(-1)!.guns.at(-1)!,
b.turrets.at(-1)!.guns.at(-1)!.shells[1], b.turrets.at(-1)!.guns.at(-1)!.shells[1]
) )
: 0), : 0)
); );
break; break;
case 'fire.reload': case "fire.reload":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
resolveReload(a.turrets.at(-1)!.guns.at(-1)!) - resolveReload(a.turrets.at(-1)!.guns.at(-1)!) -
resolveReload(b.turrets.at(-1)!.guns.at(-1)!), resolveReload(b.turrets.at(-1)!.guns.at(-1)!)
); );
break; break;
case 'fire.caliber': case "fire.caliber":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber - a.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber, b.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber
); );
break; break;
case 'fire.standardPenetration': case "fire.standardPenetration":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near - a.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near, b.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near
); );
break; break;
case 'fire.premiumPenetration': case "fire.premiumPenetration":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
(a.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ?? (a.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ??
0) - 0) -
(b.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ?? (b.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ??
0), 0)
); );
break; break;
case 'fire.tertiaryPenetration': case "fire.tertiaryPenetration":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
(a.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ?? (a.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ??
0) - 0) -
(b.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ?? (b.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ??
0), 0)
); );
break; break;
case 'fire.damage': case "fire.damage":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage - a.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage, b.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage
); );
break; break;
case 'fire.moduleDamage': case "fire.moduleDamage":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage - a.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage, b.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage
); );
break; break;
case 'fire.shellVelocity': case "fire.shellVelocity":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity - a.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity, b.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity
); );
break; break;
case 'fire.shellRange': case "fire.shellRange":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].range - a.turrets.at(-1)!.guns.at(-1)!.shells[0].range -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].range, b.turrets.at(-1)!.guns.at(-1)!.shells[0].range
); );
break; break;
case 'fire.shellCapacity': case "fire.shellCapacity":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shell_capacity - a.turrets.at(-1)!.guns.at(-1)!.shell_capacity -
b.turrets.at(-1)!.guns.at(-1)!.shell_capacity, b.turrets.at(-1)!.guns.at(-1)!.shell_capacity
); );
break; break;
case 'fire.aimTime': case "fire.aimTime":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.aim_time - a.turrets.at(-1)!.guns.at(-1)!.aim_time -
b.turrets.at(-1)!.guns.at(-1)!.aim_time, b.turrets.at(-1)!.guns.at(-1)!.aim_time
); );
break; break;
case 'fire.dispersionStill': case "fire.dispersionStill":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.dispersion_base - a.turrets.at(-1)!.guns.at(-1)!.dispersion_base -
b.turrets.at(-1)!.guns.at(-1)!.dispersion_base, b.turrets.at(-1)!.guns.at(-1)!.dispersion_base
); );
break; break;
case 'fire.dispersionMoving': case "fire.dispersionMoving":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.tracks.at(-1)!.dispersion_move - a.tracks.at(-1)!.dispersion_move -
b.tracks.at(-1)!.dispersion_move, b.tracks.at(-1)!.dispersion_move
); );
break; break;
case 'fire.gunDepression': case "fire.gunDepression":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id] modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id]
@@ -314,11 +313,11 @@ export const TankSearch = memo<TankSearchProps>(
modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id] modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id]
.guns[b.turrets.at(-1)!.guns.at(-1)!.id].pitch.max - .guns[b.turrets.at(-1)!.guns.at(-1)!.id].pitch.max -
(modelDefinitions.models[b.id].initial_turret_rotation?.pitch ?? (modelDefinitions.models[b.id].initial_turret_rotation?.pitch ??
0), 0)
); );
break; break;
case 'fire.gunElevation': case "fire.gunElevation":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id] modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id]
@@ -328,29 +327,29 @@ export const TankSearch = memo<TankSearchProps>(
modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id] modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id]
.guns[a.turrets.at(-1)!.guns.at(-1)!.id].pitch.min - .guns[a.turrets.at(-1)!.guns.at(-1)!.id].pitch.min -
(modelDefinitions.models[a.id].initial_turret_rotation?.pitch ?? (modelDefinitions.models[a.id].initial_turret_rotation?.pitch ??
0), 0)
); );
break; break;
case 'maneuverability.forwardsSpeed': case "maneuverability.forwardsSpeed":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.speed_forwards - b.speed_forwards, (a, b) => a.speed_forwards - b.speed_forwards
); );
break; break;
case 'maneuverability.backwardsSpeed': case "maneuverability.backwardsSpeed":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.speed_backwards - b.speed_backwards, (a, b) => a.speed_backwards - b.speed_backwards
); );
break; break;
case 'maneuverability.power': case "maneuverability.power":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.engines.at(-1)!.power - b.engines.at(-1)!.power, (a, b) => a.engines.at(-1)!.power - b.engines.at(-1)!.power
); );
break; break;
case 'maneuverability.powerToWeight': case "maneuverability.powerToWeight":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.engines.at(-1)!.power / a.engines.at(-1)!.power /
@@ -364,11 +363,11 @@ export const TankSearch = memo<TankSearchProps>(
b.engines.at(-1)!.weight + b.engines.at(-1)!.weight +
b.tracks.at(-1)!.weight + b.tracks.at(-1)!.weight +
b.turrets.at(-1)!.weight + b.turrets.at(-1)!.weight +
b.turrets.at(-1)!.guns.at(-1)!.weight), b.turrets.at(-1)!.guns.at(-1)!.weight)
); );
break; break;
case 'maneuverability.weight': case "maneuverability.weight":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.weight + a.weight +
@@ -380,20 +379,20 @@ export const TankSearch = memo<TankSearchProps>(
b.engines.at(-1)!.weight + b.engines.at(-1)!.weight +
b.tracks.at(-1)!.weight + b.tracks.at(-1)!.weight +
b.turrets.at(-1)!.weight + b.turrets.at(-1)!.weight +
b.turrets.at(-1)!.guns.at(-1)!.weight), b.turrets.at(-1)!.guns.at(-1)!.weight)
); );
break; break;
case 'maneuverability.traverseSpeed': case "maneuverability.traverseSpeed":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.tracks.at(-1)!.traverse_speed - a.tracks.at(-1)!.traverse_speed -
b.tracks.at(-1)!.traverse_speed, b.tracks.at(-1)!.traverse_speed
); );
break; break;
} }
return tankopediaSort.direction === 'ascending' return tankopediaSort.direction === "ascending"
? sorted ? sorted
: sorted.reverse(); : sorted.reverse();
} else { } else {
@@ -401,7 +400,7 @@ export const TankSearch = memo<TankSearchProps>(
keys: SEARCH_KEYS, keys: SEARCH_KEYS,
}); });
const searchedTanks = searchedRaw.map( const searchedTanks = searchedRaw.map(
(result) => tankDefinitions.tanks[result.obj.id], (result) => tankDefinitions.tanks[result.obj.id]
); );
return searchedTanks; return searchedTanks;
} }
@@ -444,7 +443,7 @@ export const TankSearch = memo<TankSearchProps>(
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
onSelectAll(tanksFiltered); onSelectAll(tanksFiltered);
mutateTankopediaPersistent((draft) => { TankopediaPersistent.mutate((draft) => {
draft.recentlyViewed = uniq([ draft.recentlyViewed = uniq([
...tanksFiltered.map(({ id }) => id), ...tanksFiltered.map(({ id }) => id),
...draft.recentlyViewed, ...draft.recentlyViewed,
@@ -459,24 +458,24 @@ export const TankSearch = memo<TankSearchProps>(
)} )}
</Flex> </Flex>
{tankopediaSort.by !== 'meta.none' && ( {tankopediaSort.by !== "meta.none" && (
<Text color="gray"> <Text color="gray">
{literals(strings.website.common.tank_search.sorting_by, [ {literals(strings.website.common.tank_search.sorting_by, [
strings.website.common.tank_search.sort[tankopediaSort.by], strings.website.common.tank_search.sort[tankopediaSort.by],
])} ])}
{SORT_UNITS[tankopediaSort.by] === undefined {SORT_UNITS[tankopediaSort.by] === undefined
? '' ? ""
: ` (${SORT_UNITS[tankopediaSort.by]})`} : ` (${SORT_UNITS[tankopediaSort.by]})`}
, {tankopediaSort.direction} , {tankopediaSort.direction}
</Text> </Text>
)} )}
</Flex> </Flex>
{tankFilters.testing === 'only' && ( {tankFilters.testing === "only" && (
<Flex justify="center" mt="4"> <Flex justify="center" mt="4">
<Callout.Root color="amber"> <Callout.Root color="amber">
<Callout.Icon> <Callout.Icon>
<ExperimentIcon style={{ width: '1em', height: '1em' }} /> <ExperimentIcon style={{ width: "1em", height: "1em" }} />
</Callout.Icon> </Callout.Icon>
<Callout.Text> <Callout.Text>
{strings.website.common.warnings.test} {strings.website.common.warnings.test}
@@ -505,12 +504,12 @@ export const TankSearch = memo<TankSearchProps>(
key={index} key={index}
onIntersection={() => { onIntersection={() => {
setLoadedCards((state) => setLoadedCards((state) =>
Math.min(state + 2, tanksFiltered.length), Math.min(state + 2, tanksFiltered.length)
); );
}} }}
/> />
); );
}, }
)} )}
</TankCardWrapper> </TankCardWrapper>
)} )}
@@ -523,15 +522,15 @@ export const TankSearch = memo<TankSearchProps>(
<TankCardWrapper> <TankCardWrapper>
{times( {times(
Math.round( Math.round(
skeleton ? DEFAULT_LOADED_CARDS : 10 + 10 * Math.random(), skeleton ? DEFAULT_LOADED_CARDS : 10 + 10 * Math.random()
), ),
(index) => ( (index) => (
<SkeletonTankCard key={index} /> <SkeletonTankCard key={index} />
), )
)} )}
</TankCardWrapper> </TankCardWrapper>
)} )}
</Flex> </Flex>
); );
}, }
); );
@@ -1,24 +1,23 @@
import { assertSecret, fetchTankDefinitions } from '@blitzkit/core'; import { assertSecret, fetchTankDefinitions } from "@blitzkit/core";
import { MinusCircledIcon } from '@radix-ui/react-icons'; import { MinusCircledIcon } from "@radix-ui/react-icons";
import { Callout, Flex } from '@radix-ui/themes'; import { Callout, Flex } from "@radix-ui/themes";
import { uniq } from 'lodash-es'; import { uniq } from "lodash-es";
import { useEffect } from 'react'; import { useEffect } from "react";
import { Duel } from '../../stores/duel'; import { Duel } from "../../stores/duel";
import { TankopediaPersistent } from '../../stores/tankopediaPersistent'; import { TankopediaPersistent } from "../../stores/tankopediaPersistent";
import { AesonPlug } from '../AesonPlug'; import { AesonPlug } from "../AesonPlug";
import { ExperimentIcon } from '../ExperimentIcon'; import { ExperimentIcon } from "../ExperimentIcon";
import { MAX_RECENTLY_VIEWED } from '../TankSearch/constants'; import { MAX_RECENTLY_VIEWED } from "../TankSearch/constants";
const tankDefinitions = await fetchTankDefinitions(); const tankDefinitions = await fetchTankDefinitions();
export function CalloutsSection() { export function CalloutsSection() {
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const promoteAeson = const promoteAeson =
assertSecret(import.meta.env.PUBLIC_BRANCH) === 'preview'; assertSecret(import.meta.env.PUBLIC_BRANCH) === "preview";
useEffect(() => { useEffect(() => {
mutateTankopediaPersistent((draft) => { TankopediaPersistent.mutate((draft) => {
draft.recentlyViewed = uniq([tank.id, ...draft.recentlyViewed]) draft.recentlyViewed = uniq([tank.id, ...draft.recentlyViewed])
.filter((id) => id in tankDefinitions.tanks) .filter((id) => id in tankDefinitions.tanks)
.slice(0, MAX_RECENTLY_VIEWED); .slice(0, MAX_RECENTLY_VIEWED);
@@ -45,7 +44,7 @@ export function CalloutsSection() {
{tank.testing && ( {tank.testing && (
<Callout.Root color="red"> <Callout.Root color="red">
<Callout.Icon> <Callout.Icon>
<ExperimentIcon style={{ width: '1em', height: '1em' }} /> <ExperimentIcon style={{ width: "1em", height: "1em" }} />
</Callout.Icon> </Callout.Icon>
<Callout.Text> <Callout.Text>
Tanks in testing are subject to change and may not represent the Tanks in testing are subject to change and may not represent the
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff. Show more