Compare commits

..
53 Commits
Author SHA1 Message Date
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
176 changed files with 4138 additions and 4335 deletions

No files matched your search

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

Before

Width:  |  Height:  |  Size: 490 KiB

After

Width:  |  Height:  |  Size: 669 KiB

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