Merge branch 'main' into dev-embed

This commit is contained in:
TrèsAbhi committed 2024-08-26 22:02:10 -05:00
commit e99b125517
120 files changed
+3253 -2798

No files matched your search

+6 -17
View File
@@ -13,7 +13,7 @@ concurrency:
jobs: jobs:
build: build:
runs-on: windows-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -29,23 +29,12 @@ jobs:
run: bun scripts/setupDepotDownloader.ts run: bun scripts/setupDepotDownloader.ts
- name: Run DepotDownloader - name: Run DepotDownloader
run: ./temp/depotDownloader/DepotDownloader.exe -os windows -app 444200 -username ${{ secrets.DEPOT_DOWNLOADER_USERNAME }} -password "${{ secrets.DEPOT_DOWNLOADER_PASSWORD }}" run: |
chmod +x ./temp/depotDownloader/DepotDownloader
./temp/depotDownloader/DepotDownloader -os windows -app 444200 -username ${{ secrets.DEPOT_DOWNLOADER_USERNAME }} -password "${{ secrets.DEPOT_DOWNLOADER_PASSWORD }}"
- name: Apply patches - name: Apply patches
run: | run: DISCORD_CLIENT_ID=${{ secrets.DISCORD_CLIENT_ID }} GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} WOTB_DLC_CDN=${{ secrets.WOTB_DLC_CDN }} bun scripts/applyPatches.ts --depot
$env:DISCORD_CLIENT_ID = "${{ secrets.DISCORD_CLIENT_ID }}"
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
$env:WOTB_DLC_CDN = "${{ secrets.WOTB_DLC_CDN }}"
bun scripts/applyPatches.ts --depot
- name: Build assets - name: Build assets
run: | run: DISCORD_CLIENT_ID=${{ secrets.DISCORD_CLIENT_ID }} GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} VIDEOS_SHEET=${{ secrets.VIDEOS_SHEET }} GOOGLE_APPLICATION_CREDENTIALS=${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} bun build:assets --all-targets --depot --production
$env:DISCORD_CLIENT_ID = "${{ secrets.DISCORD_CLIENT_ID }}"
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
$env:VIDEOS_SHEET = "${{ secrets.VIDEOS_SHEET }}"
$env:GOOGLE_APPLICATION_CREDENTIALS = "${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}"
bun build:assets --all-targets --depot --production
+24
View File
@@ -0,0 +1,24 @@
name: Reviews
on:
workflow_dispatch:
schedule:
- cron: 0 0 * * 2
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- name: Install dependencies
run: bun install
- name: Find reviews
run: DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} GH_TOKEN=${{ secrets.GH_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} bun scripts/reviews.ts
+1 -2
View File
@@ -55,7 +55,6 @@
"DIDS", "DIDS",
"donotremove", "donotremove",
"drei", "drei",
"Droodles",
"dvpd", "dvpd",
"dvpl", "dvpl",
"DVPLCRC", "DVPLCRC",
@@ -68,7 +67,6 @@
"Fullscreen", "Fullscreen",
"fullstats", "fullstats",
"fuzzysort", "fuzzysort",
"gamemode",
"gameplay", "gameplay",
"glsl", "glsl",
"GLTF", "GLTF",
@@ -190,6 +188,7 @@
"XMLDVPL", "XMLDVPL",
"YAMLDVPL", "YAMLDVPL",
"yolo", "yolo",
"youtuber",
"youtubers", "youtubers",
"zustand" "zustand"
], ],
+3 -3
View File
@@ -12,7 +12,7 @@ BlitzKit is a one man band **TrèsAbhi** but I am far from solo.
| Game file layout | Aeson | | Game file layout | Aeson |
| 3-caliber rule edge cases | Minitelrose, ChickenMan7777 | | 3-caliber rule edge cases | Minitelrose, ChickenMan7777 |
| Embed UX | \_34, HisRoyalFatness, Synx | | Embed UX | \_34, HisRoyalFatness, Synx |
| Tankopedia pre-release testing | [OPTML], Aeson, \_34, Droodles | | Tankopedia pre-release testing | [OPTML], Aeson, \_34 |
| Website advertisement platform | Aeson, Droodles | | Website advertisement platform | Aeson |
| Compare pre-release testing | [OPTML], Droodles | | Compare pre-release testing | [OPTML] |
| Redacted | GonnaHetzMe | | Redacted | GonnaHetzMe |
+19
View File
@@ -0,0 +1,19 @@
# BlitzKit 1.22.1
## Features
- Legendary camouflage names are now included in the Tankopedia search (for example, you can search up "Shark" and the T54E2 will show up)
- Provision and consumable buttons in the Tankopedia and Compare tools are now separate buttons
## Fixes
- The name and icons of tanks in the Tankopedia search page are now properly aligned and trimmed
- The Tankopedia search is now stricter; thus, filters out nonsensical results
- The most optimal shell index once again shows the correct index
- The DPM listing is now in parity with the game which is what "Max DPM" used to be previously
- The Max DPM value is now also correct since the optimal shell index has been corrected
- The mastery of crew members who play a secondary role in the tank is now handled properly
- The turret of tanks cannot be accidentally rotated when dragging over other objects (like the body of the tank)
- Penetrations are once again calculated after a ricochet
- Camouflage values are now calculated properly when the Camouflage Net equipment is applied
- Removed the redundant Configure tab when selecting an antagonist tank
+38
View File
@@ -0,0 +1,38 @@
# BlitzKit 1.22.2
This update packs a punch.
## Features
- Added "Game mode abilities" section to the tankopedia pages
- Shows the game mode provisions and consumables underneath the corresponding mode's banner
- Reworked the tech tree section
- Now shows the whole line and if multiple paths exist, they are can be selected
- The selected tank is now highlighted with the flag of the corresponding country
- This section now also shows the total required XP and credits required
- Added a battle count which indicates the number of games required to reach the next tank (not including module upgrades) based on average xp earnings of players
- XP multipliers can also be applied to get a sense of how many battles you will really have to play
- Reworked the "Review videos" section
- Removed all previews videos; we will be starting from scratch
- Added names of content creators under their corresponding videos
- Added a way for YouTubers to apply to be a part of the review videos section
- Reworked the navbar
- This are a lot more compact and contrastive
- Tool names are now separated with vertical lines now
- The hamburger menu now looks more like the homepage cards
- Added a warning for test tanks
- Switched to on-demand rendering
- This makes it so that the canvas only renders upon interaction and not the continuously synced with the screen's refresh rate
- Added the meta information section to the tankopedia pages
- Lists the full name, nation, tier, class, type, restoration, purchase, and sale prices
- Added tooltips to a lot of places
- Reduced ads on the tankopedia pages
- Added a screenshot button to take screenshots of the tank
- Added crew training slider to the compare tool
## Fixes
- Invalid Wargaming logins are now handled properly
- Season currencies are now displayed properly
- Tank filters are now persistent but will be reset on refresh
- The search for a tank now populates the search bar when navigating back to the search page
+16
View File
@@ -0,0 +1,16 @@
# BlitzKit 1.22.3
This should've been in the last update lol.
## Features
- The "meta information" section has been moved to the hero section on larger screens
- Added an option to take a screenshot directly to the clipboard
- Removed the number from the BlitzKit logo
- The tank playground now stops rendering when it is offscreen, hence fixing a lot of crashes
## Fixes
- The hero section of the Tankopedia is now better sized on smaller screens
- The shell penetration over distance slider now instantly updates the corresponding input instead after a delay
- The game mode banner info is now visible on Apple devices
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "blitzkit", "name": "blitzkit",
"version": "1.22.0", "version": "1.22.3",
"description": "🎉 Tools for everything World of Tanks Blitz", "description": "🎉 Tools for everything World of Tanks Blitz",
"main": "src/server.ts", "main": "src/server.ts",
"type": "module", "type": "module",
+1 -1
View File
@@ -1 +1 @@
["1.10.0","1.10.1","1.10.2","1.11.0","1.12.0","1.12.1","1.12.2","1.12.3","1.12.4","1.12.5","1.12.6","1.12.7","1.12.8","1.12.9","1.13.0","1.13.1","1.13.2","1.13.3","1.14.0","1.14.1","1.14.2","1.14.3","1.14.4","1.15.0","1.15.1","1.15.10","1.15.11","1.15.12","1.15.13","1.15.2","1.15.3","1.15.4","1.15.5","1.15.6","1.15.7","1.15.8","1.15.9","1.16.0","1.16.1","1.16.2","1.16.3","1.16.4","1.17.0","1.17.1","1.17.2","1.18.0","1.18.1","1.18.2","1.18.3","1.19.0","1.19.1","1.19.2","1.19.3","1.19.4","1.19.5","1.19.6","1.20.0","1.20.1","1.20.2","1.20.3","1.20.4","1.20.5","1.21.0","1.21.1","1.21.2","1.21.3","1.22.0","1.3.0","1.3.1","1.3.2","1.3.3","1.4.0","1.4.1","1.5.0","1.5.1","1.5.2","1.5.3","1.5.4","1.6.0","1.7.0","1.7.1","1.7.2","1.7.3-1.7.4","1.7.5","1.7.6","1.7.7","1.8.0","1.8.1","1.9.0","1.9.1","1.9.2","1.9.3","1.9.4","1.9.5"] ["1.10.0","1.10.1","1.10.2","1.11.0","1.12.0","1.12.1","1.12.2","1.12.3","1.12.4","1.12.5","1.12.6","1.12.7","1.12.8","1.12.9","1.13.0","1.13.1","1.13.2","1.13.3","1.14.0","1.14.1","1.14.2","1.14.3","1.14.4","1.15.0","1.15.1","1.15.10","1.15.11","1.15.12","1.15.13","1.15.2","1.15.3","1.15.4","1.15.5","1.15.6","1.15.7","1.15.8","1.15.9","1.16.0","1.16.1","1.16.2","1.16.3","1.16.4","1.17.0","1.17.1","1.17.2","1.18.0","1.18.1","1.18.2","1.18.3","1.19.0","1.19.1","1.19.2","1.19.3","1.19.4","1.19.5","1.19.6","1.20.0","1.20.1","1.20.2","1.20.3","1.20.4","1.20.5","1.21.0","1.21.1","1.21.2","1.21.3","1.22.0","1.22.1","1.22.2","1.3.0","1.3.1","1.3.2","1.3.3","1.4.0","1.4.1","1.5.0","1.5.1","1.5.2","1.5.3","1.5.4","1.6.0","1.7.0","1.7.1","1.7.2","1.7.3-1.7.4","1.7.5","1.7.6","1.7.7","1.8.0","1.8.1","1.9.0","1.9.1","1.9.2","1.9.3","1.9.4","1.9.5"]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

+17
View File
@@ -0,0 +1,17 @@
syntax = "proto3";
package blitzkit;
message Reviews {
map<uint32, Review> reviews = 1;
}
message Review {
float last_updated = 1;
repeated Video videos = 2;
}
message Video {
string id = 1;
string author = 2;
}
+28 -1
View File
@@ -1,7 +1,9 @@
import { mkdir, writeFile } from 'fs/promises'; import { mkdir, writeFile } from 'fs/promises';
import { parse as parsePath } from 'path'; import { parse as parsePath } from 'path';
import { parse as parseYaml } from 'yaml'; import ProgressBar from 'progress';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { readStringDVPL } from '../src/core/blitz/readStringDVPL'; import { readStringDVPL } from '../src/core/blitz/readStringDVPL';
import { readYAMLDVPL } from '../src/core/blitz/readYAMLDVPL';
import { writeDVPL } from '../src/core/blitz/writeDVPL'; import { writeDVPL } from '../src/core/blitz/writeDVPL';
import { secrets } from '../src/core/blitzkit/secrets'; import { secrets } from '../src/core/blitzkit/secrets';
import { dvp } from '../submodules/blitzkit-closed/src/dvp'; import { dvp } from '../submodules/blitzkit-closed/src/dvp';
@@ -33,6 +35,10 @@ while (true) {
`${secrets.WOTB_DLC_CDN}/dlc/${data.dx11.replace('.dvpm', '.dvpd')}`, `${secrets.WOTB_DLC_CDN}/dlc/${data.dx11.replace('.dvpm', '.dvpd')}`,
).then((response) => response.arrayBuffer()); ).then((response) => response.arrayBuffer());
const files = await dvp(dvpm, dvpd); const files = await dvp(dvpm, dvpd);
const bar = new ProgressBar(
`Patching ${files.length} files :bar`,
files.length,
);
await Promise.all( await Promise.all(
files.map(async ({ path, data }) => { files.map(async ({ path, data }) => {
@@ -40,9 +46,30 @@ while (true) {
await mkdir(`${DATA}/${dir}`, { recursive: true }); await mkdir(`${DATA}/${dir}`, { recursive: true });
await writeFile(`${DATA}/${path}.dvpl`, writeDVPL(Buffer.from(data))); await writeFile(`${DATA}/${path}.dvpl`, writeDVPL(Buffer.from(data)));
bar.tick();
}), }),
); );
if ('dynamicContentLocalizationsDir' in data) {
console.log('Found dynamic content localizations; patching...');
const localizationsResponse = await fetch(
`${secrets.WOTB_DLC_CDN}/dlc/${data.dynamicContentLocalizationsDir}/en.yaml`,
);
const newStrings = parseYaml(await localizationsResponse.text());
const oldStrings = await readYAMLDVPL<Record<string, string>>(
`${DATA}/Strings/en.yaml.dvpl`,
);
const patchedStrings = { ...oldStrings, ...newStrings };
const patchedContent = stringifyYaml(patchedStrings);
await writeFile(
`${DATA}/Strings/en.yaml.dvpl`,
writeDVPL(Buffer.from(patchedContent)),
);
}
patchIndex++; patchIndex++;
} else break; } else break;
} }
+1 -1
View File
@@ -229,7 +229,7 @@ async function postWork() {
{ {
path: `averages/${latest}.pb`, path: `averages/${latest}.pb`,
content: await encodeToBase64( content: await encodeToBase64(
'blitzkit.AverageDefinitions', 'blitzkit/blitzkit.AverageDefinitions',
averageDefinitions, averageDefinitions,
), ),
encoding: 'base64', encoding: 'base64',
+4 -4
View File
@@ -4,14 +4,14 @@ import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL'; import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets'; import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles'; import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants'; import { DATA } from './constants';
export async function boosterIcons(production: boolean) { export async function boosterIcons(production: boolean) {
const boosterFiles = (await readdir(`${DATA}/${POI.boosterIcons}`)).filter( const boosterFiles = (await readdir(`${DATA}/Gfx/Shared/boosters`)).filter(
(file) => !file.endsWith('@2x.txt.dvpl') && !file.startsWith('texture0'), (file) => !file.endsWith('@2x.txt.dvpl') && !file.startsWith('texture0'),
); );
const image = sharp( const image = sharp(
await readDVPLFile(`${DATA}/${POI.boosterIcons}/texture0.packed.webp.dvpl`), await readDVPLFile(`${DATA}/Gfx/Shared/boosters/texture0.packed.webp.dvpl`),
); );
const changes: FileChange[] = []; const changes: FileChange[] = [];
@@ -22,7 +22,7 @@ export async function boosterIcons(production: boolean) {
if (name === undefined) return; if (name === undefined) return;
const sizes = ( const sizes = (
await readStringDVPL(`${DATA}/${POI.boosterIcons}/${file}`) await readStringDVPL(`${DATA}/Gfx/Shared/boosters/${file}`)
) )
.split('\n')[4] .split('\n')[4]
.split(' ') .split(' ')
+2 -2
View File
@@ -1,12 +1,12 @@
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile'; import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { commitAssets } from '../../src/core/blitzkit/commitAssets'; import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles'; import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants'; import { DATA } from './constants';
export async function camouflageIcons(production: boolean) { export async function camouflageIcons(production: boolean) {
console.log('Building camouflage icons...'); console.log('Building camouflage icons...');
const content = await readDVPLFile( const content = await readDVPLFile(
`${DATA}/${POI.defaultCamoIcon.replace('.webp', '.packed.webp.dvpl')}`, `${DATA}/Gfx/UI/Hangar/IconCamouflage.packed.webp.dvpl`,
).then((content) => content.toString('base64')); ).then((content) => content.toString('base64'));
const changes: FileChange[] = [ const changes: FileChange[] = [
{ {
-33
View File
@@ -1,33 +0,0 @@
import { readdir } from 'fs/promises';
import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants';
export async function circleFlags(production: boolean) {
const flags = await readdir(`${DATA}/${POI.flags}`);
const changes = await Promise.all(
flags
.filter(
(flag) =>
flag.startsWith('flag_profile-stat_') &&
!flag.endsWith('@2x.packed.webp.dvpl'),
)
.map(async (flag) => {
const image = sharp(await readDVPLFile(`${DATA}/${POI.flags}/${flag}`));
const content = (
await image.trim({ threshold: 100 }).toBuffer()
).toString('base64');
const name = flag.match(/flag_profile-stat_(.+)\.packed\.webp/)![1];
return {
content,
encoding: 'base64',
path: `flags/circle/${name}.webp`,
} satisfies FileChange;
}),
);
await commitAssets('circle flags', changes, production);
}
+2 -29
View File
@@ -2,39 +2,12 @@ import { readdir } from 'fs/promises';
import { argv } from 'process'; import { argv } from 'process';
const isDepot = argv.includes('--depot'); const isDepot = argv.includes('--depot');
export const WOTB_WIN32_DEPOT = 444202; export const WOTB_WIN32_DEPOT = 444202;
export const DATA = isDepot export const DATA = isDepot
? await (async () => { ? await (async () => {
const [installationVersion] = await readdir(`depots/${WOTB_WIN32_DEPOT}`); const [installationVersion] = await readdir(`depots/${WOTB_WIN32_DEPOT}`);
return `depots/${WOTB_WIN32_DEPOT}/${installationVersion}/Data`; return `depots/${WOTB_WIN32_DEPOT}/${installationVersion}/Data`;
})() })()
: 'C:/Program Files (x86)/Steam/steamapps/common/World of Tanks Blitz/Data'; : 'C:/Program Files (x86)/Steam/steamapps/common/World of Tanks Blitz/Data';
export const POI = {
vehicleDefinitions: 'XML/item_defs/vehicles',
strings: 'Strings',
cachedStrings: 'https://stufficons.wgcdn.co/localizations/en.yaml',
tankParameters: '3d/Tanks/Parameters',
smallIcons: 'Gfx/UI/BattleScreenHUD/SmallTankIcons',
bigIcons: 'Gfx/UI/BigTankIcons',
flags: 'Gfx/Lobby/flags',
'3d': '3d',
bigShellIcons: 'Gfx/Shared/tank-supply/ammunition/big',
moduleIcons: 'Gfx/UI/ModulesTechTree',
collisionMeshes: '3d/Tanks/CollisionMeshes',
optionalDevices: 'XML/item_defs/vehicles/common/optional_devices.xml',
optionalDeviceSlots:
'XML/item_defs/vehicles/common/optional_device_slots.xml',
optionalDeviceImageMappings:
'UI/Screens3/Lobby/Inventory/OptionalDevices/OptionalDevicesItemImage.style.yaml',
optionalDevicesImage:
'Gfx/UI/InventoryIcons/Big/OptionalDevices/texture0.packed.webp',
consumablesCommon: 'XML/item_defs/vehicles/common/consumables/common.xml',
consumableIcons: 'Gfx/UI/InventoryIcons/Big/Consumables',
provisionIcons: 'Gfx/UI/InventoryIcons/Big/Provisions',
equipmentItemImageMappings:
'UI/Screens3/Lobby/Inventory/Equipment/EquipmentItemImage.style.yaml',
provisionsCommon: 'XML/item_defs/vehicles/common/provisions/common.xml',
defaultCamoIcon: 'Gfx/UI/Hangar/IconCamouflage.webp',
boosterIcons: 'Gfx/Shared/boosters',
} as const;
+156 -128
View File
@@ -1,3 +1,4 @@
import { existsSync } from 'fs';
import sharp from 'sharp'; import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile'; import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL'; import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
@@ -5,7 +6,7 @@ import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL'; import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets'; import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles'; import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants'; import { DATA } from './constants';
import { ConsumablesCommon, ProvisionsCommon } from './definitions'; import { ConsumablesCommon, ProvisionsCommon } from './definitions';
interface Mappings { interface Mappings {
@@ -16,149 +17,176 @@ interface Mappings {
}[]; }[];
} }
const listItemsPattern = /<items path="(.+)\.xml"\/>/g;
export async function consumableProvisionIcons(production: boolean) { export async function consumableProvisionIcons(production: boolean) {
console.log('Building consumable and provision icons...'); console.log('Building consumable and provision icons...');
const changes: FileChange[] = []; const changes: FileChange[] = [];
const consumablesTexture = sharp(
await readDVPLFile( const styles = [
`${DATA}/${POI.consumableIcons}/texture0.packed.webp.dvpl`, 'UI/Styles/Lobby/Inventory/Event/InventoryBigStyles.yaml',
'UI/Styles/Lobby/Inventory/Event/InventoryNormalStyles.yaml',
'UI/Styles/Lobby/Inventory/InventoryNormalStyles.yaml',
'UI/Screens3/Lobby/Inventory/Equipment/EquipmentItemImage.style.yaml',
'UI/Screens/Battle/Styles/BattleEquipmentStyles.yaml',
];
const styleSheets = await Promise.all(
styles.map(async (path) =>
Object.values(
(await readYAMLDVPL<Mappings>(`${DATA}/${path}.dvpl`)).StyleSheets,
),
), ),
); ).then((array) => array.flat());
const provisionsTexture = sharp(
await readDVPLFile(
`${DATA}/${POI.provisionIcons}/texture0.packed.webp.dvpl`,
),
);
const equipmentItemImageMappings = await readYAMLDVPL<Mappings>( for (const match of (
`${DATA}/${POI.equipmentItemImageMappings}.dvpl`, await readStringDVPL(
); `${DATA}/XML/item_defs/vehicles/common/consumables/list.xml.dvpl`,
const consumablesCommon = await readXMLDVPL<{ )
root: ConsumablesCommon; ).matchAll(listItemsPattern)) {
}>(`${DATA}/${POI.consumablesCommon}.dvpl`); const consumablesCommon = await readXMLDVPL<{
const provisionsCommon = await readXMLDVPL<{ root: ConsumablesCommon;
root: ProvisionsCommon; }>(
}>(`${DATA}/${POI.provisionsCommon}.dvpl`); `${DATA}/XML/item_defs/vehicles/common/consumables/${match[1]}.xml.dvpl`,
const styleSheets = Object.values(equipmentItemImageMappings.StyleSheets); );
await Promise.all( await Promise.all(
Object.values(consumablesCommon.root).map(async (consumable) => { Object.values(consumablesCommon.root).map(async (consumable) => {
const styleSheet = styleSheets.find((styleSheet) => const styleSheet = styleSheets.find((styleSheet) =>
styleSheet.selectors.includes( styleSheet.selectors.some((selector) =>
`.equipment_item.${consumable.icon} * .equipment-img`, selector.includes(`${consumable.icon} `),
), ),
);
if (!styleSheet) {
console.warn(
`No style sheet found for consumable ${consumable.icon}; skipping...`,
); );
return;
}
const configPath = styleSheet.properties['bg-sprite'] if (!styleSheet) {
.replace('~res:/', '') console.warn(
.replace('.psd', '') `No style sheet found for consumable ${consumable.icon}; skipping...`,
.replace('.txt', ''); );
return;
}
if (configPath.startsWith('Gfx/Shared')) { const configPath = styleSheet.properties['bg-sprite']
const image = sharp( .replace('~res:/', '')
await readDVPLFile(`${DATA}/${configPath}.packed.webp.dvpl`), .replace('.psd', '')
.replace('.txt', '');
if (existsSync(`${DATA}/${configPath}.packed.webp.dvpl`)) {
const image = sharp(
await readDVPLFile(`${DATA}/${configPath}.packed.webp.dvpl`),
);
const content = (
await image.trim({ threshold: 100 }).toBuffer()
).toString('base64');
changes.push({
path: `icons/consumables/${consumable.id}.webp`,
encoding: 'base64',
content,
});
} else {
const consumablesTexture = sharp(
await readDVPLFile(
`${`${DATA}/${configPath}`.split('/').slice(0, -1).join('/')}/texture0.packed.webp.dvpl`,
),
);
const sizes = (await readStringDVPL(`${DATA}/${configPath}.txt.dvpl`))
.split('\n')[4]
.split(' ')
.map(Number);
const content = (
await consumablesTexture
.clone()
.extract({
left: sizes[0],
top: sizes[1],
width: sizes[2],
height: sizes[3],
})
.toBuffer()
).toString('base64');
changes.push({
path: `icons/consumables/${consumable.id}.webp`,
encoding: 'base64',
content,
});
}
}),
);
}
for (const match of (
await readStringDVPL(
`${DATA}/XML/item_defs/vehicles/common/provisions/list.xml.dvpl`,
)
).matchAll(listItemsPattern)) {
const provisionsCommon = await readXMLDVPL<{
root: ProvisionsCommon;
}>(`${DATA}/XML/item_defs/vehicles/common/provisions/${match[1]}.xml.dvpl`);
await Promise.all(
Object.values(provisionsCommon.root).map(async (provision) => {
const styleSheet = styleSheets.find((styleSheet) =>
styleSheet.selectors.some((selector) =>
selector.includes(`${provision.icon} `),
),
); );
const content = (
await image.trim({ threshold: 100 }).toBuffer()
).toString('base64');
changes.push({ if (!styleSheet) {
path: `icons/consumables/${consumable.id}.webp`, console.warn(
encoding: 'base64', `No style sheet found for provision ${provision.icon}; skipping...`,
content, );
}); return;
} else { }
const sizes = (await readStringDVPL(`${DATA}/${configPath}.txt.dvpl`))
.split('\n')[4]
.split(' ')
.map(Number);
const content = (
await consumablesTexture
.clone()
.extract({
left: sizes[0],
top: sizes[1],
width: sizes[2],
height: sizes[3],
})
.toBuffer()
).toString('base64');
changes.push({ const configPath = styleSheet.properties['bg-sprite']
path: `icons/consumables/${consumable.id}.webp`, .replace('~res:/', '')
encoding: 'base64', .replace('.psd', '')
content, .replace('.txt', '');
});
}
}),
);
await Promise.all( if (existsSync(`${DATA}/${configPath}.packed.webp.dvpl`)) {
Object.values(provisionsCommon.root).map(async (provision) => { const image = sharp(
const styleSheet = styleSheets.find((styleSheet) => await readDVPLFile(`${DATA}/${configPath}.packed.webp.dvpl`),
styleSheet.selectors.includes( );
`.equipment_item.${provision.icon} * .equipment-img`, const content = (
), await image.trim({ threshold: 100 }).toBuffer()
); ).toString('base64');
if (!styleSheet) { changes.push({
console.warn( path: `icons/provisions/${provision.id}.webp`,
`No style sheet found for provision ${provision.icon}; skipping...`, encoding: 'base64',
); content,
return; });
} } else {
const provisionsTexture = sharp(
await readDVPLFile(
`${`${DATA}/${configPath}`.split('/').slice(0, -1).join('/')}/texture0.packed.webp.dvpl`,
),
);
const sizes = (await readStringDVPL(`${DATA}/${configPath}.txt.dvpl`))
.split('\n')[4]
.split(' ')
.map(Number);
const content = (
await provisionsTexture
.clone()
.extract({
left: sizes[0],
top: sizes[1],
width: sizes[2],
height: sizes[3],
})
.toBuffer()
).toString('base64');
const configPath = styleSheet.properties['bg-sprite'] changes.push({
.replace('~res:/', '') path: `icons/provisions/${provision.id}.webp`,
.replace('.psd', '') encoding: 'base64',
.replace('.txt', ''); content,
});
if (configPath.startsWith('Gfx/Shared')) { }
const image = sharp( }),
await readDVPLFile(`${DATA}/${configPath}.packed.webp.dvpl`), );
); }
const content = (
await image.trim({ threshold: 100 }).toBuffer()
).toString('base64');
changes.push({
path: `icons/provisions/${provision.id}.webp`,
encoding: 'base64',
content,
});
} else {
const sizes = (await readStringDVPL(`${DATA}/${configPath}.txt.dvpl`))
.split('\n')[4]
.split(' ')
.map(Number);
const content = (
await provisionsTexture
.clone()
.extract({
left: sizes[0],
top: sizes[1],
width: sizes[2],
height: sizes[3],
})
.toBuffer()
).toString('base64');
changes.push({
path: `icons/provisions/${provision.id}.webp`,
encoding: 'base64',
content,
});
}
}),
);
await commitAssets('consumable and provision icons', changes, production); await commitAssets('consumable and provision icons', changes, production);
} }
+42 -3
View File
@@ -14,10 +14,19 @@ const ICONS = [
['currency_battle-xp_xl.packed.webp', 'xp'], ['currency_battle-xp_xl.packed.webp', 'xp'],
]; ];
type BlitzGlossary = Record<
string,
{
image_url: null | string;
}
>;
export async function currencies(production: boolean) { export async function currencies(production: boolean) {
console.log('Building currency icons...'); console.log('Building currency icons...');
const changes = await Promise.all( const changes: FileChange[] = [];
await Promise.all(
ICONS.map(async ([file, name]) => { ICONS.map(async ([file, name]) => {
const content = ( const content = (
await sharp( await sharp(
@@ -27,13 +36,43 @@ export async function currencies(production: boolean) {
.toBuffer() .toBuffer()
).toString('base64'); ).toString('base64');
return { changes.push({
content, content,
encoding: 'base64', encoding: 'base64',
path: `icons/currencies/${name}.webp`, path: `icons/currencies/${name}.webp`,
} satisfies FileChange; });
}), }),
); );
const glossary = await fetch(process.env.WOTB_GLOSSARY!).then(
(response) => response.json() as Promise<BlitzGlossary>,
);
await Promise.all(
Object.entries(glossary)
.filter(([key]) => /^prx_season_\d+$/.test(key))
.map(async ([key, item]) => {
if (item.image_url === null) throw new Error(`No image_url for ${key}`);
const imageRaw = await fetch(item.image_url).then((response) =>
response.arrayBuffer(),
);
const content = (
await sharp(imageRaw)
.trim({
threshold: 100,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer()
).toString('base64');
changes.push({
content,
encoding: 'base64',
path: `icons/currencies/${key}.webp`,
});
}),
);
await commitAssets('currency icons', changes, production); await commitAssets('currency icons', changes, production);
} }
+365 -119
View File
@@ -8,9 +8,11 @@ import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL'; import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL'; import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { toUniqueId } from '../../src/core/blitz/toUniqueId'; import { toUniqueId } from '../../src/core/blitz/toUniqueId';
import { CamouflageDefinitions } from '../../src/core/blitzkit/camouflageDefinitions';
import { commitAssets } from '../../src/core/blitzkit/commitAssets'; import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { import {
ConsumableDefinitions, ConsumableDefinitions,
ConsumableEntry,
TankFilterDefinitionCategory, TankFilterDefinitionCategory,
} from '../../src/core/blitzkit/consumableDefinitions'; } from '../../src/core/blitzkit/consumableDefinitions';
import { import {
@@ -24,7 +26,10 @@ import {
ModelArmor, ModelArmor,
ModelDefinitions, ModelDefinitions,
} from '../../src/core/blitzkit/modelDefinitions'; } from '../../src/core/blitzkit/modelDefinitions';
import { ProvisionDefinitions } from '../../src/core/blitzkit/provisionDefinitions'; import {
ProvisionDefinitions,
ProvisionEntry,
} from '../../src/core/blitzkit/provisionDefinitions';
import { SkillDefinitions } from '../../src/core/blitzkit/skillDefinitions'; import { SkillDefinitions } from '../../src/core/blitzkit/skillDefinitions';
import { superCompress } from '../../src/core/blitzkit/superCompress'; import { superCompress } from '../../src/core/blitzkit/superCompress';
import { import {
@@ -32,12 +37,12 @@ import {
CrewMember, CrewMember,
GunDefinition, GunDefinition,
ShellType, ShellType,
TankDefinitionPrice,
TankDefinitions, TankDefinitions,
TankPrice,
Tier, Tier,
Unlock, Unlock,
} from '../../src/core/blitzkit/tankDefinitions'; } from '../../src/core/blitzkit/tankDefinitions';
import { DATA, POI } from './constants'; import { DATA } from './constants';
import { Avatar } from './skillIcons'; import { Avatar } from './skillIcons';
import { TankParameters } from './tankIcons'; import { TankParameters } from './tankIcons';
@@ -323,6 +328,48 @@ export interface ProvisionsCommon {
}; };
} }
type CamouflagesInclude =
| {
nations?: string;
}
| {
vehicle?: { name?: string; minLevel?: number; maxLevel?: number };
};
interface CamouflagesXML {
camouflages: Record<
string,
{
id: number;
userString: string;
description: string;
category: string;
group: string;
kind: string;
notInShop: boolean;
unlockCostCategory?: string;
vehicleFilter: {
include?: CamouflagesInclude | CamouflagesInclude[];
};
script: string[] | string;
icon?: string;
unlockPremium?: string;
unlockQuest?: string;
unlockClanLevel?: number;
unlockShopBundle?: string;
unlockTankRank?: number;
}
>;
}
type CamouflagesYaml = Record<
string,
{
userString?: string;
shortUserString?: string;
}
>;
type ConsumablesVehicleFilter = type ConsumablesVehicleFilter =
| { minLevel: number; maxLevel: number } | { minLevel: number; maxLevel: number }
| { name: string } | { name: string }
@@ -357,6 +404,24 @@ interface AvailableNationsYaml {
available_nations: string[]; available_nations: string[];
} }
export interface SquadBattleTypeStyles {
Prototypes: {
components: {
UIDataLocalBindingsComponent: {
data: [string, string, string][];
};
};
}[];
}
type CombatRolesYaml = Record<
string,
{
id: number;
default_abilities: string[];
}
>;
const blitzShellKindToBlitzkit: Record<ShellKind, ShellType> = { const blitzShellKindToBlitzkit: Record<ShellKind, ShellType> = {
ARMOR_PIERCING: ShellType.AP, ARMOR_PIERCING: ShellType.AP,
ARMOR_PIERCING_CR: ShellType.APCR, ARMOR_PIERCING_CR: ShellType.APCR,
@@ -376,6 +441,7 @@ export async function definitions(production: boolean) {
).then((response) => response.json())) as { ).then((response) => response.json())) as {
data: { [key: number]: { description: null | string } }; data: { [key: number]: { description: null | string } };
}; };
const gameDefinitions: GameDefinitions = { const gameDefinitions: GameDefinitions = {
version: (await readStringDVPL(`${DATA}/version.txt.dvpl`)) version: (await readStringDVPL(`${DATA}/version.txt.dvpl`))
.split(' ')[0] .split(' ')[0]
@@ -387,8 +453,11 @@ export async function definitions(production: boolean) {
`${DATA}/available_nations.yaml.dvpl`, `${DATA}/available_nations.yaml.dvpl`,
) )
).available_nations, ).available_nations,
gameModes: {},
roles: {},
}; };
const tankDefinitions: TankDefinitions = {}; const tankDefinitions: TankDefinitions = {};
const camouflageDefinitions: CamouflageDefinitions = {};
const modelDefinitions: ModelDefinitions = {}; const modelDefinitions: ModelDefinitions = {};
const mapDefinitions: MapDefinitions = {}; const mapDefinitions: MapDefinitions = {};
const equipmentDefinitions: EquipmentDefinitions = { const equipmentDefinitions: EquipmentDefinitions = {
@@ -400,18 +469,20 @@ export async function definitions(production: boolean) {
const skillDefinitions: SkillDefinitions = { const skillDefinitions: SkillDefinitions = {
classes: { lightTank: [], mediumTank: [], heavyTank: [], 'AT-SPG': [] }, classes: { lightTank: [], mediumTank: [], heavyTank: [], 'AT-SPG': [] },
}; };
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then( const nations = await readdir(`${DATA}/XML/item_defs/vehicles`).then(
(nations) => nations.filter((nation) => nation !== 'common'), (nations) => nations.filter((nation) => nation !== 'common'),
); );
const tankStringIdMap: Record<string, number> = {}; const tankStringIdMap: Record<string, number> = {};
const stringsPreInstalled = await readYAMLDVPL<BlitzStrings>( const stringsPreInstalled = await readYAMLDVPL<BlitzStrings>(
`${DATA}/${POI.strings}/en.yaml.dvpl`, `${DATA}/Strings/en.yaml.dvpl`,
); );
const stringsCache = await fetch(POI.cachedStrings) const stringsCache = await fetch(
'https://stufficons.wgcdn.co/localizations/en.yaml',
)
.then((response) => response.text()) .then((response) => response.text())
.then((string) => parseYaml(string) as BlitzStrings); .then((string) => parseYaml(string) as BlitzStrings);
const optionalDevices = await readXMLDVPL<{ root: OptionalDevices }>( const optionalDevices = await readXMLDVPL<{ root: OptionalDevices }>(
`${DATA}/${POI.optionalDevices}.dvpl`, `${DATA}/XML/item_defs/vehicles/common/optional_devices.xml.dvpl`,
); );
const strings = { const strings = {
...stringsPreInstalled, ...stringsPreInstalled,
@@ -419,48 +490,136 @@ export async function definitions(production: boolean) {
}; };
const optionalDeviceSlots = await readXMLDVPL<{ const optionalDeviceSlots = await readXMLDVPL<{
root: OptionalDeviceSlots; root: OptionalDeviceSlots;
}>(`${DATA}/${POI.optionalDeviceSlots}.dvpl`); }>(`${DATA}/XML/item_defs/vehicles/common/optional_device_slots.xml.dvpl`);
const consumables = await readXMLDVPL<{ root: ConsumablesCommon }>( const consumables: ConsumablesCommon = {};
`${DATA}/${POI.consumablesCommon}.dvpl`, const provisions: ProvisionsCommon = {};
);
const provisions = await readXMLDVPL<{ root: ProvisionsCommon }>( for (const match of (
`${DATA}/${POI.provisionsCommon}.dvpl`, await readStringDVPL(
); `${DATA}/XML/item_defs/vehicles/common/consumables/list.xml.dvpl`,
)
).matchAll(/<items path="(.+)\.xml"\/>/g)) {
if (match[1] === 'prototypes') continue;
Object.assign(
consumables,
(
await readXMLDVPL<{ root: ConsumablesCommon }>(
`${DATA}/XML/item_defs/vehicles/common/consumables/${match[1]}.xml.dvpl`,
)
).root,
);
}
for (const match of (
await readStringDVPL(
`${DATA}/XML/item_defs/vehicles/common/provisions/list.xml.dvpl`,
)
).matchAll(/<items path="(.+)\.xml"\/>/g)) {
if (match[1] === 'prototypes') continue;
Object.assign(
provisions,
(
await readXMLDVPL<{ root: ConsumablesCommon }>(
`${DATA}/XML/item_defs/vehicles/common/provisions/${match[1]}.xml.dvpl`,
)
).root,
);
}
const avatar = await readXMLDVPL<{ root: Avatar }>( const avatar = await readXMLDVPL<{ root: Avatar }>(
`${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`, `${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`,
); );
const maps = await readYAMLDVPL<Maps>(`${DATA}/maps.yaml.dvpl`); const maps = await readYAMLDVPL<Maps>(`${DATA}/maps.yaml.dvpl`);
const tankXps = new Map<number, number>(); const tankXps = new Map<number, number>();
const camouflagesXml = await readXMLDVPL<{ root: CamouflagesXML }>(
`${DATA}/XML/item_defs/vehicles/common/camouflages.xml.dvpl`,
);
const camouflagesYaml = await readYAMLDVPL<CamouflagesYaml>(
`${DATA}/camouflages.yaml.dvpl`,
);
const camouflagesXmlEntries = Object.entries(camouflagesXml.root.camouflages);
const squadBattleTypeStyles = await readYAMLDVPL<SquadBattleTypeStyles>(
`${DATA}/UI/Screens3/Lobby/Hangar/Squad/SquadBattleType.yaml.dvpl`,
);
const gameTypeSelectorStyles = await readYAMLDVPL<SquadBattleTypeStyles>(
`${DATA}/UI/Screens/Lobby/Hangar/GameTypeSelector.yaml.dvpl`,
);
const gameModeNativeNames: Record<string, number> = {};
const combatRoles = await readYAMLDVPL<CombatRolesYaml>(
`${DATA}/XML/item_defs/vehicles/common/combat_roles.yaml.dvpl`,
);
const consumableNativeNames: Record<string, number> = {};
const provisionNativeNames: Record<string, number> = {};
for (const match of squadBattleTypeStyles.Prototypes[0].components.UIDataLocalBindingsComponent.data[1][2].matchAll(
/"(\d+)" -> "(battleType\/([a-zA-Z]+))"/g,
)) {
const id = Number(match[1]);
const name = strings[match[2]];
gameModeNativeNames[match[3]] = id;
gameDefinitions.gameModes[id] = {
name,
};
}
for (const match of gameTypeSelectorStyles.Prototypes[0].components.UIDataLocalBindingsComponent.data[1][2].matchAll(
/eGameMode\.([a-zA-Z]+) -> "~res:\/Gfx\/UI\/Hangar\/GameTypes\/battle-type_([^"]+)"/g,
)) {
Object.entries(gameModeNativeNames).forEach(([key, value]) => {
if (key.toLowerCase() === match[2].toLowerCase()) {
gameModeNativeNames[match[1]] = value;
}
});
}
camouflagesXmlEntries.forEach(([camoKey, camo]) => {
const yamlEntry = camouflagesYaml[camoKey];
const fullName = yamlEntry.userString
? strings[yamlEntry.userString]
: undefined;
const shortName = yamlEntry.shortUserString
? strings[yamlEntry.shortUserString]
: undefined;
const resolvedTankName = shortName ?? fullName;
const resolvedTankNameFull =
resolvedTankName === fullName ? undefined : fullName;
camouflageDefinitions[camo.id] = {
id: camo.id,
name: strings[camo.userString],
tankName: resolvedTankName,
tankNameFull: resolvedTankNameFull,
};
});
await Promise.all( await Promise.all(
nations.map(async (nation) => { nations.map(async (nation) => {
const tankList = await readXMLDVPL<{ root: VehicleDefinitionList }>( const tankList = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/list.xml.dvpl`, `${DATA}/XML/item_defs/vehicles/${nation}/list.xml.dvpl`,
); );
const turretList = await readXMLDVPL<{ const turretList = await readXMLDVPL<{
root: TurretDefinitionsList; root: TurretDefinitionsList;
}>( }>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/components/turrets.xml.dvpl`, `${DATA}/XML/item_defs/vehicles/${nation}/components/turrets.xml.dvpl`,
); );
const gunList = await readXMLDVPL<{ const gunList = await readXMLDVPL<{
root: GunDefinitionsList; root: GunDefinitionsList;
}>( }>(`${DATA}/XML/item_defs/vehicles/${nation}/components/guns.xml.dvpl`);
`${DATA}/${POI.vehicleDefinitions}/${nation}/components/guns.xml.dvpl`,
);
const shellList = await readXMLDVPL<{ const shellList = await readXMLDVPL<{
root: ShellDefinitionsList; root: ShellDefinitionsList;
}>( }>(`${DATA}/XML/item_defs/vehicles/${nation}/components/shells.xml.dvpl`);
`${DATA}/${POI.vehicleDefinitions}/${nation}/components/shells.xml.dvpl`,
);
const enginesList = await readXMLDVPL<{ const enginesList = await readXMLDVPL<{
root: EngineDefinitionsList; root: EngineDefinitionsList;
}>( }>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/components/engines.xml.dvpl`, `${DATA}/XML/item_defs/vehicles/${nation}/components/engines.xml.dvpl`,
); );
const chassisList = await readXMLDVPL<{ const chassisList = await readXMLDVPL<{
root: ChassisDefinitionsList; root: ChassisDefinitionsList;
}>( }>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/components/chassis.xml.dvpl`, `${DATA}/XML/item_defs/vehicles/${nation}/components/chassis.xml.dvpl`,
); );
function resolveUnlocks(unlocks?: Unlocks) { function resolveUnlocks(unlocks?: Unlocks) {
@@ -511,15 +670,12 @@ export async function definitions(production: boolean) {
const engineXps = new Map<number, number>(); const engineXps = new Map<number, number>();
const trackXps = new Map<number, number>(); const trackXps = new Map<number, number>();
const tank = tankList.root[tankKey]; const tank = tankList.root[tankKey];
const tankPrice: TankDefinitionPrice = let tankPrice: TankPrice;
typeof tank.price === 'number'
? { type: 'credits', value: tank.price }
: { type: 'gold', value: tank.price['#text'] };
const tankDefinition = await readXMLDVPL<{ root: VehicleDefinitions }>( const tankDefinition = await readXMLDVPL<{ root: VehicleDefinitions }>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/${tankKey}.xml.dvpl`, `${DATA}/XML/item_defs/vehicles/${nation}/${tankKey}.xml.dvpl`,
); );
const tankParameters = await readYAMLDVPL<TankParameters>( const tankParameters = await readYAMLDVPL<TankParameters>(
`${DATA}/${POI.tankParameters}/${nation}/${tankKey}.yaml.dvpl`, `${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml.dvpl`,
); );
const turretOrigin = tankDefinition.root.hull.turretPositions.turret const turretOrigin = tankDefinition.root.hull.turretPositions.turret
.split(' ') .split(' ')
@@ -530,6 +686,23 @@ export async function definitions(production: boolean) {
const equipment = tankDefinition.root.optDevicePreset; const equipment = tankDefinition.root.optDevicePreset;
tankStringIdMap[`${nation}:${tankKey}`] = tankId; tankStringIdMap[`${nation}:${tankKey}`] = tankId;
if (tank.sellPrice) {
tankPrice = {
type: 'gold',
value: tank.sellPrice['#text'] * 2,
};
} else if (typeof tank.price === 'number') {
tankPrice = {
type: 'credits',
value: tank.price,
};
} else {
tankPrice = {
type: 'credits',
value: tank.price['#text'] * 400,
};
}
Object.keys(tankDefinition.root.hull.armor) Object.keys(tankDefinition.root.hull.armor)
.filter((name) => name.startsWith('armor_')) .filter((name) => name.startsWith('armor_'))
.forEach((name) => { .forEach((name) => {
@@ -554,11 +727,11 @@ export async function definitions(production: boolean) {
const fixedCamouflage = tankTags.includes('eventCamouflage_user'); const fixedCamouflage = tankTags.includes('eventCamouflage_user');
const totalUnlocks: UnlocksListing[] = []; const totalUnlocks: UnlocksListing[] = [];
Object.entries(tankDefinition.root.crew).forEach(([key, value]) => { Object.entries(tankDefinition.root.crew).forEach(([crewKey, value]) => {
let entry: Crew; let entry: Crew;
const index = crew.findIndex(({ type }) => type === key); const index = crew.findIndex(({ type }) => type === crewKey);
if (index === -1) { if (index === -1) {
entry = { type: key as CrewMember }; entry = { type: crewKey as CrewMember };
crew.push(entry); crew.push(entry);
} else { } else {
entry = crew[index]; entry = crew[index];
@@ -589,8 +762,30 @@ export async function definitions(production: boolean) {
} }
}); });
const camouflages = camouflagesXmlEntries
.filter(([, camo]) => {
if (!camo.vehicleFilter.include) return false;
if (camo.unlockCostCategory !== 'legendary-skins-gold')
return false;
const includeArray = Array.isArray(camo.vehicleFilter.include)
? camo.vehicleFilter.include
: [camo.vehicleFilter.include];
return includeArray.some((filter) => {
if ('vehicle' in filter && filter.vehicle?.name) {
return filter.vehicle.name === `${nation}:${tankKey}`;
}
return false;
});
})
.map(([, camo]) => camo.id);
tankDefinitions[tankId] = { tankDefinitions[tankId] = {
id: tankId, id: tankId,
roles: {},
camouflages: camouflages.length === 0 ? undefined : camouflages,
description: description:
wargamingTankopedia.data[tankId]?.description ?? undefined, wargamingTankopedia.data[tankId]?.description ?? undefined,
fixedCamouflage: fixedCamouflage ? true : undefined, fixedCamouflage: fixedCamouflage ? true : undefined,
@@ -630,6 +825,22 @@ export async function definitions(production: boolean) {
tracks: [], tracks: [],
}; };
if (tank.combatRole) {
Object.entries(tank.combatRole).forEach(([gameMode, role]) => {
const id = Object.entries(gameModeNativeNames).find(
([key]) => key.toLowerCase() === gameMode.toLowerCase(),
)?.[1];
if (id === undefined) {
throw new Error(
`Unknown game mode in tank ${tankKey}: ${gameMode}`,
);
}
tankDefinitions[tankId].roles[id] = combatRoles[role].id;
});
}
if (tankDefinitions[tankId].name === tankDefinitions[tankId].nameFull) { if (tankDefinitions[tankId].name === tankDefinitions[tankId].nameFull) {
delete tankDefinitions[tankId].nameFull; delete tankDefinitions[tankId].nameFull;
} }
@@ -1079,126 +1290,140 @@ export async function definitions(production: boolean) {
}, },
); );
Object.values(consumables.root).forEach((consumable) => { Object.entries(consumables).forEach(([key, consumable]) => {
consumableDefinitions[consumable.id] = { consumableNativeNames[key] = consumable.id;
const entry = {
id: consumable.id, id: consumable.id,
gameMode: 'gameModeFilter' in consumable,
cooldown: consumable.script.cooldown, cooldown: consumable.script.cooldown,
duration: consumable.script.duration, duration: consumable.script.duration,
name: name:
strings[consumable.userString] ?? strings[consumable.userString] ??
missingStrings[consumable.userString] ?? missingStrings[consumable.userString] ??
`Unknown ${consumable.id}`, `Unknown ${consumable.id}`,
include: [], } as ConsumableEntry;
}; consumableDefinitions[consumable.id] = entry;
const includeRaw = consumable.vehicleFilter?.include.vehicle; const includeRaw = consumable.vehicleFilter?.include.vehicle;
const excludeRaw = consumable.vehicleFilter?.exclude?.vehicle; const excludeRaw = consumable.vehicleFilter?.exclude?.vehicle;
if (includeRaw) { if (!entry.gameMode) {
if ('minLevel' in includeRaw) { if (includeRaw) {
consumableDefinitions[consumable.id].include.push({ entry.include = [];
type: 'tier',
min: includeRaw.minLevel,
max: includeRaw.maxLevel,
});
} else if ('name' in includeRaw) {
consumableDefinitions[consumable.id].include.push({
type: 'ids',
ids: includeRaw.name.split(' ').map((key) => tankStringIdMap[key]),
});
} else throw new SyntaxError('Unhandled include type');
if (consumable.vehicleFilter?.include.nations) { if ('minLevel' in includeRaw) {
consumableDefinitions[consumable.id].include.push({ entry.include.push({
type: 'nation', type: 'tier',
nations: consumable.vehicleFilter.include.nations.split(' '), min: includeRaw.minLevel,
}); max: includeRaw.maxLevel,
});
} else if ('name' in includeRaw) {
entry.include.push({
type: 'ids',
ids: includeRaw.name.split(' ').map((key) => tankStringIdMap[key]),
});
} else throw new SyntaxError('Unhandled include type');
if (consumable.vehicleFilter?.include.nations) {
entry.include.push({
type: 'nation',
nations: consumable.vehicleFilter.include.nations.split(' '),
});
}
} }
}
if (excludeRaw) { if (excludeRaw) {
consumableDefinitions[consumable.id].exclude = []; entry.exclude = [];
if ('name' in excludeRaw) { if ('name' in excludeRaw) {
consumableDefinitions[consumable.id].exclude!.push({ entry.exclude!.push({
type: 'ids', type: 'ids',
ids: excludeRaw.name.split(' ').map((key) => tankStringIdMap[key]), ids: excludeRaw.name.split(' ').map((key) => tankStringIdMap[key]),
}); });
} else if ('extendedTags' in excludeRaw) { } else if ('extendedTags' in excludeRaw) {
consumableDefinitions[consumable.id].exclude!.push({ entry.exclude!.push({
type: 'category', type: 'category',
categories: excludeRaw.extendedTags.split( categories: excludeRaw.extendedTags.split(
' ', ' ',
) as TankFilterDefinitionCategory[], ) as TankFilterDefinitionCategory[],
}); });
} else throw new SyntaxError('Unhandled exclude type'); } else throw new SyntaxError('Unhandled exclude type');
if (consumable.vehicleFilter?.exclude?.nations) { if (consumable.vehicleFilter?.exclude?.nations) {
consumableDefinitions[consumable.id].exclude!.push({ entry.exclude!.push({
type: 'nation', type: 'nation',
nations: consumable.vehicleFilter.exclude.nations.split(' '), nations: consumable.vehicleFilter.exclude.nations.split(' '),
}); });
}
} }
} }
}); });
Object.values(provisions.root).forEach((provision) => { Object.entries(provisions).forEach(([key, provision]) => {
provisionDefinitions[provision.id] = { provisionNativeNames[key] = provision.id;
const entry = {
id: provision.id, id: provision.id,
gameMode: 'gameModeFilter' in provision,
name: name:
strings[provision.userString] ?? strings[provision.userString] ??
missingStrings[provision.userString] ?? missingStrings[provision.userString] ??
`Unknown ${provision.id}`, `Unknown ${provision.id}`,
include: [], } as ProvisionEntry;
}; provisionDefinitions[provision.id] = entry;
const includeRaw = provision.vehicleFilter?.include.vehicle; const includeRaw = provision.vehicleFilter?.include.vehicle;
const excludeRaw = provision.vehicleFilter?.exclude?.vehicle; const excludeRaw = provision.vehicleFilter?.exclude?.vehicle;
if (includeRaw) { if (!entry.gameMode) {
if ('minLevel' in includeRaw) { if (includeRaw) {
provisionDefinitions[provision.id].include.push({ entry.include = [];
type: 'tier',
min: includeRaw.minLevel,
max: includeRaw.maxLevel,
});
} else if ('name' in includeRaw) {
provisionDefinitions[provision.id].include.push({
type: 'ids',
ids: includeRaw.name.split(' ').map((key) => tankStringIdMap[key]),
});
} else throw new SyntaxError('Unhandled include type');
if (provision.vehicleFilter?.include.nations) { if ('minLevel' in includeRaw) {
provisionDefinitions[provision.id].include.push({ entry.include.push({
type: 'nation', type: 'tier',
nations: provision.vehicleFilter.include.nations.split(' '), min: includeRaw.minLevel,
}); max: includeRaw.maxLevel,
});
} else if ('name' in includeRaw) {
entry.include.push({
type: 'ids',
ids: includeRaw.name.split(' ').map((key) => tankStringIdMap[key]),
});
} else throw new SyntaxError('Unhandled include type');
if (provision.vehicleFilter?.include.nations) {
entry.include.push({
type: 'nation',
nations: provision.vehicleFilter.include.nations.split(' '),
});
}
} }
}
if (excludeRaw) { if (excludeRaw) {
provisionDefinitions[provision.id].exclude = []; entry.exclude = [];
if ('name' in excludeRaw) { if ('name' in excludeRaw) {
provisionDefinitions[provision.id].exclude!.push({ entry.exclude!.push({
type: 'ids', type: 'ids',
ids: excludeRaw.name.split(' ').map((key) => tankStringIdMap[key]), ids: excludeRaw.name.split(' ').map((key) => tankStringIdMap[key]),
}); });
} else if ('extendedTags' in excludeRaw) { } else if ('extendedTags' in excludeRaw) {
provisionDefinitions[provision.id].exclude!.push({ entry.exclude!.push({
type: 'category', type: 'category',
categories: excludeRaw.extendedTags.split( categories: excludeRaw.extendedTags.split(
' ', ' ',
) as TankFilterDefinitionCategory[], ) as TankFilterDefinitionCategory[],
}); });
} else throw new SyntaxError('Unhandled exclude type'); } else throw new SyntaxError('Unhandled exclude type');
if (provision.vehicleFilter?.exclude?.nations) { if (provision.vehicleFilter?.exclude?.nations) {
provisionDefinitions[provision.id].exclude!.push({ entry.exclude!.push({
type: 'nation', type: 'nation',
nations: provision.vehicleFilter.exclude.nations.split(' '), nations: provision.vehicleFilter.exclude.nations.split(' '),
}); });
}
} }
} }
@@ -1208,6 +1433,22 @@ export async function definitions(production: boolean) {
} }
}); });
Object.entries(combatRoles).forEach(([, value]) => {
gameDefinitions.roles[value.id] = { provisions: [], consumables: [] };
value.default_abilities.forEach((ability) => {
if (ability in consumableNativeNames) {
gameDefinitions.roles[value.id].consumables.push(
consumableNativeNames[ability],
);
} else if (ability in provisionNativeNames) {
gameDefinitions.roles[value.id].provisions.push(
provisionNativeNames[ability],
);
} else throw new Error(`Unknown ability ${ability}`);
});
});
Object.entries(avatar.root.skillsByClasses).forEach(([tankClass, skills]) => { Object.entries(avatar.root.skillsByClasses).forEach(([tankClass, skills]) => {
skillDefinitions.classes[tankClass as TankClass] = skills.split(' '); skillDefinitions.classes[tankClass as TankClass] = skills.split(' ');
}); });
@@ -1262,6 +1503,11 @@ export async function definitions(production: boolean) {
encoding: 'base64', encoding: 'base64',
path: 'definitions/maps.cdon.lz4', path: 'definitions/maps.cdon.lz4',
}, },
{
content: superCompress(camouflageDefinitions),
encoding: 'base64',
path: 'definitions/camouflages.cdon.lz4',
},
], ],
production, production,
); );
+6 -4
View File
@@ -5,7 +5,7 @@ import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL'; import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets'; import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles'; import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants'; import { DATA } from './constants';
import { OptionalDevices } from './definitions'; import { OptionalDevices } from './definitions';
interface Mappings { interface Mappings {
@@ -22,13 +22,15 @@ export async function equipmentIcons(production: boolean) {
const changes: FileChange[] = []; const changes: FileChange[] = [];
const optionalDevices = await readXMLDVPL<{ root: OptionalDevices }>( const optionalDevices = await readXMLDVPL<{ root: OptionalDevices }>(
`${DATA}/${POI.optionalDevices}.dvpl`, `${DATA}/XML/item_defs/vehicles/common/optional_devices.xml.dvpl`,
); );
const mappings = await readYAMLDVPL<Mappings>( const mappings = await readYAMLDVPL<Mappings>(
`${DATA}/${POI.optionalDeviceImageMappings}.dvpl`, `${DATA}/UI/Screens3/Lobby/Inventory/OptionalDevices/OptionalDevicesItemImage.style.yaml.dvpl`,
); );
const image = sharp( const image = sharp(
await readDVPLFile(`${DATA}/${POI.optionalDevicesImage}.dvpl`), await readDVPLFile(
`${DATA}/Gfx/UI/InventoryIcons/Big/OptionalDevices/texture0.packed.webp.dvpl`,
),
); );
await Promise.all( await Promise.all(
+86
View File
@@ -0,0 +1,86 @@
import { readdir, writeFile } from 'fs/promises';
import sharp from 'sharp';
import { readBase64DVPL } from '../../src/core/blitz/readBase64DVPL';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
export async function flags(production: boolean) {
console.log('Building flags...');
const changes = [
...(await Promise.all(
(await readdir(`${DATA}/Gfx/Lobby/flags`))
.filter(
(flag) =>
flag.startsWith('flag_profile-stat_') &&
!flag.endsWith('@2x.packed.webp.dvpl'),
)
.map(async (flag) => {
const image = sharp(
await readDVPLFile(`${DATA}/Gfx/Lobby/flags/${flag}`),
);
const content = (
await image.trim({ threshold: 100 }).toBuffer()
).toString('base64');
const name = flag.match(/flag_profile-stat_(.+)\.packed\.webp/)![1];
return {
content,
encoding: 'base64',
path: `flags/circle/${name}.webp`,
} satisfies FileChange;
}),
)),
...(await Promise.all(
(await readdir(`${DATA}/Gfx/Lobby/flags`))
.filter(
(flag) =>
flag.startsWith('flag_tutor-tank_') &&
!flag.endsWith('@2x.packed.webp.dvpl'),
)
.map(async (flag) => {
const content = await readBase64DVPL(
`${DATA}/Gfx/Lobby/flags/${flag}`,
);
const name = flag.match(/flag_tutor-tank_(.+)\.packed\.webp/)![1];
return {
content,
encoding: 'base64',
path: `flags/scratched/${name}.webp`,
} satisfies FileChange;
}),
)),
...(await Promise.all(
(await readdir(`${DATA}/Gfx/Lobby/flags`))
.filter(
(flag) =>
flag.startsWith('flag_filter_') &&
flag.endsWith('@2x.packed.webp.dvpl'),
)
.map(async (flag) => {
const content = (
await sharp(await readDVPLFile(`${DATA}/Gfx/Lobby/flags/${flag}`))
.trim({
threshold: 100,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer()
).toString('base64');
const name = flag.match(/flag_filter_(.+)@2x\.packed\.webp/)![1];
writeFile(`temp/icons/${name}.webp`, content, { encoding: 'base64' });
return {
content,
encoding: 'base64',
path: `flags/fade_small/${name}.webp`,
} satisfies FileChange;
}),
)),
];
await commitAssets('flags', changes, production);
}
+54
View File
@@ -0,0 +1,54 @@
import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
import { SquadBattleTypeStyles } from './definitions';
export async function gameModeBanners(production: boolean) {
console.log('Building game mode banners...');
const changes: FileChange[] = [];
const gameTypeSelectorStyles = await readYAMLDVPL<SquadBattleTypeStyles>(
`${DATA}/UI/Screens/Lobby/Hangar/GameTypeSelector.yaml.dvpl`,
);
const squadBattleTypeStyles = await readYAMLDVPL<SquadBattleTypeStyles>(
`${DATA}/UI/Screens3/Lobby/Hangar/Squad/SquadBattleType.yaml.dvpl`,
);
const bannerMatches: { name: string; path: string }[] = [];
for (const match of gameTypeSelectorStyles.Prototypes[0].components.UIDataLocalBindingsComponent.data[1][2].matchAll(
/eGameMode\.([a-zA-Z]+) -> "~res:([^"]+)"/g,
)) {
bannerMatches.push({ name: match[1], path: match[2] });
}
for (const match of squadBattleTypeStyles.Prototypes[0].components.UIDataLocalBindingsComponent.data[1][2].matchAll(
/"(\d+)" -> "battleType\/([a-zA-Z]+)"/g,
)) {
const id = Number(match[1]);
const name = match[2];
let path = bannerMatches.find(
(bannerMatch) => bannerMatch.name.toLowerCase() === name.toLowerCase(),
)?.path;
if (path === undefined) {
path = `/Gfx/UI/Hangar/GameTypes/battle-type_${name.toLowerCase()}`;
}
const content = (
await sharp(await readDVPLFile(`${DATA}${path}.packed.webp.dvpl`))
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } })
.toBuffer()
).toString('base64');
changes.push({
path: `icons/game_mode_banners/${id}.webp`,
content,
encoding: 'base64',
});
}
await commitAssets('game mode banners', changes, production);
}
+6 -8
View File
@@ -12,38 +12,36 @@ const production = argv.includes('--production');
import { boosterIcons } from './boosterIcons'; import { boosterIcons } from './boosterIcons';
import { camouflageIcons } from './camouflageIcons'; import { camouflageIcons } from './camouflageIcons';
import { circleFlags } from './circleFlags';
import { consumableProvisionIcons } from './consumableProvisionIcons'; import { consumableProvisionIcons } from './consumableProvisionIcons';
import { currencies } from './currencies'; import { currencies } from './currencies';
import { definitions } from './definitions'; import { definitions } from './definitions';
import { equipmentIcons } from './equipmentIcons'; import { equipmentIcons } from './equipmentIcons';
import { flags } from './flags';
import { gameModeBanners } from './gameModeBanners';
import { moduleIcons } from './moduleIcons'; import { moduleIcons } from './moduleIcons';
import { scratchedFlags } from './scratchedFlags';
import { shellIcons } from './shellIcons'; import { shellIcons } from './shellIcons';
import { skillIcons } from './skillIcons'; import { skillIcons } from './skillIcons';
import { tankArmors } from './tankArmors'; import { tankArmors } from './tankArmors';
import { tankIcons } from './tankIcons'; import { tankIcons } from './tankIcons';
import { tankModels } from './tankModels'; import { tankModels } from './tankModels';
import { videos } from './videos';
if (!targets && !allTargets) throw new Error('No target(s) specified'); if (!targets && !allTargets) throw new Error('No target(s) specified');
const methods = [ const methods = [
gameModeBanners,
definitions, definitions,
videos, tankIcons,
scratchedFlags,
currencies, currencies,
skillIcons, skillIcons,
circleFlags, flags,
shellIcons, shellIcons,
moduleIcons, moduleIcons,
equipmentIcons, equipmentIcons,
consumableProvisionIcons, consumableProvisionIcons,
camouflageIcons, camouflageIcons,
boosterIcons, boosterIcons,
tankArmors,
tankModels, tankModels,
tankIcons, tankArmors,
]; ];
for (const method of methods) { for (const method of methods) {
+5 -3
View File
@@ -3,13 +3,13 @@ import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile'; import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { commitAssets } from '../../src/core/blitzkit/commitAssets'; import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles'; import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants'; import { DATA } from './constants';
export async function moduleIcons(production: boolean) { export async function moduleIcons(production: boolean) {
console.log('Building module icons...'); console.log('Building module icons...');
const changes = await Promise.all( const changes = await Promise.all(
(await readdir(`${DATA}/${POI.moduleIcons}`)) (await readdir(`${DATA}/Gfx/UI/ModulesTechTree`))
.filter( .filter(
(file) => (file) =>
!file.endsWith('@2x.packed.webp.dvpl') && file.startsWith('vehicle'), !file.endsWith('@2x.packed.webp.dvpl') && file.startsWith('vehicle'),
@@ -18,7 +18,9 @@ export async function moduleIcons(production: boolean) {
const nameRaw = file.match(/vehicle(.+)\.packed\.webp\.dvpl/)![1]; const nameRaw = file.match(/vehicle(.+)\.packed\.webp\.dvpl/)![1];
const name = nameRaw[0].toLowerCase() + nameRaw.slice(1); const name = nameRaw[0].toLowerCase() + nameRaw.slice(1);
const content = ( const content = (
await sharp(await readDVPLFile(`${DATA}/${POI.moduleIcons}/${file}`)) await sharp(
await readDVPLFile(`${DATA}/Gfx/UI/ModulesTechTree/${file}`),
)
.trim() .trim()
.toBuffer() .toBuffer()
).toString('base64'); ).toString('base64');
-31
View File
@@ -1,31 +0,0 @@
import { readdir } from 'fs/promises';
import { readBase64DVPL } from '../../src/core/blitz/readBase64DVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants';
export async function scratchedFlags(production: boolean) {
console.log('Building scratched flags...');
const flags = await readdir(`${DATA}/${POI.flags}`);
const changes = await Promise.all(
flags
.filter(
(flag) =>
flag.startsWith('flag_tutor-tank_') &&
!flag.endsWith('@2x.packed.webp.dvpl'),
)
.map(async (flag) => {
const content = await readBase64DVPL(`${DATA}/${POI.flags}/${flag}`);
const name = flag.match(/flag_tutor-tank_(.+)\.packed\.webp/)![1];
return {
content,
encoding: 'base64',
path: `flags/scratched/${name}.webp`,
} satisfies FileChange;
}),
);
await commitAssets('scratched flags', changes, production);
}
+6 -4
View File
@@ -4,24 +4,26 @@ import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL'; import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets'; import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles'; import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants'; import { DATA } from './constants';
export async function shellIcons(production: boolean) { export async function shellIcons(production: boolean) {
console.log('Building shell icons...'); console.log('Building shell icons...');
const image = sharp( const image = sharp(
await readDVPLFile( await readDVPLFile(
`${DATA}/${POI.bigShellIcons}/texture0.packed.webp.dvpl`, `${DATA}/Gfx/Shared/tank-supply/ammunition/big/texture0.packed.webp.dvpl`,
), ),
); );
const changes = await Promise.all( const changes = await Promise.all(
(await readdir(`${DATA}/${POI.bigShellIcons}`)) (await readdir(`${DATA}/Gfx/Shared/tank-supply/ammunition/big`))
.filter((file) => file.endsWith('_l.txt.dvpl')) .filter((file) => file.endsWith('_l.txt.dvpl'))
.map(async (file) => { .map(async (file) => {
const name = file.match(/(.+)_l\.txt\.dvpl/)![1]; const name = file.match(/(.+)_l\.txt\.dvpl/)![1];
const sizes = ( const sizes = (
await readStringDVPL(`${DATA}/${POI.bigShellIcons}/${file}`) await readStringDVPL(
`${DATA}/Gfx/Shared/tank-supply/ammunition/big/${file}`,
)
) )
.split('\n')[4] .split('\n')[4]
.split(' ') .split(' ')
+3 -3
View File
@@ -5,7 +5,7 @@ import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { toUniqueId } from '../../src/core/blitz/toUniqueId'; import { toUniqueId } from '../../src/core/blitz/toUniqueId';
import { commitAssets } from '../../src/core/blitzkit/commitAssets'; import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles'; import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants'; import { DATA } from './constants';
import { VehicleDefinitionList } from './definitions'; import { VehicleDefinitionList } from './definitions';
export async function tankArmors(production: boolean) { export async function tankArmors(production: boolean) {
@@ -13,14 +13,14 @@ export async function tankArmors(production: boolean) {
const changes: FileChange[] = []; const changes: FileChange[] = [];
const nodeIO = new NodeIO(); const nodeIO = new NodeIO();
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then( const nations = await readdir(`${DATA}/XML/item_defs/vehicles`).then(
(nations) => nations.filter((nation) => nation !== 'common'), (nations) => nations.filter((nation) => nation !== 'common'),
); );
await Promise.all( await Promise.all(
nations.map(async (nation) => { nations.map(async (nation) => {
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>( const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/list.xml.dvpl`, `${DATA}/XML/item_defs/vehicles/${nation}/list.xml.dvpl`,
); );
await Promise.all( await Promise.all(
+4 -4
View File
@@ -7,7 +7,7 @@ import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL'; import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets'; import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles'; import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants'; import { DATA } from './constants';
import { VehicleDefinitionList } from './definitions'; import { VehicleDefinitionList } from './definitions';
export interface TankParameters { export interface TankParameters {
@@ -46,14 +46,14 @@ export async function tankIcons(production: boolean) {
console.log('Building tank icons...'); console.log('Building tank icons...');
const changes: FileChange[] = []; const changes: FileChange[] = [];
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then( const nations = await readdir(`${DATA}/XML/item_defs/vehicles`).then(
(nations) => nations.filter((nation) => nation !== 'common'), (nations) => nations.filter((nation) => nation !== 'common'),
); );
await Promise.all( await Promise.all(
nations.map(async (nation) => { nations.map(async (nation) => {
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>( const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/list.xml.dvpl`, `${DATA}/XML/item_defs/vehicles/${nation}/list.xml.dvpl`,
); );
await Promise.all( await Promise.all(
@@ -63,7 +63,7 @@ export async function tankIcons(production: boolean) {
const nationVehicleId = tank.id; const nationVehicleId = tank.id;
const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1; const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1;
const parameters = await readYAMLDVPL<TankParameters>( const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/${POI.tankParameters}/${nation}/${tankKey}.yaml.dvpl`, `${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml.dvpl`,
); );
const smallPath = `${DATA}/${parameters.resourcesPath.smallIconPath const smallPath = `${DATA}/${parameters.resourcesPath.smallIconPath
.replace(/~res:\//, '') .replace(/~res:\//, '')
+4 -4
View File
@@ -6,7 +6,7 @@ import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { toUniqueId } from '../../src/core/blitz/toUniqueId'; import { toUniqueId } from '../../src/core/blitz/toUniqueId';
import { commitAssets } from '../../src/core/blitzkit/commitAssets'; import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles'; import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants'; import { DATA } from './constants';
import { VehicleDefinitionList } from './definitions'; import { VehicleDefinitionList } from './definitions';
import { TankParameters } from './tankIcons'; import { TankParameters } from './tankIcons';
@@ -14,7 +14,7 @@ export async function tankModels(production: boolean) {
console.log('Building tank models...'); console.log('Building tank models...');
const nodeIO = new NodeIO(); const nodeIO = new NodeIO();
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then( const nations = await readdir(`${DATA}/XML/item_defs/vehicles`).then(
(nations) => nations.filter((nation) => nation !== 'common'), (nations) => nations.filter((nation) => nation !== 'common'),
); );
@@ -22,7 +22,7 @@ export async function tankModels(production: boolean) {
const nation = nations[nationIndex]; const nation = nations[nationIndex];
const changes: FileChange[] = []; const changes: FileChange[] = [];
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>( const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/list.xml.dvpl`, `${DATA}/XML/item_defs/vehicles/${nation}/list.xml.dvpl`,
); );
console.log(`Building models for ${nation}`); console.log(`Building models for ${nation}`);
@@ -33,7 +33,7 @@ export async function tankModels(production: boolean) {
const id = toUniqueId(nation, tank.id); const id = toUniqueId(nation, tank.id);
const parameters = await readYAMLDVPL<TankParameters>( const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/${POI.tankParameters}/${nation}/${tankKey}.yaml.dvpl`, `${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml.dvpl`,
); );
const model = await extractModel( const model = await extractModel(
DATA, DATA,
-142
View File
@@ -1,142 +0,0 @@
import { readdir } from 'fs/promises';
import { google } from 'googleapis';
import { cloneDeep, uniqBy } from 'lodash';
import { parse as parseYaml } from 'yaml';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { toUniqueId } from '../../src/core/blitz/toUniqueId';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { VideoDefinitions } from '../../src/core/blitzkit/videos';
import { DATA, POI } from './constants';
import { BlitzStrings, botPattern, VehicleDefinitionList } from './definitions';
const youtubers = [
'UCKBYXp4Xn2I2tL1UL4fpbhw', // droodles
'UCrQ-Dy8lVsm11u8pCJ8W7Tw', // fatness
'UCxRpZqMElssTdPYDK3x7wyg', // n90
];
export async function videos(production: boolean) {
console.log('Building videos...');
const currentVideos = await fetch(
'https://raw.githubusercontent.com/tresabhi/blitzkit-assets/main/definitions/videos.csv',
)
.then((response) => response.text())
.then((lines) => {
return lines
.split('\n')
.map((line) => {
const [idString, lastUpdatedString, ...videos] = line.split(',');
const id = Number(idString);
const lastUpdated = Number(lastUpdatedString);
return { id, lastUpdated, videos };
})
.reduce<VideoDefinitions>(
(accumulator, { id, lastUpdated, videos }) => {
accumulator[id] = { lastUpdated, videos };
return accumulator;
},
{},
);
});
const auth = await google.auth.getClient({
scopes: ['https://www.googleapis.com/auth/youtube.readonly'],
});
const youtube = google.youtube({ version: 'v3', auth });
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then(
(nations) => nations.filter((nation) => nation !== 'common'),
);
const stringsPreInstalled = await readYAMLDVPL<BlitzStrings>(
`${DATA}/${POI.strings}/en.yaml.dvpl`,
);
const stringsCache = await fetch(POI.cachedStrings)
.then((response) => response.text())
.then((string) => parseYaml(string) as BlitzStrings);
const strings = {
...stringsPreInstalled,
...stringsCache,
};
const tanks: { id: number; name: string; tier: number }[] = [];
await Promise.all(
nations.map(async (nation) => {
const tankList = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/list.xml.dvpl`,
);
await Promise.all(
Object.entries(tankList.root).map(async ([tankKey, tank]) => {
if (botPattern.test(tankKey)) return null;
const id = toUniqueId(nation, tank.id);
const name =
(tank.shortUserString
? strings[tank.shortUserString]
: undefined) ?? strings[tank.userString];
const tier = tank.level;
tanks.push({ id, name, tier });
}),
);
}),
);
const tanksSanitized = tanks
.sort((a, b) => b.tier - a.tier)
.sort((a, b) => {
const lastUpdatedA = currentVideos[a.id]?.lastUpdated ?? 0;
const lastUpdatedB = currentVideos[b.id]?.lastUpdated ?? 0;
return lastUpdatedA - lastUpdatedB;
});
const videos: VideoDefinitions = cloneDeep(currentVideos);
let done = 0;
for (const tank of tanksSanitized) {
try {
const results = await youtube.search.list({
part: ['snippet'],
q: `World of Tanks Blitz ${tank.name}`,
});
if (!results.data.items || results.data.items.length === 0) continue;
const items = uniqBy(
results.data.items.filter(
(item) =>
item.snippet?.channelId &&
item.id?.videoId &&
youtubers.includes(item.snippet.channelId),
),
(item) => item.snippet!.channelId,
);
videos[tank.id] = {
lastUpdated: Math.round(Date.now() / 1000),
videos: items.map((item) => item.id!.videoId!),
};
console.log(
`${++done} / ${tanks.length} (${Math.round(100 * (done / tanks.length))}%) done; found ${items.length} for ${tank.name}`,
);
await new Promise((resolve) => setTimeout(resolve, 1000 / 5));
} catch (error) {
console.warn('An error occurred, safely exiting prematurely...', error);
break;
}
}
const content = Object.entries(videos)
.map(
([id, { videos, lastUpdated }]) =>
`${id},${lastUpdated},${videos.join(',')}`,
)
.join('\n');
await commitAssets(
'videos',
[{ content, encoding: 'utf-8', path: 'definitions/videos.csv' }],
production,
);
}
+135
View File
@@ -0,0 +1,135 @@
import { readdir } from 'fs/promises';
import { google } from 'googleapis';
import { cloneDeep, uniqBy } from 'lodash';
import { parse as parseYaml } from 'yaml';
import { youtubers } from '../src/constants/youtubers';
import { readXMLDVPL } from '../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../src/core/blitz/readYAMLDVPL';
import { toUniqueId } from '../src/core/blitz/toUniqueId';
import { asset } from '../src/core/blitzkit/asset';
import { commitAssets } from '../src/core/blitzkit/commitAssets';
import { Reviews, Video } from '../src/core/blitzkit/reviews';
import { decode } from '../src/core/protobuf/decode';
import { encode } from '../src/core/protobuf/encode';
import { DATA } from './buildAssets/constants';
import {
BlitzStrings,
botPattern,
VehicleDefinitionList,
} from './buildAssets/definitions';
const MAX_QUERIES = 128;
console.log('Finding reviews...');
const currentReviews = await fetch(asset('definitions/reviews.pb', false))
.then((response) => response.arrayBuffer())
.then((buffer) =>
decode<Reviews>('reviews/blitzkit.Reviews', new Uint8Array(buffer)),
);
const auth = await google.auth.getClient({
scopes: ['https://www.googleapis.com/auth/youtube.readonly'],
});
const youtube = google.youtube({ version: 'v3', auth });
const nations = await readdir(`${DATA}/XML/item_defs/vehicles`).then(
(nations) => nations.filter((nation) => nation !== 'common'),
);
const stringsPreInstalled = await readYAMLDVPL<BlitzStrings>(
`${DATA}/Strings/en.yaml.dvpl`,
);
const stringsCache = await fetch(
'https://stufficons.wgcdn.co/localizations/en.yaml',
)
.then((response) => response.text())
.then((string) => parseYaml(string) as BlitzStrings);
const strings = {
...stringsPreInstalled,
...stringsCache,
};
const tanks: { id: number; name: string; tier: number }[] = [];
await Promise.all(
nations.map(async (nation) => {
const tankList = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/XML/item_defs/vehicles/${nation}/list.xml.dvpl`,
);
await Promise.all(
Object.entries(tankList.root).map(async ([tankKey, tank]) => {
if (botPattern.test(tankKey)) return null;
const id = toUniqueId(nation, tank.id);
const name =
(tank.shortUserString ? strings[tank.shortUserString] : undefined) ??
strings[tank.userString];
const tier = tank.level;
tanks.push({ id, name, tier });
}),
);
}),
);
const tanksSanitized = tanks
.sort((a, b) => b.tier - a.tier)
.sort((a, b) => {
const lastUpdatedA = currentReviews.reviews[a.id]?.last_updated ?? 0;
const lastUpdatedB = currentReviews.reviews[b.id]?.last_updated ?? 0;
return lastUpdatedA - lastUpdatedB;
});
const reviews: Reviews = cloneDeep(currentReviews);
let done = 0;
for (const tank of tanksSanitized) {
if (done >= MAX_QUERIES) {
console.log(`Limit reached with ${done} queries; stopping...`);
break;
}
try {
const results = await youtube.search.list({
part: ['snippet'],
q: `World of Tanks Blitz ${tank.name}`,
});
if (!results.data.items || results.data.items.length === 0) continue;
const items = uniqBy(
results.data.items.filter(
(item) =>
item.snippet?.channelId &&
item.id?.videoId &&
youtubers.some(({ id }) => id === item.snippet?.channelId),
),
(item) => item.snippet!.channelId,
);
reviews.reviews[tank.id] = {
last_updated: Date.now(),
videos: items.map(
(item) =>
({
id: item.id!.videoId!,
author: item.snippet!.channelId!,
}) satisfies Video,
),
};
console.log(`${++done} done; found ${items.length} for ${tank.name}`);
await new Promise((resolve) => setTimeout(resolve, 1000 / 5));
} catch (error) {
console.warn(`Limit reached with ${done} queries`);
break;
}
}
const content = await encode('reviews/blitzkit.Reviews', reviews).then(
(array) => Buffer.from(array).toString('base64'),
);
await commitAssets(
'reviews',
[{ content, encoding: 'base64', path: 'definitions/reviews.pb' }],
true,
);
+2 -2
View File
@@ -18,13 +18,13 @@ await fetch(
}>, }>,
) )
.then(({ assets }) => { .then(({ assets }) => {
const asset = assets.find(({ name }) => name.includes('windows-x64')); const asset = assets.find(({ name }) => name.includes('linux-x64'));
if (!asset) throw new Error('No asset found'); if (!asset) throw new Error('No asset found');
return fetch(asset.browser_download_url); return fetch(asset.browser_download_url);
}) })
.then((response) => response.arrayBuffer()) .then((response) => response.arrayBuffer())
.then((arrayBuffer) => .then((arrayBuffer) =>
writeFile('temp/depotDownloader.zip', Buffer.from(arrayBuffer)), writeFile('temp/depotDownloader.zip', new Uint8Array(arrayBuffer)),
); );
console.log('Unzipping...'); console.log('Unzipping...');
+21 -12
View File
@@ -9,13 +9,18 @@ import * as App from '../../stores/app';
import { CURRENT_POLICIES_AGREEMENT_INDEX } from '../../stores/app/constants'; import { CURRENT_POLICIES_AGREEMENT_INDEX } from '../../stores/app/constants';
import { PatreonAuthResponse } from '../auth/[provider]/page'; import { PatreonAuthResponse } from '../auth/[provider]/page';
interface Extension { type Extension =
data: { | {
access_token: string; status: 'ok';
account_id: number; data: {
expires_at: number; access_token: string;
}; account_id: number;
} expires_at: number;
};
}
| {
status: 'error';
};
/** /**
* Wargaming: 14 (refresh: 7) * Wargaming: 14 (refresh: 7)
@@ -80,11 +85,15 @@ export function Checks() {
.then((response) => response.json() as Promise<Extension>) .then((response) => response.json() as Promise<Extension>)
.then((json) => { .then((json) => {
mutateApp((draft) => { mutateApp((draft) => {
draft.logins.wargaming = { if (json.status === 'error') {
id: wargaming.id, draft.logins.wargaming = undefined;
token: json.data.access_token, } else {
expires: json.data.expires_at * 1000, draft.logins.wargaming = {
}; id: wargaming.id,
token: json.data.access_token,
expires: json.data.expires_at * 1000,
};
}
}); });
}); });
} }
+4 -3
View File
@@ -44,7 +44,8 @@ export function ToolCard({ tool, style, ...props }: ToolCardProps) {
{...props} {...props}
> >
<Link <Link
href={`/tools/${tool.id}`} href={tool.href ?? `/tools/${tool.id}`}
target={tool.href ? '_blank' : undefined}
style={{ style={{
display: 'flex', display: 'flex',
width: '100%', width: '100%',
@@ -78,7 +79,7 @@ export function ToolCard({ tool, style, ...props }: ToolCardProps) {
width="100%" width="100%"
direction={{ direction={{
initial: 'column', initial: 'column',
sm: 'row', xs: 'row',
}} }}
style={{ style={{
backgroundColor: 'var(--color-panel-translucent)', backgroundColor: 'var(--color-panel-translucent)',
@@ -91,7 +92,7 @@ export function ToolCard({ tool, style, ...props }: ToolCardProps) {
justify="center" justify="center"
align={{ align={{
initial: 'center', initial: 'center',
sm: 'start', xs: 'start',
}} }}
> >
<Heading <Heading
+3 -3
View File
@@ -7,7 +7,7 @@ import { Roboto_Flex } from 'next/font/google';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { ReactNode, Suspense } from 'react'; import { ReactNode, Suspense } from 'react';
import { Footer } from '../components/Footer'; import { Footer } from '../components/Footer';
import Navbar, { NAVBAR_HEIGHT } from '../components/Navbar'; import Navbar from '../components/Navbar';
import { Party3 } from '../components/Party3'; import { Party3 } from '../components/Party3';
import * as App from '../stores/app'; import * as App from '../stores/app';
import { Checks } from './components/Checks'; import { Checks } from './components/Checks';
@@ -48,7 +48,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
</> </>
)} )}
<meta property="og:site_name" content="BlitzKit" /> <meta property="og:site_name" content="BlitzKit" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.png" />
<Party3 /> <Party3 />
</head> </head>
@@ -71,7 +71,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
direction="column" direction="column"
style={{ style={{
minHeight: '100vh', minHeight: '100vh',
paddingTop: hideNav ? undefined : NAVBAR_HEIGHT, // paddingTop: isEmbed ? undefined : NAVBAR_HEIGHT,
}} }}
> >
{!hideNav && <Navbar />} {!hideNav && <Navbar />}
+1 -1
View File
@@ -5,7 +5,7 @@ import PageWrapper from '../../components/PageWrapper';
import * as App from '../../stores/app'; import * as App from '../../stores/app';
export default function Page() { export default function Page() {
const developerMode = App.use((state) => state.developerMode); const developerMode = App.useDeferred(false, (state) => state.developerMode);
const appStore = App.useStore(); const appStore = App.useStore();
return ( return (
@@ -1,6 +1,6 @@
import { ComponentPlaceholderIcon } from '@radix-ui/react-icons'; import { ComponentPlaceholderIcon } from '@radix-ui/react-icons';
import { Button, Flex, Popover, Table } from '@radix-ui/themes'; import { Button, Flex, Popover, Slider, Table, Text } from '@radix-ui/themes';
import { times } from 'lodash'; import { debounce, times } from 'lodash';
import { use } from 'react'; import { use } from 'react';
import { ConsumablesManager } from '../../../../components/ConsumablesManager'; import { ConsumablesManager } from '../../../../components/ConsumablesManager';
import { CrewSkillManager } from '../../../../components/CrewSkillManager'; import { CrewSkillManager } from '../../../../components/CrewSkillManager';
@@ -17,7 +17,7 @@ import { equipmentDefinitions } from '../../../../core/blitzkit/equipmentDefinit
import { provisionDefinitions } from '../../../../core/blitzkit/provisionDefinitions'; import { provisionDefinitions } from '../../../../core/blitzkit/provisionDefinitions';
import { skillDefinitions } from '../../../../core/blitzkit/skillDefinitions'; import { skillDefinitions } from '../../../../core/blitzkit/skillDefinitions';
import { TankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics'; import { TankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
import { BlitzkitButtonWatermark } from '../../../../icons/BlitzkitButtonWatermark'; import { BlitzkitButtonGray } from '../../../../icons/BlitzkitButtonGray';
import { theme } from '../../../../stitches.config'; import { theme } from '../../../../stitches.config';
import * as CompareEphemeral from '../../../../stores/compareEphemeral'; import * as CompareEphemeral from '../../../../stores/compareEphemeral';
import { EquipmentMatrix } from '../../../../stores/duel'; import { EquipmentMatrix } from '../../../../stores/duel';
@@ -56,7 +56,7 @@ export function CompareTable({ stats }: CompareTableProps) {
align="center" align="center"
justify="center" justify="center"
> >
<BlitzkitButtonWatermark width={64} height={64} /> <BlitzkitButtonGray opacity="0.25" width={64} height={64} />
<InsertionMarker index={0} /> <InsertionMarker index={0} />
</Flex> </Flex>
</StickyColumnHeaderCell> </StickyColumnHeaderCell>
@@ -167,6 +167,7 @@ export function CompareTable({ stats }: CompareTableProps) {
engine, engine,
track, track,
consumables, consumables,
crewMastery,
}, },
index, index,
) => { ) => {
@@ -509,7 +510,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<ConsumablesManager <ConsumablesManager
consumables={consumablesList} consumables={consumablesList}
selected={consumables} selected={consumables}
onChange={(consumables) => { onConsumablesChange={(consumables) => {
mutateCompareEphemeral((draft) => { mutateCompareEphemeral((draft) => {
draft.members[index].consumables = consumables; draft.members[index].consumables = consumables;
}); });
@@ -532,6 +533,23 @@ export function CompareTable({ stats }: CompareTableProps) {
</Popover.Content> </Popover.Content>
</Popover.Root> </Popover.Root>
</Flex> </Flex>
<Flex gap="2" width="100%" align="center">
<Text>{Math.round(crewMastery * 100)}%</Text>
<Flex flexGrow="1">
<Slider
defaultValue={[crewMastery]}
min={0.5}
max={1}
step={1 / 100}
onValueChange={debounce(([value]: number[]) => {
mutateCompareEphemeral((draft) => {
draft.members[index].crewMastery = value;
});
}, 500)}
/>
</Flex>
</Flex>
</Flex> </Flex>
</Table.Cell> </Table.Cell>
); );
@@ -1,16 +0,0 @@
import { Tabs, Text } from '@radix-ui/themes';
import { TriggerProps } from '@radix-ui/themes/dist/cjs/components/tabs';
export function FlippedTrigger({ children, ...props }: TriggerProps) {
return (
<Tabs.Trigger {...props}>
<Text
style={{
transform: 'scaleY(-1)',
}}
>
{children}
</Text>
</Tabs.Trigger>
);
}
-322
View File
@@ -1,322 +0,0 @@
'use client';
import { TimerIcon } from '@radix-ui/react-icons';
import {
Box,
Button,
Flex,
Heading,
Link,
Popover,
SegmentedControl,
Text,
Theme,
} from '@radix-ui/themes';
import { useState } from 'react';
import { DatePicker } from '../../../../components/DatePicker';
import { Speedometer } from '../../../../components/Speedometer';
import { getAccountInfo } from '../../../../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../../../../core/blitz/getClanAccountInfo';
import { idToRegion } from '../../../../core/blitz/idToRegion';
import { parseBkni } from '../../../../core/blitzkit/parseBkni';
import { PeriodType } from '../../../../core/discord/addPeriodSubCommands';
import {
BKNI_COLORS,
BKNI_PERCENTILES,
} from '../../../../core/statistics/getBkniPercentile';
import { useAwait } from '../../../../hooks/useAwait';
import strings from '../../../../lang/en-US.json';
export default function Page({ params }: { params: { id: string } }) {
const id = parseInt(params.id);
const region = idToRegion(id);
const accountInfo = useAwait(getAccountInfo(region, id));
const clanAccountInfo = useAwait(getClanAccountInfo(region, id, ['clan']));
// const bkni = ((Math.round(Date.now() / 1000 / 60) / 100) % 2) - 1;
const bkni = 0.81;
const winrate = 0.6;
const [period, setPeriod] = useState<PeriodType>('30');
const { bkniColor, bkniMetric, bkniFraction, bkniPercentile } =
parseBkni(bkni);
const [customFrom, setCustomFrom] = useState<Date>(
new Date(Date.now() - 29 * 24 * 60 * 60 * 1000),
);
const stats = [
['Damage', '3,203'],
['Tier', '9.83'],
['Survival', '70%'],
['Kills', '1.54'],
['Damage ratio', '2.45'],
['Accuracy', '91%'],
];
const [customTo, setCustomTo] = useState<Date>(new Date());
const [fromSelectorOpen, setFromSelectorOpen] = useState(false);
const [toSelectorOpen, setToSelectorOpen] = useState(false);
return (
<>
<Theme accentColor={bkniColor}>
<Flex
direction="column"
align="center"
justify="center"
py="8"
gap="6"
style={{
background: `linear-gradient(var(--${bkniColor}-a3), var(--${bkniColor}-a1))`,
}}
>
<Flex direction="column" align="center">
<Heading
size={{
initial: '8',
sm: '9',
}}
align="center"
>
{accountInfo.nickname}
</Heading>
<Text color="gray" align="center">
{clanAccountInfo?.clan && `[${clanAccountInfo.clan.tag}] • `}
{
(clanAccountInfo?.clan
? strings.common.regions.short
: strings.common.regions.normal)[region]
}
</Text>
</Flex>
<Flex direction="column" align="center" gap="3">
<SegmentedControl.Root
value={period}
onValueChange={(value) => setPeriod(value as PeriodType)}
>
<SegmentedControl.Item value={'today' satisfies PeriodType}>
Today
</SegmentedControl.Item>
<SegmentedControl.Item value={'30' satisfies PeriodType}>
30 days
</SegmentedControl.Item>
<SegmentedControl.Item value={'career' satisfies PeriodType}>
Career
</SegmentedControl.Item>
<SegmentedControl.Item value={'custom' satisfies PeriodType}>
Custom
</SegmentedControl.Item>
</SegmentedControl.Root>
{period === 'custom' && (
<Text>
From{' '}
<Popover.Root
open={fromSelectorOpen}
onOpenChange={setFromSelectorOpen}
>
<Popover.Trigger>
<Link underline="always" href="#">
{customFrom.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</Link>
</Popover.Trigger>
<Popover.Content>
<DatePicker
defaultDate={customFrom}
onDateChange={(date) => {
setCustomFrom(date);
setFromSelectorOpen(false);
}}
/>
</Popover.Content>
</Popover.Root>{' '}
to{' '}
<Popover.Root
open={toSelectorOpen}
onOpenChange={setToSelectorOpen}
>
<Popover.Trigger>
<Link underline="always" href="#">
{customTo.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</Link>
</Popover.Trigger>
<Popover.Content>
<DatePicker
defaultDate={customTo}
onDateChange={(date) => {
setCustomTo(date);
setToSelectorOpen(false);
}}
/>
</Popover.Content>
</Popover.Root>
</Text>
)}
</Flex>
</Flex>
<Flex justify="center" py="8" gap="6">
<Speedometer
value={`${bkniMetric}`}
color={bkniColor}
fill={bkniFraction}
label={
<Popover.Root>
<Popover.Trigger>
<Button
style={{ position: 'relative' }}
color="gray"
size="2"
variant="ghost"
>
<TimerIcon />
{strings.common.bkni_percentile[bkniPercentile]}
</Button>
</Popover.Trigger>
<Popover.Content align="center">
<Flex
direction="column"
style={{
width: 'fit-content',
}}
>
<Flex direction="column" mb="3" align="center">
<Heading weight="bold" size="5">
BkNI
</Heading>
<Text size="2" color="gray" wrap="wrap">
A next generation performance metric
</Text>
</Flex>
{BKNI_PERCENTILES.map(({ percentile, min }, index) => (
<Button
radius="none"
color={BKNI_COLORS[percentile]}
key={percentile}
>
<Flex
align="center"
justify="between"
width="100%"
gap="2"
>
<Text>
{strings.common.bkni_percentile[percentile]}
</Text>
<Text size="1">
{min}% - {BKNI_PERCENTILES[index + 1]?.min ?? 100}%
</Text>
</Flex>
</Button>
))}
</Flex>
</Popover.Content>
</Popover.Root>
}
/>
<Speedometer
value={
<Flex align="center" gap="1">
{(winrate * 100).toFixed(0)}
<Text size="3" color="gray">
%
</Text>
</Flex>
}
color="green"
fill={0.5 * (Math.cbrt(2 * winrate - 1) + 1)}
label={<Text color="gray">Winrate</Text>}
/>
</Flex>
<Flex style={{ flex: 1 }} wrap="wrap" gap="2" justify="center">
{stats.map((stat) => (
<Flex minWidth="96px" direction="column" align="center">
<Text size="6">{stat[1]}</Text>
<Text color="gray" size="2">
{stat[0]}
</Text>
</Flex>
))}
</Flex>
</Theme>
<Box flexGrow="1" />
{/* <Flex gap="4">
<Link href="/tools/tankopedia">
<Button variant="ghost" size="1" ml="-1">
<ChevronLeftIcon />
Back
</Button>
</Link>
</Flex> */}
{/* <Flex style={{ flex: 1 }} justify="center">
</Flex>
*/}
{/* <PageWrapper
p="6"
pt="9"
size={1600}
noFlex1
position="relative"
color={bkniColor}
containerProps={{
style: {
background: `linear-gradient(var(--${bkniColor}-a2), var(--${bkniColor}-a1))`,
},
}}
>
<Tabs.Root
style={{
position: 'absolute',
top: 0,
left: '50%',
transform: 'scaleY(-1) translateX(-50%)',
}}
value={`${period}`}
onValueChange={(value) => {
setPeriod(value === 'custom' ? 'custom' : Number(value));
}}
>
<Tabs.List justify="center">
<FlippedTrigger value="1">Today</FlippedTrigger>
<FlippedTrigger value="30">30 days</FlippedTrigger>
<FlippedTrigger value="Infinity">Career</FlippedTrigger>
<FlippedTrigger value="custom">Custom</FlippedTrigger>
</Tabs.List>
</Tabs.Root>
</PageWrapper>
<Flex flexGrow="1" justify="center" align="center" p="4">
<Callout.Root color="red" mt="4">
<Callout.Icon>
<ExclamationTriangleIcon />
</Callout.Icon>
<Callout.Text>
We haven't tracked long enough to provide statistics. Please check
back after a few days.
</Callout.Text>
</Callout.Root>
</Flex>
{isTracking && <Box flexGrow="1" />} */}
</>
);
}
-110
View File
@@ -1,110 +0,0 @@
'use client';
import { ArrowDownIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons';
import { Button, Card, Flex, Text, TextField } from '@radix-ui/themes';
import { useRef, useState } from 'react';
import { Link } from '../../../components/Link';
import PageWrapper from '../../../components/PageWrapper';
import { UNLOCALIZED_REGION_NAMES_SHORT } from '../../../constants/regions';
import searchPlayersAcrossRegions, {
AccountListWithServer,
} from '../../../core/blitz/searchPlayersAcrossRegions';
export default function Page() {
const input = useRef<HTMLInputElement>(null);
const [showSearch, setShowSearch] = useState(false);
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<AccountListWithServer>([]);
async function search() {
if (!input.current) return;
setSearchResults(await searchPlayersAcrossRegions(input.current.value, 9));
setSearching(false);
}
return (
<PageWrapper>
<Flex
style={{ flex: 1 }}
align="center"
justify="center"
direction="column"
gap="3"
>
<Flex gap="2" align="center">
<Text color="gray">Look up a player to get started</Text>
<ArrowDownIcon />
</Flex>
<TextField.Root
size="3"
style={{
position: 'relative',
width: '75vw',
maxWidth: 480,
}}
ref={input}
placeholder="Search players..."
onChange={() => {
if (!input.current) return;
const sanitized = input.current.value.trim();
if (sanitized.length > 0) {
setShowSearch(true);
setSearching(true);
search();
} else {
setShowSearch(false);
}
}}
>
<TextField.Slot>
<MagnifyingGlassIcon />
</TextField.Slot>
{showSearch && (
<Card
mt="2"
style={{
position: 'absolute',
top: '100%',
left: 0,
width: '100%',
}}
>
{searching && (
<Flex justify="center">
<Text color="gray">Searching...</Text>
</Flex>
)}
{!searching && searchResults.length === 0 && (
<Flex justify="center">
<Text color="gray">No players found</Text>
</Flex>
)}
{!searching && searchResults.length > 0 && (
<Flex direction="column" gap="2">
{searchResults.map((player) => (
<Link
key={player.account_id}
href={`/tools/player-stats/${player.account_id}`}
>
<Button key={player.account_id} variant="ghost">
{player.nickname} (
{UNLOCALIZED_REGION_NAMES_SHORT[player.region]})
</Button>
</Link>
))}
</Flex>
)}
</Card>
)}
</TextField.Root>
</Flex>
</PageWrapper>
);
}
@@ -1,15 +1,21 @@
import { Flex } from '@radix-ui/themes'; import { Flex, FlexProps } from '@radix-ui/themes';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
interface ConfigurationChildWrapperProps { type ConfigurationChildWrapperProps = FlexProps & {
children: ReactNode; children: ReactNode;
} };
export function ConfigurationChildWrapper({ export function ConfigurationChildWrapper({
children, children,
...props
}: ConfigurationChildWrapperProps) { }: ConfigurationChildWrapperProps) {
return ( return (
<Flex gap="2" direction="column"> <Flex
gap="2"
direction="column"
align={{ initial: 'center', sm: 'start' }}
{...props}
>
{children} {children}
</Flex> </Flex>
); );
@@ -77,11 +77,12 @@ export function Consumables() {
hasConsumableDeliverySystem={hasConsumableDeliverySystem} hasConsumableDeliverySystem={hasConsumableDeliverySystem}
hasHighEndConsumables={hasHighEndConsumables} hasHighEndConsumables={hasHighEndConsumables}
timers timers
onChange={(consumables) => { onConsumablesChange={(consumables) => {
mutateDuel((draft) => { mutateDuel((draft) => {
draft.protagonist.consumables = consumables; draft.protagonist.consumables = consumables;
}); });
}} }}
justify={{ initial: 'center', sm: 'start' }}
/> />
</ConfigurationChildWrapper> </ConfigurationChildWrapper>
); );
@@ -47,8 +47,6 @@ export function Miscellaneous() {
left: '50%', left: '50%',
transform: 'translate(calc(-50% + 4px), calc(-50% + 4px))', transform: 'translate(calc(-50% + 4px), calc(-50% + 4px))',
}} }}
first
last
onClick={() => { onClick={() => {
mutateDuel((draft) => { mutateDuel((draft) => {
draft.protagonist.camouflage = !camouflage; draft.protagonist.camouflage = !camouflage;
@@ -59,8 +57,6 @@ export function Miscellaneous() {
<GenericTankComponentButton <GenericTankComponentButton
icon={asset('icons/boosters/equipment.webp')} icon={asset('icons/boosters/equipment.webp')}
selected={cooldownBooster > 0} selected={cooldownBooster > 0}
first
last
banner={ banner={
cooldownBooster === 0 cooldownBooster === 0
? undefined ? undefined
@@ -214,9 +214,7 @@ export function Modules() {
align="center" align="center"
direction="column" direction="column"
gap="2" gap="2"
style={{ position="relative"
position: 'relative',
}}
> >
{unlock.cost.value > 0 && ( {unlock.cost.value > 0 && (
<> <>
@@ -310,6 +308,7 @@ export function Modules() {
<ConfigurationChildWrapper> <ConfigurationChildWrapper>
<Flex gap="4" align="center"> <Flex gap="4" align="center">
<Heading size="4">Modules</Heading> <Heading size="4">Modules</Heading>
{hasUpgrades && ( {hasUpgrades && (
<> <>
<Button <Button
@@ -348,7 +347,7 @@ export function Modules() {
)} )}
</Flex> </Flex>
<Flex gap="2"> <Flex gap="2" wrap="wrap" gapY="6">
{tree('engine', [ {tree('engine', [
{ {
cost: { type: 'xp', value: 0 }, cost: { type: 'xp', value: 0 },
@@ -7,12 +7,12 @@ import {
Slider, Slider,
Text, Text,
TextField, TextField,
Tooltip,
} from '@radix-ui/themes'; } from '@radix-ui/themes';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { Fragment, use, useEffect, useRef, useState } from 'react'; import { Fragment, use, useEffect, useRef, useState } from 'react';
import { lerp } from 'three/src/math/MathUtils'; import { lerp } from 'three/src/math/MathUtils';
import { Ad, AdType } from '../../../../../../components/Ad'; import { Ad, AdType } from '../../../../../../components/Ad';
import { Link } from '../../../../../../components/Link';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits'; import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { isExplosive } from '../../../../../../core/blitz/isExplosive'; import { isExplosive } from '../../../../../../core/blitz/isExplosive';
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient'; import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
@@ -44,6 +44,9 @@ export function Characteristics() {
const hasImprovedVentilation = useEquipment(102); const hasImprovedVentilation = useEquipment(102);
const crewMastery = Duel.use((state) => state.protagonist.crewMastery); const crewMastery = Duel.use((state) => state.protagonist.crewMastery);
const [penetrationDistance, setPenetrationDistance] = useState(250); const [penetrationDistance, setPenetrationDistance] = useState(250);
const setPenetrationDistanceDebounced = debounce((value: number) => {
setPenetrationDistance(value);
}, 500);
const provisions = Duel.use((state) => state.protagonist.provisions); const provisions = Duel.use((state) => state.protagonist.provisions);
const provisionCrewBonus = const provisionCrewBonus =
@@ -145,32 +148,33 @@ export function Characteristics() {
<Flex> <Flex>
{gun.shells.map((thisShell, shellIndex) => ( {gun.shells.map((thisShell, shellIndex) => (
<IconButton <Tooltip content={thisShell.name} key={thisShell.id}>
color={thisShell.id === shell.id ? undefined : 'gray'} <IconButton
variant="soft" color={thisShell.id === shell.id ? undefined : 'gray'}
key={thisShell.id} variant="soft"
style={{ style={{
borderTopLeftRadius: shellIndex === 0 ? undefined : 0, borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
borderBottomLeftRadius: shellIndex === 0 ? undefined : 0, borderBottomLeftRadius: shellIndex === 0 ? undefined : 0,
borderTopRightRadius: borderTopRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0, shellIndex === gun.shells.length - 1 ? undefined : 0,
borderBottomRightRadius: borderBottomRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0, shellIndex === gun.shells.length - 1 ? undefined : 0,
marginLeft: shellIndex === 0 ? 0 : -1, marginLeft: shellIndex === 0 ? 0 : -1,
}} }}
onClick={() => { onClick={() => {
mutateDuel((draft) => { mutateDuel((draft) => {
draft.protagonist.shell = thisShell; draft.protagonist.shell = thisShell;
}); });
}} }}
> >
<img <img
alt={thisShell.name} alt={thisShell.name}
width={16} width={16}
height={16} height={16}
src={asset(`icons/shells/${thisShell.icon}.webp`)} src={asset(`icons/shells/${thisShell.icon}.webp`)}
/> />
</IconButton> </IconButton>
</Tooltip>
))} ))}
</Flex> </Flex>
</Flex> </Flex>
@@ -179,35 +183,22 @@ export function Characteristics() {
{stats.dpm} {stats.dpm}
</InfoWithDelta> </InfoWithDelta>
{gun.type === 'autoReloader' && ( {gun.type === 'autoReloader' && (
<> <InfoWithDelta
<InfoWithDelta decimals={0} indent name="Maximum" unit="hp / min"> decimals={0}
{stats.dpmMaximum!} indent
</InfoWithDelta> name="Effective at 60s"
<InfoWithDelta unit="hp / min"
decimals={0} >
indent {stats.dpmEffective!}
name="Effective at 60s" </InfoWithDelta>
unit="hp / min"
>
{stats.dpmEffective!}
</InfoWithDelta>
<Info
indent
name={
<Link target="_blank" href="/docs/guide/dpm">
What's the difference?
</Link>
}
/>
</>
)} )}
{gun.type !== 'regular' && ( {gun.type !== 'regular' && (
<InfoWithDelta name="Shells">{stats.shells}</InfoWithDelta> <InfoWithDelta name="Shells">{stats.shells}</InfoWithDelta>
)} )}
{gun.type === 'autoReloader' ? ( {gun.type === 'autoReloader' ? (
<> <>
<Info indent name="Most optimal"> <Info indent name="Most optimal shell index">
{stats.mostOptimalShellIndex} {stats.mostOptimalShellIndex! + 1}
</Info> </Info>
<Info name="Shell reloads" unit="s" /> <Info name="Shell reloads" unit="s" />
{stats.shellReloads!.map((reload, index) => ( {stats.shellReloads!.map((reload, index) => (
@@ -269,9 +260,13 @@ export function Characteristics() {
max={500} max={500}
style={{ flex: 1 }} style={{ flex: 1 }}
defaultValue={[penetrationDistance]} defaultValue={[penetrationDistance]}
onValueChange={debounce(([value]) => { onValueChange={([value]) => {
setPenetrationDistance(value); setPenetrationDistanceDebounced(value);
}, 500)}
if (!penetrationDistanceInput.current) return;
penetrationDistanceInput.current.value = value.toString();
}}
/> />
<TextField.Root <TextField.Root
style={{ width: 64 }} style={{ width: 64 }}
@@ -477,15 +472,18 @@ export function Characteristics() {
> >
{(member.type === 'commander' {(member.type === 'commander'
? commanderMastery ? commanderMastery
: crewMemberMastery) * 100} : commanderMastery * 1.1) * 100}
</InfoWithDelta> </InfoWithDelta>
{member.substitute && ( {member.substitute && (
<Info <InfoWithDelta
key={`${member.type}-substitute`} key={`${member.type}-substitute`}
decimals={0}
unit="%"
indent indent
name={ name={
<> <>
<Flex align="center" gap="1"> <Flex align="center" gap="1" display="inline-flex">
<AccessibilityIcon /> <AccessibilityIcon />
{member.substitute {member.substitute
.map((sub, index) => .map((sub, index) =>
@@ -495,7 +493,9 @@ export function Characteristics() {
</Flex> </Flex>
</> </>
} }
/> >
{commanderMastery * 1.05 * 100}
</InfoWithDelta>
)} )}
</Fragment> </Fragment>
); );
@@ -8,7 +8,7 @@ export function HistorySection() {
if (!tank.description) return null; if (!tank.description) return null;
return ( return (
<PageWrapper noFlex1> <PageWrapper noFlex1 align="center">
<Heading>History of the {tank.name}</Heading> <Heading>History of the {tank.name}</Heading>
<Text>{tank.description}</Text> <Text>{tank.description}</Text>
</PageWrapper> </PageWrapper>
@@ -0,0 +1,9 @@
import { useFrame } from '@react-three/fiber';
export function AutoClear() {
useFrame(({ gl }) => {
gl.clear();
}, 0);
return null;
}
@@ -1,6 +1,4 @@
import { Flex, Heading } from '@radix-ui/themes'; import { Flex, Heading } from '@radix-ui/themes';
import { Ad, AdType } from '../../../../../../components/Ad';
import { useAdExempt } from '../../../../../../hooks/useAdExempt';
import { Characteristics } from '../Characteristics'; import { Characteristics } from '../Characteristics';
import { Consumables } from '../Characteristics/components/Consumables'; import { Consumables } from '../Characteristics/components/Consumables';
import { Equipment } from '../Characteristics/components/Equipment'; import { Equipment } from '../Characteristics/components/Equipment';
@@ -10,33 +8,21 @@ import { Provisions } from '../Characteristics/components/Provisions';
import { Skills } from '../Characteristics/components/Skills'; import { Skills } from '../Characteristics/components/Skills';
export function CharacteristicsSection() { export function CharacteristicsSection() {
const exempt = useAdExempt();
return ( return (
<Flex <Flex mt="4" px="4" style={{ boxSizing: 'border-box' }} gap="6">
mt={exempt ? '4' : '0'}
px="4"
style={{ boxSizing: 'border-box' }}
gap="6"
>
{!exempt && (
<Flex
direction="column"
gap="4"
display={{ initial: 'none', lg: 'flex' }}
pt={{ initial: '9', md: '0' }}
>
<Ad type={AdType.WideSkyscraperVerticalPurple} />
</Flex>
)}
<Flex <Flex
flexGrow="1" flexGrow="1"
gap="6" gap="9"
justify="center" justify="center"
align={{ initial: 'center', sm: 'start' }}
direction={{ initial: 'column', sm: 'row' }} direction={{ initial: 'column', sm: 'row' }}
> >
<Flex direction="column" gap="4"> <Flex
direction="column"
gap="6"
maxWidth="21rem"
align={{ initial: 'center', sm: 'start' }}
>
<Heading>Configuration</Heading> <Heading>Configuration</Heading>
<Modules /> <Modules />
<Equipment /> <Equipment />
@@ -45,33 +31,19 @@ export function CharacteristicsSection() {
<Skills /> <Skills />
<Miscellaneous /> <Miscellaneous />
</Flex> </Flex>
<Flex <Flex
flexGrow="1" flexGrow="1"
maxWidth={{ maxWidth={{ initial: '20rem', md: '40rem' }}
initial: 'unset',
xs: exempt ? undefined : '320px',
md: '640px',
}}
direction="column" direction="column"
gap="4" gap="6"
width="100%"
> >
<Heading>Characteristics</Heading> <Heading align={{ initial: 'center', sm: 'left' }}>
Characteristics
</Heading>
<Characteristics /> <Characteristics />
</Flex> </Flex>
</Flex> </Flex>
{!exempt && (
<Flex
direction="column"
gap="4"
height="100%"
display={{ initial: 'none', xs: 'flex' }}
pt={{ initial: '9', md: '0' }}
>
<Ad type={AdType.WideSkyscraperVerticalPurple} />
</Flex>
)}
</Flex> </Flex>
); );
} }
@@ -0,0 +1,115 @@
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
import { use } from 'react';
import { asset } from '../../../../../../core/blitzkit/asset';
import { consumableDefinitions } from '../../../../../../core/blitzkit/consumableDefinitions';
import { gameDefinitions } from '../../../../../../core/blitzkit/gameDefinitions';
import { provisionDefinitions } from '../../../../../../core/blitzkit/provisionDefinitions';
import * as Duel from '../../../../../../stores/duel';
export function GameModeSection() {
const tank = Duel.use((state) => state.protagonist.tank);
const awaitedGameDefinitions = use(gameDefinitions);
const awaitedConsumableDefinitions = use(consumableDefinitions);
const awaitedProvisionDefinitions = use(provisionDefinitions);
const roles = Object.entries(tank.roles);
if (roles.length === 0) return null;
return (
<Flex direction="column" gap="4" align="center">
<Heading size="6">Game mode abilities</Heading>
<Flex justify="center" gap="4" wrap="wrap" px="4">
{roles.map(([key, value]) => {
const id = Number(key);
const gameMode = awaitedGameDefinitions.gameModes[id];
return (
<Flex
key={id}
width="16rem"
height="20rem"
overflow="hidden"
direction="column"
style={{
borderRadius: 'var(--radius-3)',
background: `url(${asset(`icons/game_mode_banners/${id}.webp`)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<Box
flexGrow="1"
style={{
background: `url(${asset(`icons/game_mode_banners/${id}.webp`)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<Flex
p="3"
direction="column"
height="98px"
flexShrink="0"
style={{
backdropFilter: 'blur(4rem)',
WebkitBackdropFilter: 'blur(4rem)',
}}
align="center"
justify="center"
>
<Text size="4" weight="bold">
{gameMode.name}
</Text>
<Flex direction="column">
{awaitedGameDefinitions.roles[value].provisions.map((id) => {
const provisions = awaitedProvisionDefinitions[id];
return (
<Text color="gray" wrap="nowrap" key={id}>
<Flex align="center" gap="1">
<img
style={{
width: '1.5em',
height: '1.5em',
objectFit: 'contain',
}}
src={asset(`icons/provisions/${id}.webp`)}
alt={provisions.name}
/>
{provisions.name}
</Flex>
</Text>
);
})}
{awaitedGameDefinitions.roles[value].consumables.map((id) => {
const consumable = awaitedConsumableDefinitions[id];
return (
<Text color="gray" wrap="nowrap" key={id}>
<Flex align="center" gap="1">
<img
style={{
width: '1.5em',
height: '1.5em',
objectFit: 'contain',
}}
src={asset(`icons/consumables/${id}.webp`)}
alt={consumable.name}
/>
{consumable.name}
</Flex>
</Text>
);
})}
</Flex>
</Flex>
</Flex>
);
})}
</Flex>
</Flex>
);
}
@@ -1,16 +1,16 @@
import { ChevronLeftIcon, MixIcon } from '@radix-ui/react-icons'; import { ChevronLeftIcon, MixIcon } from '@radix-ui/react-icons';
import { Box, Button, Flex, Heading, Text } from '@radix-ui/themes'; import { Box, Button, Flex, Heading } from '@radix-ui/themes';
import Link from 'next/link'; import Link from 'next/link';
import { Suspense, use, useEffect, useMemo, useState } from 'react'; import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react';
import { classIcons } from '../../../../../../components/ClassIcon'; import { classIcons } from '../../../../../../components/ClassIcon';
import { ThicknessRange } from '../../../../../../components/StaticArmor'; import { ThicknessRange } from '../../../../../../components/StaticArmor';
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration'; import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
import { tankDefinitions } from '../../../../../../core/blitzkit/tankDefinitions'; import { tankDefinitions } from '../../../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../core/blitzkit/tankDefinitions/constants';
import { useFullScreen } from '../../../../../../hooks/useFullScreen'; import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import strings from '../../../../../../lang/en-US.json'; import * as App from '../../../../../../stores/app';
import * as Duel from '../../../../../../stores/duel'; import * as Duel from '../../../../../../stores/duel';
import { Options } from './components/Options'; import { Options } from './components/Options';
import { MetaSection } from './MetaSection';
import { TankSandbox } from './TankSandbox'; import { TankSandbox } from './TankSandbox';
import { TankSandboxLoader } from './TankSandboxLoader'; import { TankSandboxLoader } from './TankSandboxLoader';
@@ -19,6 +19,8 @@ interface HeroSectionProps {
} }
export function HeroSection({ id }: HeroSectionProps) { export function HeroSection({ id }: HeroSectionProps) {
const developerMode = App.useDeferred(false, (state) => state.developerMode);
const canvas = useRef<HTMLCanvasElement>(null);
const protagonist = Duel.use((state) => state.protagonist.tank); const protagonist = Duel.use((state) => state.protagonist.tank);
const antagonist = Duel.use((state) => state.antagonist.tank); const antagonist = Duel.use((state) => state.antagonist.tank);
const compareTanks = const compareTanks =
@@ -36,6 +38,7 @@ export function HeroSection({ id }: HeroSectionProps) {
: undefined; : undefined;
const awaitedTankDefinitions = use(tankDefinitions); const awaitedTankDefinitions = use(tankDefinitions);
const tank = awaitedTankDefinitions[id]; const tank = awaitedTankDefinitions[id];
const ClassIcon = classIcons[tank.class];
const thicknessRange = useMemo(() => { const thicknessRange = useMemo(() => {
const entries = Object.values(awaitedTankDefinitions); const entries = Object.values(awaitedTankDefinitions);
const filtered = entries.filter((thisTank) => thisTank.tier === tank.tier); const filtered = entries.filter((thisTank) => thisTank.tier === tank.tier);
@@ -63,12 +66,14 @@ export function HeroSection({ id }: HeroSectionProps) {
justify="center" justify="center"
pt={{ initial: '6', md: '0' }} pt={{ initial: '6', md: '0' }}
style={{ background: 'var(--color-surface)', position: 'relative' }} style={{ background: 'var(--color-surface)', position: 'relative' }}
height={{ initial: 'calc(75vh - 64px)', md: undefined }} height={{ initial: 'calc(75vh - 4rem)', md: undefined }}
minHeight={{ initial: '30rem' }}
gap="4" gap="4"
> >
<Flex <Flex
direction={{ initial: 'column', md: 'row' }} direction={{ initial: 'column', md: 'row' }}
style={{ maxWidth: 1600, flex: 1 }} style={{ maxWidth: '140rem', flex: 1 }}
gap={{ initial: '0', md: '6' }}
> >
<Flex <Flex
align="center" align="center"
@@ -77,39 +82,14 @@ export function HeroSection({ id }: HeroSectionProps) {
flexBasis="0" flexBasis="0"
> >
<Flex <Flex
gap={{ initial: '2', md: '4' }} gap="2"
direction="column" direction="column"
ml={{ initial: '0', md: '8' }} ml={{ initial: '0', md: '8' }}
align={{ initial: 'center', md: 'start' }} align={{ initial: 'center', md: 'start' }}
justify="center" justify="center"
style={{ style={{ height: '100%', userSelect: 'none' }}
height: '100%',
userSelect: 'none',
}}
> >
<Flex gap="4"> <Flex gap="4">
<Heading size={{ initial: '8', md: '9' }} color={treeColor}>
<Icon style={{ width: '0.75em', height: '0.75em' }} />{' '}
</Heading>
<Heading
size={{ initial: '8', md: '9' }}
align={{ initial: 'center', md: 'left' }}
color={treeColor}
>
{protagonist.name}
</Heading>
</Flex>
<Text color="gray" ml={{ initial: '0', md: '9' }}>
Tier {TIER_ROMAN_NUMERALS[protagonist.tier]}{' '}
{
(strings.common.nations_adjectives as Record<string, string>)[
protagonist.nation
]
}{' '}
{strings.common.tank_class_short[protagonist.class]}
</Text>
<Flex gap="4" ml={{ initial: '0', md: '9' }} mt="-1">
<Link href="/tools/tankopedia"> <Link href="/tools/tankopedia">
<Button variant="ghost" size="1" ml="-1"> <Button variant="ghost" size="1" ml="-1">
<ChevronLeftIcon /> <ChevronLeftIcon />
@@ -124,6 +104,22 @@ export function HeroSection({ id }: HeroSectionProps) {
</Button> </Button>
</Link> </Link>
</Flex> </Flex>
<Flex gap="4">
<Heading size={{ initial: '8', sm: '9' }} color={treeColor}>
<Icon style={{ width: '0.75em', height: '0.75em' }} />{' '}
</Heading>
<Heading
size={{ initial: '8', sm: '9' }}
align={{ initial: 'center', md: 'left' }}
color={treeColor}
>
{protagonist.name}
</Heading>
</Flex>
<MetaSection naked />
</Flex> </Flex>
</Flex> </Flex>
@@ -156,11 +152,11 @@ export function HeroSection({ id }: HeroSectionProps) {
/> />
} }
> >
<TankSandbox thicknessRange={thicknessRange} /> <TankSandbox ref={canvas} thicknessRange={thicknessRange} />
</Suspense> </Suspense>
</Box> </Box>
<Options thicknessRange={thicknessRange} /> <Options canvas={canvas} thicknessRange={thicknessRange} />
</Box> </Box>
</Box> </Box>
</Flex> </Flex>
@@ -0,0 +1,14 @@
import { Flex, Text, TextProps } from '@radix-ui/themes';
type ListingProps = TextProps & {
label: string;
};
export function Listing({ label, ...props }: ListingProps) {
return (
<Flex minWidth="15rem" justify="between" gap="4">
<Text color="gray">{label}</Text>
<Text {...props} />
</Flex>
);
}
@@ -0,0 +1,127 @@
import { Code, Flex } from '@radix-ui/themes';
import { classIcons } from '../../../../../../../components/ClassIcon';
import { asset } from '../../../../../../../core/blitzkit/asset';
import strings from '../../../../../../../lang/en-US.json';
import * as App from '../../../../../../../stores/app';
import * as Duel from '../../../../../../../stores/duel';
import { Listing } from './components/Listing';
interface MetaSectionProps {
naked?: boolean;
}
export function MetaSection({ naked }: MetaSectionProps) {
const developerMode = App.useDeferred(false, (state) => state.developerMode);
const tank = Duel.use((state) => state.protagonist.tank);
const ClassIcon = classIcons[tank.class];
const content = (
<Flex
wrap={{ initial: 'nowrap', sm: 'wrap' }}
direction={{ initial: 'column', sm: 'row' }}
align={{ initial: 'center', sm: 'start' }}
gapX="4"
width="100%"
mt={{ initial: '4', md: '6' }}
display={naked ? { initial: 'none', sm: 'flex' } : undefined}
maxWidth="calc(2 * 15rem + var(--space-4))"
>
{tank.nameFull && <Listing label="Full-name">{tank.nameFull}</Listing>}
<Listing label="Nation">
<Flex align="center" gap="1">
<img
style={{ width: '1em', height: '1em' }}
alt={tank.nation}
src={asset(`flags/circle/${tank.nation}.webp`)}
/>
{
strings.common.nations[
tank.nation as keyof typeof strings.common.nations
]
}
</Flex>
</Listing>
<Listing label="Tier">{tank.tier}</Listing>
<Listing label="Class">
<Flex align="center" gap="1">
<ClassIcon width="1em" height="1em" />
{strings.common.tank_class_short[tank.class]}
</Flex>
</Listing>
<Listing
label="Type"
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: undefined
}
>
{strings.common.tree_type[tank.treeType]}
</Listing>
{developerMode && (
<Listing label="DEV: ID">
<Code>{tank.id}</Code>
</Listing>
)}
{tank.treeType === 'premium' && (
<Listing label="Purchase price">
<Flex align="center" gap="1">
{tank.price.value / 400}
<img
style={{ width: '1em', height: '1em' }}
alt="gold"
src={asset('icons/currencies/gold.webp')}
/>
</Flex>
</Listing>
)}
<Listing
label={`${tank.treeType === 'researchable' ? 'Purchase' : 'Restoration'} price`}
>
<Flex align="center" gap="1">
{tank.price.value.toLocaleString()}
<img
style={{ width: '1em', height: '1em' }}
alt={tank.price.type}
src={asset(
`icons/currencies/${tank.price.type === 'gold' ? 'gold' : 'silver'}.webp`,
)}
/>
</Flex>
</Listing>
<Listing label="Sale price">
<Flex align="center" gap="1">
{(tank.price.value / 2).toLocaleString()}
<img
style={{ width: '1em', height: '1em' }}
alt={tank.price.type}
src={asset(
`icons/currencies/${tank.price.type === 'gold' ? 'gold' : 'silver'}.webp`,
)}
/>
</Flex>
</Listing>
{tank.xp && (
<Listing label="Research XP">
<Flex align="center" gap="1">
{tank.xp.toLocaleString()}
<img
style={{ width: '1em', height: '1em' }}
alt="xp"
src={asset('icons/currencies/xp.webp')}
/>
</Flex>
</Listing>
)}
</Flex>
);
if (naked) return content;
return (
<Flex justify="center" display={{ initial: 'flex', sm: 'none' }}>
{content}
</Flex>
);
}
@@ -1,5 +1,11 @@
import { Canvas } from '@react-three/fiber'; import { Canvas } from '@react-three/fiber';
import { Suspense, useEffect, useRef } from 'react'; import {
forwardRef,
Suspense,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import { Armor } from '../../../../../../components/Armor'; import { Armor } from '../../../../../../components/Armor';
import { ArmorPlateDisplay } from '../../../../../../components/Armor/components/ArmorPlateDisplay'; import { ArmorPlateDisplay } from '../../../../../../components/Armor/components/ArmorPlateDisplay';
import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay'; import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay';
@@ -11,6 +17,7 @@ import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawL
import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform'; import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform';
import { Pose, poseEvent } from '../../../../../../core/blitzkit/pose'; import { Pose, poseEvent } from '../../../../../../core/blitzkit/pose';
import { useEquipment } from '../../../../../../hooks/useEquipment'; import { useEquipment } from '../../../../../../hooks/useEquipment';
import useOnScreen from '../../../../../../hooks/useOnScreen';
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition'; import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
import * as Duel from '../../../../../../stores/duel'; import * as Duel from '../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../stores/tankopediaEphemeral'; import * as TankopediaEphemeral from '../../../../../../stores/tankopediaEphemeral';
@@ -19,6 +26,7 @@ import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent
import { Controls } from '../Control'; import { Controls } from '../Control';
import { Lighting } from '../Lighting'; import { Lighting } from '../Lighting';
import { SceneProps } from '../SceneProps'; import { SceneProps } from '../SceneProps';
import { AutoClear } from './AutoClear';
import { ModelLoader } from './components/ModelLoader'; import { ModelLoader } from './components/ModelLoader';
import { TankModel } from './components/TankModel'; import { TankModel } from './components/TankModel';
@@ -26,153 +34,166 @@ interface TankSandboxProps {
thicknessRange: ThicknessRange; thicknessRange: ThicknessRange;
} }
export function TankSandbox({ thicknessRange }: TankSandboxProps) { export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); ({ thicknessRange }, ref) => {
const canvas = useRef<HTMLCanvasElement>(null); const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const hasImprovedVerticalStabilizer = useEquipment(122); const canvas = useRef<HTMLCanvasElement>(null);
const protagonist = Duel.use((state) => state.protagonist); const hasImprovedVerticalStabilizer = useEquipment(122);
const mutateDuel = Duel.useMutation(); const protagonist = Duel.use((state) => state.protagonist);
const tankModelDefinition = useTankModelDefinition(); const mutateDuel = Duel.useMutation();
const turretModelDefinition = const tankModelDefinition = useTankModelDefinition();
tankModelDefinition.turrets[protagonist.turret.id]; const turretModelDefinition =
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id]; tankModelDefinition.turrets[protagonist.turret.id];
const display = TankopediaPersistent.use((state) => state.display); const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const display = TankopediaPersistent.use((state) => state.display);
const onScreen = useOnScreen(canvas);
function handlePointerDown() { console.log(onScreen);
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
event.preventDefault();
}
function handlePointerUp(event: PointerEvent) {
event.preventDefault();
window.removeEventListener('pointermove', handlePointerMove); useImperativeHandle(ref, () => canvas.current!, []);
window.removeEventListener('pointerup', handlePointerUp);
}
useEffect(() => { function handlePointerDown() {
function handlePoseEvent(pose: Pose) { window.addEventListener('pointermove', handlePointerMove);
switch (pose) { window.addEventListener('pointerup', handlePointerUp);
case Pose.HullDown: { }
mutateDuel((draft) => { function handlePointerMove(event: PointerEvent) {
const [pitch, yaw] = applyPitchYawLimits( event.preventDefault();
-Infinity, }
0, function handlePointerUp(event: PointerEvent) {
gunModelDefinition.pitch, event.preventDefault();
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw }); window.removeEventListener('pointermove', handlePointerMove);
draft.protagonist.pitch = pitch; window.removeEventListener('pointerup', handlePointerUp);
draft.protagonist.yaw = yaw; }
});
break; useEffect(() => {
function handlePoseEvent(pose: Pose) {
switch (pose) {
case Pose.HullDown: {
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
-Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break;
}
case Pose.FaceHug: {
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break;
}
case Pose.Default:
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
0,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break;
} }
case Pose.FaceHug: {
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break;
}
case Pose.Default:
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
0,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break;
} }
}
poseEvent.on(handlePoseEvent); poseEvent.on(handlePoseEvent);
return () => { return () => {
poseEvent.off(handlePoseEvent); poseEvent.off(handlePoseEvent);
}; };
});
useEffect(() => {
mutateDuel((draft) => {
[draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits(
draft.protagonist.pitch,
draft.protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
}); });
}, [protagonist.gun, protagonist.turret]);
useEffect(() => { useEffect(() => {
if (display !== TankopediaDisplay.DynamicArmor) { mutateDuel((draft) => {
mutateTankopediaEphemeral((draft) => { [draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits(
draft.shot = undefined; draft.protagonist.pitch,
draft.protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
}); });
} }, [protagonist.gun, protagonist.turret]);
if (display !== TankopediaDisplay.StaticArmor) { useEffect(() => {
mutateTankopediaEphemeral((draft) => { if (display !== TankopediaDisplay.DynamicArmor) {
draft.highlightArmor = undefined;
});
}
}, [display]);
return (
<Canvas
gl={{ clippingPlanes: Object.freeze([]), localClippingEnabled: true }}
shadows
ref={canvas}
onPointerDown={handlePointerDown}
onPointerMissed={() => {
mutateTankopediaEphemeral((draft) => { mutateTankopediaEphemeral((draft) => {
draft.shot = undefined; draft.shot = undefined;
});
}
if (display !== TankopediaDisplay.StaticArmor) {
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
}} }
style={{ userSelect: 'none' }} }, [display]);
camera={{ fov: 25, far: 32 }}
>
<Controls />
<SceneProps />
{display !== TankopediaDisplay.StaticArmor && <TankModel />}
<ShotDisplay /> return (
<ArmorPlateDisplay /> <Canvas
frameloop={onScreen ? 'demand' : 'never'}
gl={{
clippingPlanes: Object.freeze([]),
localClippingEnabled: true,
preserveDrawingBuffer: true,
}}
shadows
ref={canvas}
onPointerDown={handlePointerDown}
onPointerMissed={() => {
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
draft.highlightArmor = undefined;
});
}}
style={{ userSelect: 'none' }}
camera={{ fov: 25, far: 32 }}
>
<Controls />
<SceneProps />
{display !== TankopediaDisplay.StaticArmor && <TankModel />}
{/* idk why the shot display doesn't work without suspense here lol */} <ShotDisplay />
<Suspense fallback={<ModelLoader />}> <ArmorPlateDisplay />
{display === TankopediaDisplay.DynamicArmor && <Armor />} <AutoClear />
{display === TankopediaDisplay.StaticArmor && (
<StaticArmor thicknessRange={thicknessRange} /> {/* idk why the shot display doesn't work without suspense here lol */}
)} <Suspense fallback={<ModelLoader />}>
<Lighting /> {display === TankopediaDisplay.DynamicArmor && <Armor />}
</Suspense> {display === TankopediaDisplay.StaticArmor && (
</Canvas> <StaticArmor thicknessRange={thicknessRange} />
); )}
} <Lighting />
</Suspense>
</Canvas>
);
},
);
@@ -1,30 +0,0 @@
import { Flex, Link, Text } from '@radix-ui/themes';
import PageWrapper from '../../../../../../components/PageWrapper';
import { PatreonIcon } from '../../../../../../icons/Patreon';
export function TankopediaPlug() {
return (
<PageWrapper>
<Flex justify="center">
<Text>
Liking BlitzKit? Consider donating on{' '}
<Link
href="https://www.patreon.com/tresabhi"
target="_blank"
underline="hover"
>
<PatreonIcon
style={{
width: '1em',
height: '1em',
transform: 'translateY(12.5%)',
}}
/>{' '}
Patreon
</Link>
.
</Text>
</Flex>
</PageWrapper>
);
}
@@ -1,205 +0,0 @@
import { ChevronRightIcon } from '@radix-ui/react-icons';
import { Box, ChevronDownIcon, Flex, Heading, Text } from '@radix-ui/themes';
import Link from 'next/link';
import { ComponentProps, use } from 'react';
import { asset } from '../../../../../../core/blitzkit/asset';
import { tankDefinitions } from '../../../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../core/blitzkit/tankDefinitions/constants';
import { formatCompact } from '../../../../../../core/math/formatCompact';
import * as Duel from '../../../../../../stores/duel';
export function TreeArrow({
style,
down = false,
...props
}: ComponentProps<typeof Text> & { down?: boolean }) {
const Icon = down ? ChevronDownIcon : ChevronRightIcon;
return (
<Text
color="gray"
style={{
flex: 1,
...style,
}}
{...props}
>
<Flex
style={{
backgroundColor: 'currentcolor',
height: down ? '100%' : 1,
width: down ? 1 : '100%',
position: 'relative',
}}
direction={down ? 'column' : 'row'}
justify="end"
>
<Icon
style={{
position: 'absolute',
right: down ? undefined : 0,
top: down ? undefined : '50%',
bottom: down ? 0 : undefined,
left: down ? '50%' : undefined,
transform: down ? 'translate(-50%, 2px)' : 'translate(5px, -50%)',
}}
/>
</Flex>
</Text>
);
}
export function TechTreeSection() {
const awaitedTankDefinitions = use(tankDefinitions);
const tank = Duel.use((state) => state.protagonist.tank);
if (tank.treeType !== 'researchable') return null;
function Card({ id }: { id: number }) {
const ancestor = awaitedTankDefinitions[id];
return (
<Link
href={`/tools/tankopedia/${id}`}
key={id}
style={{
textDecoration: 'none',
color: 'inherit',
position: 'relative',
}}
>
<Flex direction="column" align="center">
<img
alt={ancestor.name}
src={asset(`icons/tanks/big/${id}.webp`)}
width={64}
height={64}
style={{
objectFit: 'contain',
}}
/>
<Flex direction="column" align="center">
<Flex gap="2" align="center">
<Text color="gray" size="1">
{TIER_ROMAN_NUMERALS[ancestor.tier]}
</Text>
<Text>{ancestor.name}</Text>
</Flex>
<Flex gap="2" align="center">
<Flex gap="1" align="center">
<img
alt="XP"
src={asset('icons/currencies/xp.webp')}
width={16}
height={16}
style={{
objectFit: 'contain',
}}
/>
<Text color="gray" size="1">
{formatCompact(ancestor.xp!)}
</Text>
</Flex>
<Flex gap="1" align="center">
<img
alt="Silver"
src={asset('icons/currencies/silver.webp')}
width={16}
height={16}
style={{
objectFit: 'contain',
}}
/>
<Text color="gray" size="1">
{formatCompact(ancestor.price.value)}
</Text>
</Flex>
</Flex>
</Flex>
</Flex>
</Link>
);
}
return (
<Flex
p="5"
style={{
overflow: 'hidden',
backgroundColor: 'var(--color-surface)',
}}
align="center"
direction="column"
position="relative"
>
<img
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-40%, -50%)',
filter: 'blur(2px)',
opacity: 0.25,
}}
/>
<Heading mb="4">Tech tree</Heading>
<Flex align="center" gap="6" direction={{ initial: 'column', sm: 'row' }}>
{tank.ancestors && (
<>
<Flex
wrap="wrap"
direction={{ initial: 'row', sm: 'column' }}
maxHeight={{ initial: 'unset', sm: '320px' }}
align="center"
justify="center"
gap="6"
>
{tank.ancestors.map((id) => (
<Card key={id} id={id} />
))}
</Flex>
<Box display={{ initial: 'block', sm: 'none' }}>
<TreeArrow down />
</Box>
<Box display={{ initial: 'none', sm: 'block' }}>
<TreeArrow />
</Box>
</>
)}
<Card id={tank.id} />
{tank.successors && (
<>
<Box display={{ initial: 'block', sm: 'none' }}>
<TreeArrow down />
</Box>
<Box display={{ initial: 'none', sm: 'block' }}>
<TreeArrow />
</Box>
<Flex
wrap="wrap"
direction={{ initial: 'row', sm: 'column' }}
maxHeight={{ initial: 'unset', sm: '320px' }}
align="center"
justify="center"
gap="6"
>
{tank.successors.map((id) => (
<Card key={id} id={id} />
))}
</Flex>
</>
)}
</Flex>
</Flex>
);
}
@@ -0,0 +1,10 @@
import { CaretRightIcon } from '@radix-ui/react-icons';
import { Text } from '@radix-ui/themes';
export function Arrow() {
return (
<Text color="gray">
<CaretRightIcon />
</Text>
);
}
@@ -0,0 +1,129 @@
import { Box, Flex, Text } from '@radix-ui/themes';
import Link from 'next/link';
import { use } from 'react';
import { asset } from '../../../../../../../../core/blitzkit/asset';
import { averageDefinitions } from '../../../../../../../../core/blitzkit/averageDefinitions';
import { tankDefinitions } from '../../../../../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../../../core/blitzkit/tankDefinitions/constants';
import { formatCompact } from '../../../../../../../../core/math/formatCompact';
import * as TankopediaEphemeral from '../../../../../../../../stores/tankopediaEphemeral';
interface NodeProps {
id: number;
nextIds?: number[];
highlight?: boolean;
}
export function Node({ id, highlight, nextIds }: NodeProps) {
const xpMultiplier = TankopediaEphemeral.use((state) => state.xpMultiplier);
const awaitedTankDefinitions = use(tankDefinitions);
const tank = awaitedTankDefinitions[id];
const nextTanks = nextIds?.map((id) => awaitedTankDefinitions[id]);
const awaitedAverageDefinitions = use(averageDefinitions);
const thisTankXp =
tank.xp === undefined || tank.tier === 1 ? 0 : tank.xp! / xpMultiplier;
const nextTanksXp = nextTanks?.reduce(
(xp, tank) =>
xp +
(tank.xp === undefined || tank.tier === 1 ? 0 : tank.xp! / xpMultiplier),
0,
);
const averages = awaitedAverageDefinitions.averages[tank.id]?.mu;
const averageXp = averages ? averages.xp / averages.battles : undefined;
const games =
averages && nextTanks ? Math.round(nextTanksXp! / averageXp!) : undefined;
return (
<Link
href={`/tools/tankopedia/${id}`}
key={id}
style={{
textDecoration: 'none',
color: 'inherit',
position: 'relative',
}}
>
<Box
mx={highlight ? '2' : '0'}
style={{
background: highlight
? `url(${asset(`flags/fade_small/${tank.nation}.webp`)})`
: undefined,
backgroundPosition: 'center',
backgroundSize: 'cover',
boxShadow: highlight ? 'var(--shadow-4)' : undefined,
borderRadius: 'var(--radius-4)',
overflow: 'hidden',
}}
>
<Flex
direction="column"
align="center"
py="4"
px={highlight ? '4' : '2'}
style={{
backdropFilter: highlight ? 'brightness(0.25)' : undefined,
WebkitBackdropFilter: highlight ? 'brightness(0.25)' : undefined,
}}
>
<img
alt={tank.name}
src={asset(`icons/tanks/big/${id}.webp`)}
width={64}
height={64}
style={{
objectFit: 'contain',
}}
/>
<Flex direction="column" align="center">
<Flex gap="2" align="center">
<Text color="gray" size="1">
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
<Text wrap="nowrap">{tank.name}</Text>
</Flex>
<Flex gap="2" align="center">
<Text color="gray" size="1" wrap="nowrap">
<Flex gap="1" align="center">
<img
alt="XP"
src={asset('icons/currencies/xp.webp')}
style={{
width: '1em',
height: '1em',
objectFit: 'contain',
objectPosition: 'center',
}}
/>
{formatCompact(thisTankXp)}
</Flex>
</Text>
<Text color="gray" size="1" wrap="nowrap">
<Flex gap="1" align="center">
<img
alt="Silver"
src={asset('icons/currencies/silver.webp')}
style={{
width: '1em',
height: '1em',
objectFit: 'contain',
objectPosition: 'center',
}}
/>
{formatCompact(tank.price.value)}
</Flex>
</Text>
</Flex>
{averages && nextTanks && (
<Text color="gray" size="1" mt="2">
{games!} {games === 1 ? 'battle' : 'battles'}
</Text>
)}
</Flex>
</Flex>
</Box>
</Link>
);
}
@@ -0,0 +1,224 @@
import { CaretLeftIcon, CaretRightIcon, PlusIcon } from '@radix-ui/react-icons';
import { Flex, Heading, IconButton, ScrollArea, Text } from '@radix-ui/themes';
import { use, useEffect, useMemo, useRef, useState } from 'react';
import { asset } from '../../../../../../../core/blitzkit/asset';
import { imgur } from '../../../../../../../core/blitzkit/imgur';
import { tankDefinitions } from '../../../../../../../core/blitzkit/tankDefinitions';
import * as Duel from '../../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../../stores/tankopediaEphemeral';
import { Arrow } from './components/Arrow';
import { Node } from './components/Node';
type Line = number[];
export const XP_MULTIPLIERS = [1, 2, 3, 4, 5, 10];
export function TechTreeSection() {
const xpMultiplier = TankopediaEphemeral.use((state) => state.xpMultiplier);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const master = Duel.use((state) => state.protagonist.tank);
const awaitedTankDefinitions = use(tankDefinitions);
const container = useRef<HTMLDivElement>(null);
const lines = useMemo(() => {
function extend(line: Line): Line[] {
const root = awaitedTankDefinitions[line.at(-1)!];
if (root.ancestors === undefined || root.tier === 1) {
return [line];
} else {
if (root.ancestors.length === 1 || root.tier === 2) {
line.push(root.ancestors[0]);
return extend(line);
} else {
return root.ancestors
.map((ancestor) => {
const newLine = [...line, ancestor];
return extend(newLine);
})
.flat();
}
}
}
return extend([master.id]);
}, [master]);
const [lineIndex, setLineIndex] = useState(0);
const line = useMemo(
() => [...lines[lineIndex]].toReversed(),
[master, lineIndex],
);
const totalXp = line.reduce(
(xp, id) =>
xp +
(awaitedTankDefinitions[id].xp === undefined ||
awaitedTankDefinitions[id].tier === 1
? 0
: awaitedTankDefinitions[id].xp),
0,
);
const totalCredits = line.reduce(
(credits, id) => credits + awaitedTankDefinitions[id].price.value,
0,
);
if (
master.treeType !== 'researchable' ||
master.ancestors === undefined ||
master.ancestors.length === 0
) {
return null;
}
useEffect(() => {
if (!container.current) return;
container.current.scrollLeft = container.current.scrollWidth;
});
return (
<Flex
direction="column"
align="center"
gap="0"
style={{ backgroundColor: 'var(--color-surface)' }}
py="6"
>
<Flex direction="column" align="center">
<Heading size="6">Tech tree</Heading>
{master.tier > 1 && (
<Flex gap="4" align="center">
<Text color="gray" wrap="nowrap">
<Flex gap="1" align="center">
<img
alt="XP"
src={asset('icons/currencies/xp.webp')}
style={{
width: '1em',
height: '1em',
objectFit: 'contain',
objectPosition: 'center',
}}
/>
{Math.round(totalXp / xpMultiplier).toLocaleString()}
</Flex>
</Text>
<Text color="gray" wrap="nowrap">
<Flex gap="1" align="center">
<img
alt="Silver"
src={asset('icons/currencies/silver.webp')}
style={{
width: '1em',
height: '1em',
objectFit: 'contain',
objectPosition: 'center',
}}
/>
{totalCredits.toLocaleString()}
</Flex>
</Text>
</Flex>
)}
<Flex gap="1" mt="2">
{XP_MULTIPLIERS.map((multiplier) => {
const selected = xpMultiplier === multiplier;
return (
<Flex
style={{
background: `url(${imgur('7hDltb4')})`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
opacity: selected ? 1 : 0.5,
cursor: 'pointer',
}}
onClick={() => {
mutateTankopediaEphemeral((draft) => {
draft.xpMultiplier = multiplier;
});
}}
key={multiplier}
width="2.5rem"
justify="center"
align="center"
pt="1"
pl="1"
>
<Text size="2">
x<b>{multiplier}</b>
</Text>
</Flex>
);
})}
</Flex>
</Flex>
<ScrollArea type="hover" scrollbars="horizontal" ref={container}>
<Flex align="center" gap="2" justify="center" p="4">
{line.map((id, index) => {
const last = index === line.length - 1;
const tank = awaitedTankDefinitions[id];
return (
<>
{index > 0 && <Arrow />}
<Node
key={id}
id={id}
nextIds={last ? tank.successors : [line[index + 1]]}
highlight={last}
/>
</>
);
})}
{master.tier < 10 && (
<>
<Arrow />
{master.successors!.map((id, index) => (
<>
{index > 0 && (
<Text color="gray">
<PlusIcon />
</Text>
)}
<Node key={id} id={id} />
</>
))}
</>
)}
</Flex>
</ScrollArea>
<Flex>
{lines.length > 1 && (
<Flex align="center" gap="2">
<IconButton
size="2"
variant="soft"
onClick={() =>
setLineIndex((lines.length + lineIndex - 1) % lines.length)
}
>
<CaretLeftIcon />
</IconButton>
<Text>
Route {lineIndex + 1} of {lines.length}
</Text>
<IconButton
size="2"
variant="soft"
onClick={() => setLineIndex((lineIndex + 1) % lines.length)}
>
<CaretRightIcon />
</IconButton>
</Flex>
)}
</Flex>
</Flex>
);
}
@@ -1,5 +1,7 @@
import { import {
Cross1Icon, CameraIcon,
CopyIcon,
DownloadIcon,
EnterFullScreenIcon, EnterFullScreenIcon,
ExitFullScreenIcon, ExitFullScreenIcon,
EyeOpenIcon, EyeOpenIcon,
@@ -11,15 +13,16 @@ import {
Dialog, Dialog,
DropdownMenu, DropdownMenu,
Flex, Flex,
Heading,
IconButton, IconButton,
Popover, Popover,
SegmentedControl, SegmentedControl,
Strong, Strong,
Tabs, Tabs,
Text, Text,
Tooltip,
} from '@radix-ui/themes'; } from '@radix-ui/themes';
import { useEffect, useState } from 'react'; import { invalidate } from '@react-three/fiber';
import { RefObject, useEffect, useState } from 'react';
import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton'; import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton';
import { SmallTankIcon } from '../../../../../../../components/SmallTankIcon'; import { SmallTankIcon } from '../../../../../../../components/SmallTankIcon';
import { ThicknessRange } from '../../../../../../../components/StaticArmor'; import { ThicknessRange } from '../../../../../../../components/StaticArmor';
@@ -28,10 +31,7 @@ import { resolvePenetrationCoefficient } from '../../../../../../../core/blitz/r
import { asset } from '../../../../../../../core/blitzkit/asset'; import { asset } from '../../../../../../../core/blitzkit/asset';
import { imgur } from '../../../../../../../core/blitzkit/imgur'; import { imgur } from '../../../../../../../core/blitzkit/imgur';
import { Pose, poseEvent } from '../../../../../../../core/blitzkit/pose'; import { Pose, poseEvent } from '../../../../../../../core/blitzkit/pose';
import { import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkit/tankDefinitions/constants';
SHELL_NAMES,
TIER_ROMAN_NUMERALS,
} from '../../../../../../../core/blitzkit/tankDefinitions/constants';
import { uniqueGuns } from '../../../../../../../core/blitzkit/uniqueGuns'; import { uniqueGuns } from '../../../../../../../core/blitzkit/uniqueGuns';
import { useEquipment } from '../../../../../../../hooks/useEquipment'; import { useEquipment } from '../../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../../hooks/useFullScreen'; import { useFullScreen } from '../../../../../../../hooks/useFullScreen';
@@ -48,9 +48,10 @@ import { Thicknesses } from '../../Thicknesses';
interface OptionsProps { interface OptionsProps {
thicknessRange: ThicknessRange; thicknessRange: ThicknessRange;
canvas: RefObject<HTMLCanvasElement>;
} }
export function Options({ thicknessRange }: OptionsProps) { export function Options({ thicknessRange, canvas }: OptionsProps) {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const mutateTankopediaPersistent = TankopediaPersistent.useMutation(); const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const displayRaw = TankopediaPersistent.use((state) => state.display); const displayRaw = TankopediaPersistent.use((state) => state.display);
@@ -75,12 +76,12 @@ export function Options({ thicknessRange }: OptionsProps) {
const environment = TankopediaPersistent.use( const environment = TankopediaPersistent.use(
(state) => state.model.visual.environment, (state) => state.model.visual.environment,
); );
const developerMode = App.use((state) => state.developerMode); const developerMode = App.useDeferred(false, (state) => state.developerMode);
const protagonist = Duel.use((state) => state.protagonist.tank);
const antagonistGun = Duel.use((state) => state.antagonist.gun); const antagonistGun = Duel.use((state) => state.antagonist.gun);
const antagonistShell = Duel.use((state) => state.antagonist.shell); const antagonistShell = Duel.use((state) => state.antagonist.shell);
const [antagonistSelectorOpen, setAntagonistSelectorOpen] = useState(false); const [antagonistSelectorOpen, setAntagonistSelectorOpen] = useState(false);
const antagonistTank = Duel.use((state) => state.antagonist.tank); const antagonistTank = Duel.use((state) => state.antagonist.tank);
const antagonistTurret = Duel.use((state) => state.antagonist.turret);
const hasCalibratedShells = useEquipment(103, true); const hasCalibratedShells = useEquipment(103, true);
const [tab, setTab] = useState('search'); const [tab, setTab] = useState('search');
const mutateDuel = Duel.useMutation(); const mutateDuel = Duel.useMutation();
@@ -126,12 +127,9 @@ export function Options({ thicknessRange }: OptionsProps) {
<Flex <Flex
gap="2" gap="2"
direction="column" direction="column"
style={{ top="50%"
position: 'absolute', right="3"
right: 16, style={{ position: 'absolute', transform: 'translateY(-50%)' }}
top: '50%',
transform: 'translateY(-50%)',
}}
align="end" align="end"
> >
<Text color="gray" size={{ initial: '1', sm: '2' }}> <Text color="gray" size={{ initial: '1', sm: '2' }}>
@@ -181,12 +179,11 @@ export function Options({ thicknessRange }: OptionsProps) {
</Popover.Trigger> </Popover.Trigger>
<Popover.Content side="left" align="center"> <Popover.Content side="left" align="center">
<Flex> <Flex gap="2">
{[...antagonistUniqueGuns] {[...antagonistUniqueGuns]
.reverse() .reverse()
.map(({ gun, turret }, index) => ( .map(({ gun, turret }, index) => (
<ModuleButton <ModuleButton
rowChild
module="gun" module="gun"
key={gun.id} key={gun.id}
onClick={() => onClick={() =>
@@ -196,8 +193,6 @@ export function Options({ thicknessRange }: OptionsProps) {
draft.antagonist.shell = gun.shells[0]; draft.antagonist.shell = gun.shells[0];
}) })
} }
first={index === 0}
last={index === antagonistUniqueGuns.length - 1}
selected={gun.id === antagonistGun.id} selected={gun.id === antagonistGun.id}
discriminator={TIER_ROMAN_NUMERALS[gun.tier]} discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
/> />
@@ -225,6 +220,7 @@ export function Options({ thicknessRange }: OptionsProps) {
marginTop: shellIndex === 0 ? 0 : -1, marginTop: shellIndex === 0 ? 0 : -1,
}} }}
onClick={() => { onClick={() => {
invalidate();
mutateDuel((draft) => { mutateDuel((draft) => {
draft.antagonist.shell = thisShell; draft.antagonist.shell = thisShell;
}); });
@@ -308,6 +304,59 @@ export function Options({ thicknessRange }: OptionsProps) {
</Flex> </Flex>
)} )}
<Box position="absolute" top="3" right="3">
<Popover.Root>
<Popover.Trigger>
<IconButton size={{ initial: '2', sm: '3' }} variant="soft">
<CameraIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex direction="column" gap="2">
<Popover.Close>
<Button
onClick={() => {
if (!canvas.current) return;
const anchor = document.createElement('a');
anchor.setAttribute('download', `${protagonist.name}.png`);
anchor.setAttribute(
'href',
canvas.current.toDataURL('image/png'),
);
anchor.click();
}}
>
<DownloadIcon />
Download
</Button>
</Popover.Close>
<Popover.Close>
<Button
variant="outline"
onClick={() => {
if (!canvas.current) return;
canvas.current.toBlob((blob) => {
if (!blob) return;
navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
});
}}
>
<CopyIcon />
Copy
</Button>
</Popover.Close>
</Flex>
</Popover.Content>
</Popover.Root>
</Box>
<Flex <Flex
style={{ style={{
position: 'absolute', position: 'absolute',
@@ -343,136 +392,21 @@ export function Options({ thicknessRange }: OptionsProps) {
onValueChange={setTab} onValueChange={setTab}
style={{ position: 'relative' }} style={{ position: 'relative' }}
> >
<Dialog.Close> <TankSearch
<Button compact
variant="ghost" onSelect={(tank) => {
style={{ position: 'absolute', right: 0, top: 8 }} mutateDuel((draft) => {
> draft.antagonist.tank = tank;
<Cross1Icon /> draft.antagonist.engine = tank.engines.at(-1)!;
</Button> draft.antagonist.track = tank.tracks.at(-1)!;
</Dialog.Close> draft.antagonist.turret = tank.turrets.at(-1)!;
draft.antagonist.gun =
<Flex gap="4" direction="column"> draft.antagonist.turret.guns.at(-1)!;
<Tabs.List> draft.antagonist.shell = draft.antagonist.gun.shells[0];
<Tabs.Trigger value="search">Search</Tabs.Trigger> });
<Tabs.Trigger value="configure">Configure</Tabs.Trigger> setAntagonistSelectorOpen(false);
</Tabs.List> }}
/>
<Tabs.Content value="search">
<Flex
direction="column"
gap="4"
style={{ flex: 1 }}
justify="center"
>
<TankSearch
compact
onSelect={(tank) => {
mutateDuel((draft) => {
draft.antagonist.tank = tank;
draft.antagonist.engine = tank.engines.at(-1)!;
draft.antagonist.track = tank.tracks.at(-1)!;
draft.antagonist.turret = tank.turrets.at(-1)!;
draft.antagonist.gun =
draft.antagonist.turret.guns.at(-1)!;
draft.antagonist.shell =
draft.antagonist.gun.shells[0];
});
setAntagonistSelectorOpen(false);
}}
/>
</Flex>
</Tabs.Content>
<Tabs.Content value="configure">
<Flex direction="column" gap="4">
<Flex direction="column" gap="2" style={{ flex: 1 }}>
<Heading size="4">
{antagonistTank.name} modules
</Heading>
<Flex gap="2" wrap="wrap">
<Flex>
{antagonistTank.turrets.map((turret, index) => (
<ModuleButton
rowChild
first={index === 0}
last={
index === antagonistTank.turrets.length - 1
}
key={turret.id}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist.turret = turret;
draft.antagonist.gun =
turret.guns.at(-1)!;
draft.antagonist.shell =
draft.antagonist.gun.shells[0];
});
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}}
selected={antagonistTurret.id === turret.id}
discriminator={
TIER_ROMAN_NUMERALS[turret.tier]
}
module="turret"
/>
))}
</Flex>
<Flex>
{antagonistTurret.guns.map((gun, index) => (
<ModuleButton
key={gun.id}
rowChild
first={index === 0}
last={
index === antagonistTurret.guns.length - 1
}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist.gun = gun;
draft.antagonist.shell = gun.shells[0];
});
}}
selected={antagonistGun.id === gun.id}
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
module="gun"
/>
))}
</Flex>
</Flex>
</Flex>
<Flex direction="column" style={{ flex: 1 }}>
<Heading size="4">Properties</Heading>
<ul>
<li>
Turret: <b>{antagonistTurret.name}</b>
</li>
<li>
Gun: <b>{antagonistGun.name}</b>
</li>
<li>Shells:</li>
<ul>
{antagonistGun.shells.map((shell) => (
<li key={shell.id}>
{SHELL_NAMES[shell.type]}:{' '}
<b>
{resolveNearPenetration(shell.penetration)}
mm
</b>
, {shell.damage.armor}HP
</li>
))}
</ul>
</ul>
</Flex>
</Flex>
</Tabs.Content>
</Flex>
</Tabs.Root> </Tabs.Root>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -489,19 +423,25 @@ export function Options({ thicknessRange }: OptionsProps) {
}} }}
> >
<SegmentedControl.Item value={`${TankopediaDisplay.Model}`}> <SegmentedControl.Item value={`${TankopediaDisplay.Model}`}>
<Flex height="100%" align="center"> <Tooltip content="Model">
<img src={imgur('jAdYf0m')} style={{ height: '1.25em' }} /> <Flex height="100%" align="center">
</Flex> <img src={imgur('jAdYf0m')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item> </SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaDisplay.DynamicArmor}`}> <SegmentedControl.Item value={`${TankopediaDisplay.DynamicArmor}`}>
<Flex height="100%" align="center"> <Tooltip content="Dynamic armor">
<img src={imgur('oe4Cq0g')} style={{ height: '1.25em' }} /> <Flex height="100%" align="center">
</Flex> <img src={imgur('oe4Cq0g')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item> </SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaDisplay.StaticArmor}`}> <SegmentedControl.Item value={`${TankopediaDisplay.StaticArmor}`}>
<Flex height="100%" align="center"> <Tooltip content="Static armor">
<img src={imgur('VQ4uDno')} style={{ height: '1.25em' }} /> <Flex height="100%" align="center">
</Flex> <img src={imgur('VQ4uDno')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item> </SegmentedControl.Item>
</SegmentedControl.Root> </SegmentedControl.Root>
@@ -8,13 +8,7 @@ export function ShotDisplaySection() {
if (!shot) return null; if (!shot) return null;
return ( return (
<Flex <Flex justify="center" display={{ initial: 'flex', sm: 'none' }}>
justify="center"
display={{
initial: 'flex',
sm: 'none',
}}
>
<ShotDisplayCard shot={shot} /> <ShotDisplayCard shot={shot} />
</Flex> </Flex>
); );
@@ -0,0 +1,23 @@
import { Callout, Flex } from '@radix-ui/themes';
import { ExperimentIcon } from '../../../../../components/ExperimentIcon';
import * as Duel from '../../../../../stores/duel';
export function TestingSection() {
const tank = Duel.use((state) => state.protagonist.tank);
if (!tank.testing) return null;
return (
<Flex justify="center" mb="4">
<Callout.Root>
<Callout.Icon>
<ExperimentIcon style={{ width: '1em', height: '1em' }} />
</Callout.Icon>
<Callout.Text>
Tanks in testing are subject to change and may not represent the final
product.
</Callout.Text>
</Callout.Root>
</Flex>
);
}
@@ -1,41 +1,114 @@
import { Flex, Heading, Link, Text } from '@radix-ui/themes'; import { Box, Flex, Heading, Text } from '@radix-ui/themes';
import { use } from 'react'; import { use } from 'react';
import { Link } from '../../../../../components/Link';
import PageWrapper from '../../../../../components/PageWrapper'; import PageWrapper from '../../../../../components/PageWrapper';
import { videoDefinitions } from '../../../../../core/blitzkit/videos'; import { VerifiedIcon } from '../../../../../components/VerifiedIcon';
import { youtubers } from '../../../../../constants/youtubers';
import { reviews } from '../../../../../core/blitzkit/reviews';
import { Var } from '../../../../../core/blitzkit/var';
import * as Duel from '../../../../../stores/duel'; import * as Duel from '../../../../../stores/duel';
export function VideoSection() { export function VideoSection() {
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const awaitedVideoDefinitions = use(videoDefinitions); const awaitedReviews = use(reviews);
const videos = awaitedVideoDefinitions[tank.id]?.videos ?? []; const videos = awaitedReviews.reviews[tank.id]?.videos;
if (!videos) return null;
return ( return (
<PageWrapper noFlex1> <PageWrapper noFlex1>
<Flex direction="column" gap="4" align="start"> <Flex direction="column" gap="6" align="center">
<Flex direction="column"> <Flex direction="column" align="center">
<Heading size="6">Review videos</Heading> <Heading size="6">Review videos</Heading>
<Text color="gray">BlitzKit does not endorse any review videos.</Text> <Text color="gray">BlitzKit does not endorse any review videos.</Text>
</Flex> </Flex>
<Flex gap="3" wrap="wrap"> <Flex gap="3" wrap="wrap" justify="center">
{videos.map((video) => ( {videos.map((video) => {
<Link const image = `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`;
key={video} const youtuber = youtubers.find(
href={`https://www.youtube.com/watch?v=${video}`} (youtuber) => youtuber.id === video.author,
target="_blank" );
if (!youtuber) return null;
return (
<Link
color="gray"
highContrast
underline="hover"
key={video.id}
href={`https://www.youtube.com/watch?v=${video.id}`}
target="_blank"
>
<Flex
direction="column"
style={{
borderRadius: 'var(--radius-3)',
overflow: 'hidden',
background: `url(${image})`,
backgroundPosition: '0 18px',
backgroundSize: 'cover',
}}
>
<Box
style={{
background: `url(${image})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
height: 128,
aspectRatio: '16 / 9',
}}
/>
<Flex
p="2"
width="100%"
justify="center"
style={{
backdropFilter: 'blur(4rem)',
WebkitBackdropFilter: 'blur(4rem)',
}}
align="center"
gap="1"
>
<Text>{youtuber.name}</Text>
<VerifiedIcon width="1em" height="1em" />
</Flex>
</Flex>
</Link>
);
})}
<Link
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
color="gray"
highContrast
>
<Flex
align="center"
justify="center"
gap="2"
p="3"
direction="column"
height="100%"
style={{
backgroundColor: Var('color-panel-solid'),
borderRadius: Var('radius-3'),
}}
> >
<img <VerifiedIcon />
alt="Video thumbnail" <Text
src={`https://i.ytimg.com/vi/${video}/hqdefault.jpg`} align="center"
style={{ style={{
aspectRatio: '16 / 9', width: '9rem',
objectFit: 'cover',
height: 128,
borderRadius: 8,
}} }}
/> >
</Link> Apply to BlitzKit YouTuber program
))} </Text>
</Flex>
</Link>
</Flex> </Flex>
</Flex> </Flex>
</PageWrapper> </PageWrapper>
+14 -17
View File
@@ -6,7 +6,6 @@ import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/c
import { tankIcon } from '../../../../core/blitzkit/tankIcon'; import { tankIcon } from '../../../../core/blitzkit/tankIcon';
import strings from '../../../../lang/en-US.json'; import strings from '../../../../lang/en-US.json';
import * as Duel from '../../../../stores/duel'; import * as Duel from '../../../../stores/duel';
import * as TankFilters from '../../../../stores/tankFilters';
import * as TankopediaEphemeral from '../../../../stores/tankopediaEphemeral'; import * as TankopediaEphemeral from '../../../../stores/tankopediaEphemeral';
export default async function Layout({ export default async function Layout({
@@ -25,26 +24,24 @@ export default async function Layout({
const title = `${tank.name} - Tier ${TIER_ROMAN_NUMERALS[tank.tier]} ${ const title = `${tank.name} - Tier ${TIER_ROMAN_NUMERALS[tank.tier]} ${
(strings.common.nations_adjectives as Record<string, string>)[tank.nation] (strings.common.nations_adjectives as Record<string, string>)[tank.nation]
} ${strings.common.tank_class_short[tank.class]}`; } ${strings.common.tank_class_short[tank.class]}`;
const description = `Statistics, armor, and equipment for ${tank.name}`; const description = `Statistics, armor, and equipment for ${tank.name} in World of Tanks Blitz (WoTB)`;
return ( return (
<TankopediaEphemeral.Provider data={tankModelDefinition}> <TankopediaEphemeral.Provider data={tankModelDefinition}>
<TankFilters.Provider> <Duel.Provider
<Duel.Provider data={{
data={{ tank: awaitedTankDefinitions[id],
tank: awaitedTankDefinitions[id], provisionDefinitions: awaitedProvisionDefinitions,
provisionDefinitions: awaitedProvisionDefinitions, }}
}} >
> <title>{title}</title>
<title>{title}</title> <meta name="description" content={description} />
<meta name="description" content={description} /> <meta property="og:title" content={title} />
<meta property="og:title" content={title} /> <meta property="og:image" content={tankIcon(id)} />
<meta property="og:image" content={tankIcon(id)} /> <meta property="og:description" content={description} />
<meta property="og:description" content={description} />
{children} {children}
</Duel.Provider> </Duel.Provider>
</TankFilters.Provider>
</TankopediaEphemeral.Provider> </TankopediaEphemeral.Provider>
); );
} }
+14 -4
View File
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { invalidate } from '@react-three/fiber';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { AdMidSectionResponsive } from '../../../../components/AdMidSectionResponsive'; import { AdMidSectionResponsive } from '../../../../components/AdMidSectionResponsive';
import PageWrapper from '../../../../components/PageWrapper'; import PageWrapper from '../../../../components/PageWrapper';
@@ -8,9 +9,12 @@ import * as Duel from '../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../stores/tankopediaEphemeral'; import * as TankopediaEphemeral from '../../../../stores/tankopediaEphemeral';
import { HistorySection } from './components/HistorySection'; import { HistorySection } from './components/HistorySection';
import { CharacteristicsSection } from './components/Model/CharacteristicsSection'; import { CharacteristicsSection } from './components/Model/CharacteristicsSection';
import { GameModeSection } from './components/Model/GameModeSection';
import { HeroSection } from './components/Model/HeroSection'; import { HeroSection } from './components/Model/HeroSection';
import { MetaSection } from './components/Model/MetaSection';
import { TechTreeSection } from './components/Model/TechTreeSection'; import { TechTreeSection } from './components/Model/TechTreeSection';
import { ShotDisplaySection } from './components/ShotDisplaySection'; import { ShotDisplaySection } from './components/ShotDisplaySection';
import { TestingSection } from './components/TestingSection';
import { VideoSection } from './components/VideoSection'; import { VideoSection } from './components/VideoSection';
export default function Page({ params }: { params: { id: string } }) { export default function Page({ params }: { params: { id: string } }) {
@@ -31,11 +35,13 @@ export default function Page({ params }: { params: { id: string } }) {
} }
if (event.key === '1') { if (event.key === '1') {
invalidate();
wipeShot(); wipeShot();
mutateDuel((draft) => { mutateDuel((draft) => {
draft.antagonist.shell = draft.antagonist.gun.shells[0]; draft.antagonist.shell = draft.antagonist.gun.shells[0];
}); });
} else if (event.key === '2') { } else if (event.key === '2') {
invalidate();
wipeShot(); wipeShot();
mutateDuel((draft) => { mutateDuel((draft) => {
if (draft.antagonist.gun.shells[1]) { if (draft.antagonist.gun.shells[1]) {
@@ -43,6 +49,7 @@ export default function Page({ params }: { params: { id: string } }) {
} }
}); });
} else if (event.key === '3') { } else if (event.key === '3') {
invalidate();
wipeShot(); wipeShot();
mutateDuel((draft) => { mutateDuel((draft) => {
if (draft.antagonist.gun.shells[2]) { if (draft.antagonist.gun.shells[2]) {
@@ -73,16 +80,19 @@ export default function Page({ params }: { params: { id: string } }) {
}, []); }, []);
return ( return (
<PageWrapper p="0" noMaxWidth color="purple" size={1600}> <PageWrapper p="0" noMaxWidth color="purple" size={1600} gap="9">
<HeroSection id={id} /> <HeroSection id={id} />
<ShotDisplaySection /> <ShotDisplaySection />
{!exempt && <AdMidSectionResponsive />} <MetaSection />
<CharacteristicsSection />
{!exempt && <AdMidSectionResponsive />} {!exempt && <AdMidSectionResponsive />}
<TechTreeSection /> <TechTreeSection />
<TestingSection />
<CharacteristicsSection />
{!exempt && <AdMidSectionResponsive />}
<GameModeSection />
<VideoSection /> <VideoSection />
<HistorySection /> <HistorySection />
{!exempt && <AdMidSectionResponsive />} {!exempt && <AdMidSectionResponsive mb="6" />}
</PageWrapper> </PageWrapper>
); );
} }
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { ReloadIcon } from '@radix-ui/react-icons'; import { ReloadIcon } from '@radix-ui/react-icons';
import { Box, Flex, IconButton, Text } from '@radix-ui/themes'; import { Box, Flex, IconButton, Text, Tooltip } from '@radix-ui/themes';
import { times } from 'lodash'; import { times } from 'lodash';
import { use } from 'react'; import { use } from 'react';
import { classIcons } from '../../../../components/ClassIcon'; import { classIcons } from '../../../../components/ClassIcon';
@@ -14,6 +14,7 @@ import { asset } from '../../../../core/blitzkit/asset';
import { gameDefinitions } from '../../../../core/blitzkit/gameDefinitions'; import { gameDefinitions } from '../../../../core/blitzkit/gameDefinitions';
import { Tier } from '../../../../core/blitzkit/tankDefinitions'; import { Tier } from '../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/constants'; import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/constants';
import strings from '../../../../lang/en-US.json';
import * as TankFilters from '../../../../stores/tankFilters'; import * as TankFilters from '../../../../stores/tankFilters';
interface FilterControlProps { interface FilterControlProps {
@@ -39,24 +40,26 @@ export function FilterControl({ compact }: FilterControlProps) {
const selected = filters.tiers?.includes(tier); const selected = filters.tiers?.includes(tier);
return ( return (
<IconButton <Tooltip key={tier} content={`Tier ${tier}`}>
key={tier} <IconButton
variant={selected ? 'solid' : 'soft'} key={tier}
radius="none" variant={selected ? 'solid' : 'soft'}
color={selected ? undefined : 'gray'} radius="none"
highContrast color={selected ? undefined : 'gray'}
onClick={() => highContrast
mutateTankFilters((draft) => { onClick={() =>
if (draft.tiers?.includes(tier)) { mutateTankFilters((draft) => {
draft.tiers = draft.tiers?.filter((t) => t !== tier); if (draft.tiers?.includes(tier)) {
} else { draft.tiers = draft.tiers?.filter((t) => t !== tier);
draft.tiers = [...(draft.tiers ?? []), tier]; } else {
} draft.tiers = [...(draft.tiers ?? []), tier];
}) }
} })
> }
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text> >
</IconButton> <Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
</IconButton>
</Tooltip>
); );
})} })}
</Flex> </Flex>
@@ -66,24 +69,26 @@ export function FilterControl({ compact }: FilterControlProps) {
const selected = filters.tiers?.includes(tier); const selected = filters.tiers?.includes(tier);
return ( return (
<IconButton <Tooltip key={tier} content={`Tier ${tier}`}>
key={tier} <IconButton
variant={selected ? 'solid' : 'soft'} key={tier}
radius="none" variant={selected ? 'solid' : 'soft'}
color={selected ? undefined : 'gray'} radius="none"
highContrast color={selected ? undefined : 'gray'}
onClick={() => highContrast
mutateTankFilters((draft) => { onClick={() =>
if (draft.tiers?.includes(tier)) { mutateTankFilters((draft) => {
draft.tiers = draft.tiers?.filter((t) => t !== tier); if (draft.tiers?.includes(tier)) {
} else { draft.tiers = draft.tiers?.filter((t) => t !== tier);
draft.tiers = [...(draft.tiers ?? []), tier]; } else {
} draft.tiers = [...(draft.tiers ?? []), tier];
}) }
} })
> }
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text> >
</IconButton> <Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
</IconButton>
</Tooltip>
); );
})} })}
</Flex> </Flex>
@@ -101,29 +106,37 @@ export function FilterControl({ compact }: FilterControlProps) {
const selected = filters.nations?.includes(nation); const selected = filters.nations?.includes(nation);
return ( return (
<IconButton <Tooltip
key={nation} content={
variant={selected ? 'solid' : 'soft'} strings.common.nations[
color={selected ? undefined : 'gray'} nation as keyof typeof strings.common.nations
highContrast ]
radius="none"
onClick={() =>
mutateTankFilters((draft) => {
if (draft.nations?.includes(nation)) {
draft.nations = draft.nations?.filter(
(n) => n !== nation,
);
} else {
draft.nations = [...(draft.nations ?? []), nation];
}
})
} }
key={nation}
> >
<img <IconButton
style={{ width: '1em', height: '1em' }} variant={selected ? 'solid' : 'soft'}
src={asset(`flags/circle/${nation}.webp`)} color={selected ? undefined : 'gray'}
/> highContrast
</IconButton> radius="none"
onClick={() =>
mutateTankFilters((draft) => {
if (draft.nations?.includes(nation)) {
draft.nations = draft.nations?.filter(
(n) => n !== nation,
);
} else {
draft.nations = [...(draft.nations ?? []), nation];
}
})
}
>
<img
style={{ width: '1em', height: '1em' }}
src={asset(`flags/circle/${nation}.webp`)}
/>
</IconButton>
</Tooltip>
); );
})} })}
</Flex> </Flex>
@@ -134,30 +147,39 @@ export function FilterControl({ compact }: FilterControlProps) {
const selected = filters.nations?.includes(nation); const selected = filters.nations?.includes(nation);
return ( return (
<IconButton <Tooltip
key={nation} content={
style={{ flex: 1 }} strings.common.nations[
variant={selected ? 'solid' : 'soft'} nation as keyof typeof strings.common.nations
color={selected ? undefined : 'gray'} ]
highContrast
radius="none"
onClick={() =>
mutateTankFilters((draft) => {
if (draft.nations?.includes(nation)) {
draft.nations = draft.nations?.filter(
(n) => n !== nation,
);
} else {
draft.nations = [...(draft.nations ?? []), nation];
}
})
} }
key={nation}
> >
<img <IconButton
style={{ width: '1em', height: '1em' }} key={nation}
src={asset(`flags/circle/${nation}.webp`)} style={{ flex: 1 }}
/> variant={selected ? 'solid' : 'soft'}
</IconButton> color={selected ? undefined : 'gray'}
highContrast
radius="none"
onClick={() =>
mutateTankFilters((draft) => {
if (draft.nations?.includes(nation)) {
draft.nations = draft.nations?.filter(
(n) => n !== nation,
);
} else {
draft.nations = [...(draft.nations ?? []), nation];
}
})
}
>
<img
style={{ width: '1em', height: '1em' }}
src={asset(`flags/circle/${nation}.webp`)}
/>
</IconButton>
</Tooltip>
); );
})} })}
</Flex> </Flex>
@@ -173,26 +195,30 @@ export function FilterControl({ compact }: FilterControlProps) {
const selected = filters.classes?.includes(tankClass); const selected = filters.classes?.includes(tankClass);
return ( return (
<IconButton <Tooltip
content={strings.common.tank_class_medium[tankClass]}
key={tankClass} key={tankClass}
variant={selected ? 'solid' : 'soft'}
radius="none"
color={selected ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
if (draft.classes?.includes(tankClass)) {
draft.classes = draft.classes?.filter(
(c) => c !== tankClass,
);
} else {
draft.classes = [...(draft.classes ?? []), tankClass];
}
})
}
> >
<Icon style={{ width: '1em', height: '1em' }} /> <IconButton
</IconButton> variant={selected ? 'solid' : 'soft'}
radius="none"
color={selected ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
if (draft.classes?.includes(tankClass)) {
draft.classes = draft.classes?.filter(
(c) => c !== tankClass,
);
} else {
draft.classes = [...(draft.classes ?? []), tankClass];
}
})
}
>
<Icon style={{ width: '1em', height: '1em' }} />
</IconButton>
</Tooltip>
); );
})} })}
</Flex> </Flex>
@@ -202,67 +228,75 @@ export function FilterControl({ compact }: FilterControlProps) {
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { sm: 'row', initial: 'column' }} direction={compact ? 'column' : { sm: 'row', initial: 'column' }}
> >
<IconButton <Tooltip content="Researchable">
variant={filters.types?.includes('researchable') ? 'solid' : 'soft'} <IconButton
radius="none" variant={filters.types?.includes('researchable') ? 'solid' : 'soft'}
color={filters.types?.includes('researchable') ? undefined : 'gray'} radius="none"
highContrast color={filters.types?.includes('researchable') ? undefined : 'gray'}
onClick={() => highContrast
mutateTankFilters((draft) => { onClick={() =>
if (draft.types?.includes('researchable')) { mutateTankFilters((draft) => {
draft.types = draft.types?.filter((t) => t !== 'researchable'); if (draft.types?.includes('researchable')) {
} else { draft.types = draft.types?.filter(
draft.types = [...(draft.types ?? []), 'researchable']; (t) => t !== 'researchable',
} );
}) } else {
} draft.types = [...(draft.types ?? []), 'researchable'];
> }
<ResearchedIcon style={{ width: '1em', height: '1em' }} /> })
</IconButton> }
<IconButton
variant={filters.types?.includes('premium') ? 'solid' : 'soft'}
radius="none"
color={filters.types?.includes('premium') ? 'amber' : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
if (draft.types?.includes('premium')) {
draft.types = draft.types?.filter((t) => t !== 'premium');
} else {
draft.types = [...(draft.types ?? []), 'premium'];
}
})
}
>
<Text
color={filters.types?.includes('premium') ? undefined : 'amber'}
style={{ display: 'flex', justifyContent: 'center' }}
> >
<ResearchedIcon style={{ width: '1em', height: '1em' }} /> <ResearchedIcon style={{ width: '1em', height: '1em' }} />
</Text> </IconButton>
</IconButton> </Tooltip>
<IconButton <Tooltip content="Premium">
variant={filters.types?.includes('collector') ? 'solid' : 'soft'} <IconButton
radius="none" variant={filters.types?.includes('premium') ? 'solid' : 'soft'}
color={filters.types?.includes('collector') ? 'blue' : 'gray'} radius="none"
highContrast color={filters.types?.includes('premium') ? 'amber' : 'gray'}
onClick={() => highContrast
mutateTankFilters((draft) => { onClick={() =>
if (draft.types?.includes('collector')) { mutateTankFilters((draft) => {
draft.types = draft.types?.filter((t) => t !== 'collector'); if (draft.types?.includes('premium')) {
} else { draft.types = draft.types?.filter((t) => t !== 'premium');
draft.types = [...(draft.types ?? []), 'collector']; } else {
} draft.types = [...(draft.types ?? []), 'premium'];
}) }
} })
> }
<Text
color={filters.types?.includes('collector') ? undefined : 'blue'}
style={{ display: 'flex', justifyContent: 'center' }}
> >
<ResearchedIcon style={{ width: '1em', height: '1em' }} /> <Text
</Text> color={filters.types?.includes('premium') ? undefined : 'amber'}
</IconButton> style={{ display: 'flex', justifyContent: 'center' }}
>
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
</Text>
</IconButton>
</Tooltip>
<Tooltip content="Collector">
<IconButton
variant={filters.types?.includes('collector') ? 'solid' : 'soft'}
radius="none"
color={filters.types?.includes('collector') ? 'blue' : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
if (draft.types?.includes('collector')) {
draft.types = draft.types?.filter((t) => t !== 'collector');
} else {
draft.types = [...(draft.types ?? []), 'collector'];
}
})
}
>
<Text
color={filters.types?.includes('collector') ? undefined : 'blue'}
style={{ display: 'flex', justifyContent: 'center' }}
>
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
</Text>
</IconButton>
</Tooltip>
</Flex> </Flex>
<Flex <Flex
@@ -270,51 +304,57 @@ export function FilterControl({ compact }: FilterControlProps) {
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { sm: 'row', initial: 'column' }} direction={compact ? 'column' : { sm: 'row', initial: 'column' }}
> >
<IconButton <Tooltip content="Include test tank">
variant={filters.testing === 'include' ? 'solid' : 'soft'} <IconButton
radius="none" variant={filters.testing === 'include' ? 'solid' : 'soft'}
color={filters.testing === 'include' ? undefined : 'gray'} radius="none"
highContrast color={filters.testing === 'include' ? undefined : 'gray'}
onClick={() => highContrast
mutateTankFilters((draft) => { onClick={() =>
draft.testing = 'include'; mutateTankFilters((draft) => {
}) draft.testing = 'include';
} })
> }
<ExperimentIcon >
style={{ width: '1em', height: '1em', color: 'currentColor' }} <ExperimentIcon
/> style={{ width: '1em', height: '1em', color: 'currentColor' }}
</IconButton> />
<IconButton </IconButton>
variant={filters.testing === 'exclude' ? 'solid' : 'soft'} </Tooltip>
radius="none" <Tooltip content="Exclude test tank">
color={filters.testing === 'exclude' ? undefined : 'gray'} <IconButton
highContrast variant={filters.testing === 'exclude' ? 'solid' : 'soft'}
onClick={() => radius="none"
mutateTankFilters((draft) => { color={filters.testing === 'exclude' ? undefined : 'gray'}
draft.testing = 'exclude'; highContrast
}) onClick={() =>
} mutateTankFilters((draft) => {
> draft.testing = 'exclude';
<ScienceOffIcon })
style={{ width: '1em', height: '1em', color: 'currentColor' }} }
/> >
</IconButton> <ScienceOffIcon
<IconButton style={{ width: '1em', height: '1em', color: 'currentColor' }}
variant={filters.testing === 'only' ? 'solid' : 'soft'} />
radius="none" </IconButton>
color={filters.testing === 'only' ? undefined : 'gray'} </Tooltip>
highContrast <Tooltip content="Only test tank">
onClick={() => <IconButton
mutateTankFilters((draft) => { variant={filters.testing === 'only' ? 'solid' : 'soft'}
draft.testing = 'only'; radius="none"
}) color={filters.testing === 'only' ? undefined : 'gray'}
} highContrast
> onClick={() =>
<ScienceIcon mutateTankFilters((draft) => {
style={{ width: '1em', height: '1em', color: 'currentColor' }} draft.testing = 'only';
/> })
</IconButton> }
>
<ScienceIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
</Tooltip>
</Flex> </Flex>
<Box> <Box>
@@ -15,6 +15,7 @@ interface SearchBarProps {
export function SearchBar({ topResult, onSelect }: SearchBarProps) { export function SearchBar({ topResult, onSelect }: SearchBarProps) {
const tankFiltersStore = TankFilters.useStore(); const tankFiltersStore = TankFilters.useStore();
const lastSearch = tankFiltersStore.getState().search;
const router = useRouter(); const router = useRouter();
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const searching = TankFilters.use((state) => state.searching); const searching = TankFilters.use((state) => state.searching);
@@ -58,6 +59,7 @@ export function SearchBar({ topResult, onSelect }: SearchBarProps) {
<Flex justify="center" mt="4"> <Flex justify="center" mt="4">
<Flex gap="2" flexGrow="1"> <Flex gap="2" flexGrow="1">
<TextField.Root <TextField.Root
defaultValue={lastSearch}
style={{ flex: 1 }} style={{ flex: 1 }}
ref={input} ref={input}
placeholder="Search tanks..." placeholder="Search tanks..."
@@ -9,6 +9,7 @@ export const card = style({
alignItems: 'center', alignItems: 'center',
gap: 'var(--space-2)', gap: 'var(--space-2)',
background: cardBackgroundVar, background: cardBackgroundVar,
overflow: 'hidden',
}); });
export const image = style({ export const image = style({
@@ -34,6 +35,7 @@ export const name = style({
export const link = style({ export const link = style({
color: 'inherit', color: 'inherit',
textDecoration: 'none', textDecoration: 'none',
width: '100%',
':hover': { ':hover': {
textDecoration: 'underline', textDecoration: 'underline',
@@ -162,7 +162,6 @@ export function TankCard({ tank, onSelect }: TankCardProps) {
align="center" align="center"
overflow="hidden" overflow="hidden"
width="100%" width="100%"
maxWidth="100%"
> >
<Icon className={styles.classIcon} /> <Icon className={styles.classIcon} />
<Text align="center" className={styles.name}> <Text align="center" className={styles.name}>
@@ -18,8 +18,8 @@ import {
TankDefinition, TankDefinition,
tankDefinitions, tankDefinitions,
tankDefinitionsArray, tankDefinitionsArray,
tankNames,
} from '../../../../../core/blitzkit/tankDefinitions'; } from '../../../../../core/blitzkit/tankDefinitions';
import { tankNames } from '../../../../../core/blitzkit/tankDefinitions/tankNames';
import { unionBoundingBox } from '../../../../../core/blitzkit/unionBoundingBox'; import { unionBoundingBox } from '../../../../../core/blitzkit/unionBoundingBox';
import { useAdExempt } from '../../../../../hooks/useAdExempt'; import { useAdExempt } from '../../../../../hooks/useAdExempt';
import * as TankFilters from '../../../../../stores/tankFilters'; import * as TankFilters from '../../../../../stores/tankFilters';
@@ -375,8 +375,7 @@ export const TankSearch = memo<TankSearchProps>(
return sort.direction === 'ascending' ? sorted : sorted.reverse(); return sort.direction === 'ascending' ? sorted : sorted.reverse();
} else { } else {
const searchedRaw = go(filters.search, awaitedTankNames, { const searchedRaw = go(filters.search, awaitedTankNames, {
key: 'combined', keys: ['searchableName', 'searchableNameDeburr', 'camouflages'],
limit: 25,
}); });
const searchedTanks = searchedRaw.map( const searchedTanks = searchedRaw.map(
(result) => awaitedTankDefinitions[result.obj.id], (result) => awaitedTankDefinitions[result.obj.id],
@@ -434,8 +433,8 @@ export const TankSearch = memo<TankSearchProps>(
<ExperimentIcon style={{ width: '1em', height: '1em' }} /> <ExperimentIcon style={{ width: '1em', height: '1em' }} />
</Callout.Icon> </Callout.Icon>
<Callout.Text> <Callout.Text>
Tanks in testing are subject to change and many not represent Tanks in testing are subject to change and may not represent the
the final product. final product.
</Callout.Text> </Callout.Text>
</Callout.Root> </Callout.Root>
</Flex> </Flex>
+5 -2
View File
@@ -1,10 +1,13 @@
import * as TankFilters from '../../../stores/tankFilters';
import * as TankopediaPersistent from '../../../stores/tankopediaPersistent'; import * as TankopediaPersistent from '../../../stores/tankopediaPersistent';
import * as TankopediaSort from '../../../stores/tankopediaSort'; import * as TankopediaSort from '../../../stores/tankopediaSort';
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<TankopediaPersistent.Provider> <TankFilters.Provider>
<TankopediaPersistent.Provider>
<TankopediaSort.Provider>{children}</TankopediaSort.Provider> <TankopediaSort.Provider>{children}</TankopediaSort.Provider>
</TankopediaPersistent.Provider> </TankopediaPersistent.Provider>
</TankFilters.Provider>
); );
} }
-3
View File
@@ -1,13 +1,10 @@
import PageWrapper from '../../../components/PageWrapper'; import PageWrapper from '../../../components/PageWrapper';
import * as TankFilters from '../../../stores/tankFilters';
import { TankSearch } from './components/TankSearch'; import { TankSearch } from './components/TankSearch';
export default function Page() { export default function Page() {
return ( return (
<TankFilters.Provider>
<PageWrapper color="purple" size={1028}> <PageWrapper color="purple" size={1028}>
<TankSearch /> <TankSearch />
</PageWrapper> </PageWrapper>
</TankFilters.Provider>
); );
} }
@@ -22,8 +22,7 @@ export function ArmorPlateDisplay() {
(state) => state.highlightArmor, (state) => state.highlightArmor,
); );
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const developerMode = App.use((state) => state.developerMode); const developerMode = App.useDeferred(false, (state) => state.developerMode);
const devThicknessInput = useRef<HTMLInputElement>(null);
const duelStore = Duel.useStore(); const duelStore = Duel.useStore();
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const tankopediaEphemeralStore = TankopediaEphemeral.useStore(); const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
@@ -7,7 +7,6 @@ import { useArmor } from '../../../hooks/useArmor';
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition'; import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition';
import { useTankTransform } from '../../../hooks/useTankTransform'; import { useTankTransform } from '../../../hooks/useTankTransform';
import * as Duel from '../../../stores/duel'; import * as Duel from '../../../stores/duel';
import * as TankopediaPersistent from '../../../stores/tankopediaPersistent';
import { ModelTankWrapper } from './ModelTankWrapper'; import { ModelTankWrapper } from './ModelTankWrapper';
import { PrimaryArmorSceneComponent } from './PrimaryArmorSceneComponent'; import { PrimaryArmorSceneComponent } from './PrimaryArmorSceneComponent';
@@ -29,13 +28,6 @@ export const PrimaryArmorScene = memo(() => {
const hullOrigin = correctZYTuple(trackModelDefinition.origin); const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turretOrigin); const turretOrigin = correctZYTuple(tankModelDefinition.turretOrigin);
const gunOrigin = correctZYTuple(turretModelDefinition.gunOrigin); const gunOrigin = correctZYTuple(turretModelDefinition.gunOrigin);
const tankopediaPersistentStore = TankopediaPersistent.useStore();
console.log(
tankModelDefinition.armor,
turretModelDefinition.armor,
gunModelDefinition.armor,
);
useTankTransform(protagonist, turretContainer, gunContainer); useTankTransform(protagonist, turretContainer, gunContainer);
@@ -140,8 +140,8 @@ export function SpacedArmorSceneComponent({
if ( if (
!allowRicochet && !allowRicochet &&
!noDuplicateIntersections.some((intersection) => { noDuplicateIntersections.every((intersection) => {
intersection.object.userData.type === ArmorType.Primary; intersection.object.userData.type !== ArmorType.Primary;
}) })
) { ) {
return null; return null;
+9 -18
View File
@@ -1,37 +1,31 @@
import { Flex } from '@radix-ui/themes'; import { Flex, FlexProps } from '@radix-ui/themes';
import { ConsumableEntry } from '../core/blitzkit/consumableDefinitions'; import { ConsumableEntry } from '../core/blitzkit/consumableDefinitions';
import { ConsumableButton } from './ModuleButtons/ConsumableButton'; import { ConsumableButton } from './ModuleButtons/ConsumableButton';
interface ConsumablesManagerProps { type ConsumablesManagerProps = FlexProps & {
consumables: ConsumableEntry[]; consumables: ConsumableEntry[];
selected: number[]; selected: number[];
onChange?: (consumables: number[]) => void; onConsumablesChange?: (consumables: number[]) => void;
disabled?: boolean; disabled?: boolean;
timers?: boolean; timers?: boolean;
hasHighEndConsumables?: boolean; hasHighEndConsumables?: boolean;
hasConsumableDeliverySystem?: boolean; hasConsumableDeliverySystem?: boolean;
cooldownBooster?: number; cooldownBooster?: number;
} };
export function ConsumablesManager({ export function ConsumablesManager({
consumables, consumables,
selected, selected,
onChange, onConsumablesChange,
disabled, disabled,
timers, timers,
hasConsumableDeliverySystem, hasConsumableDeliverySystem,
cooldownBooster, cooldownBooster,
hasHighEndConsumables, hasHighEndConsumables,
...props
}: ConsumablesManagerProps) { }: ConsumablesManagerProps) {
return ( return (
<Flex <Flex wrap="wrap" gap="2" {...props}>
wrap="wrap"
style={{
maxWidth: 288,
borderRadius: 4,
overflow: 'hidden',
}}
>
{consumables.map((consumable, index) => { {consumables.map((consumable, index) => {
const isSelected = selected.some((id) => id === consumable.id); const isSelected = selected.some((id) => id === consumable.id);
@@ -53,14 +47,11 @@ export function ConsumablesManager({
: undefined : undefined
} }
key={consumable.id} key={consumable.id}
first={index === 0}
last={index === consumables.length - 1}
rowChild
disabled={disabled && !isSelected} disabled={disabled && !isSelected}
consumable={consumable.id} consumable={consumable.id}
selected={isSelected} selected={isSelected}
onClick={() => { onClick={() => {
if (!onChange) return; if (!onConsumablesChange) return;
const draft = [...selected]; const draft = [...selected];
@@ -70,7 +61,7 @@ export function ConsumablesManager({
draft.push(consumable.id); draft.push(consumable.id);
} }
onChange(draft); onConsumablesChange(draft);
}} }}
/> />
); );
-2
View File
@@ -30,8 +30,6 @@ export function CrewSkillManager({
<GenericTankComponentButton <GenericTankComponentButton
key={skill} key={skill}
special={!permanentSkills.includes(skill)} special={!permanentSkills.includes(skill)}
first
last
selected={level > 0} selected={level > 0}
discriminator={ discriminator={
level === 0 ? undefined : TIER_ROMAN_NUMERALS[level as Tier] level === 0 ? undefined : TIER_ROMAN_NUMERALS[level as Tier]
+8 -4
View File
@@ -23,8 +23,10 @@ export function EquipmentManager({
<Flex key={columnIndex}> <Flex key={columnIndex}>
<EquipmentButton <EquipmentButton
equipment={equipment[0]} equipment={equipment[0]}
first style={{
rowChild borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
selected={matrix[rowIndex][columnIndex] === -1} selected={matrix[rowIndex][columnIndex] === -1}
onClick={() => { onClick={() => {
const draft = cloneDeep(matrix); const draft = cloneDeep(matrix);
@@ -40,8 +42,10 @@ export function EquipmentManager({
/> />
<EquipmentButton <EquipmentButton
equipment={equipment[1]} equipment={equipment[1]}
last style={{
rowChild borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
selected={matrix[rowIndex][columnIndex] === 1} selected={matrix[rowIndex][columnIndex] === 1}
onClick={() => { onClick={() => {
const draft = cloneDeep(matrix); const draft = cloneDeep(matrix);
+2 -2
View File
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
export const Link = forwardRef<HTMLAnchorElement, LinkProps>( export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
({ href, onClick, ...props }, ref) => { ({ href, onClick, target, ...props }, ref) => {
const router = useRouter(); const router = useRouter();
return ( return (
@@ -15,7 +15,7 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
if (href) { if (href) {
if (event.ctrlKey || event.metaKey) { if (event.ctrlKey || event.metaKey || target === '_blank') {
window.open(href); window.open(href);
} else { } else {
router.push(href); router.push(href);
@@ -1,6 +1,8 @@
import { ClockIcon, ReloadIcon } from '@radix-ui/react-icons'; import { ClockIcon, ReloadIcon } from '@radix-ui/react-icons';
import { Flex, Text } from '@radix-ui/themes'; import { Flex, Text } from '@radix-ui/themes';
import { use } from 'react';
import { asset } from '../../core/blitzkit/asset'; import { asset } from '../../core/blitzkit/asset';
import { consumableDefinitions } from '../../core/blitzkit/consumableDefinitions';
import { useDelta } from '../../hooks/useDelta'; import { useDelta } from '../../hooks/useDelta';
import { GenericTankComponentButton } from './GenericTankComponentButton'; import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton'; import { TankComponentButtonProps } from './TankComponentButton';
@@ -17,23 +19,22 @@ export function ConsumableButton({
duration, duration,
...props ...props
}: ConsumableButtonProps) { }: ConsumableButtonProps) {
const awaitedConsumableDefinitions = use(consumableDefinitions);
const cooldownDelta = useDelta(cooldown ?? 0); const cooldownDelta = useDelta(cooldown ?? 0);
const durationDelta = useDelta(duration ?? 0); const durationDelta = useDelta(duration ?? 0);
return ( return (
<GenericTankComponentButton <GenericTankComponentButton
style={{ width: '6rem' }}
tooltip={awaitedConsumableDefinitions[consumable].name}
icon={asset(`icons/consumables/${consumable}.webp`)} icon={asset(`icons/consumables/${consumable}.webp`)}
style={{
flex: 1,
minWidth: 'fit-content',
borderRadius: 0,
}}
{...props} {...props}
> >
{(cooldown || duration) && ( {(cooldown || duration) && (
<Flex direction="column" style={{ transform: 'translateX(-10px)' }}> <Flex direction="column" gap="1" ml="-2" mr="2">
{duration && ( {duration && (
<Text <Text
size="1"
color={ color={
durationDelta > 0 durationDelta > 0
? 'green' ? 'green'
@@ -50,6 +51,7 @@ export function ConsumableButton({
)} )}
{cooldown && ( {cooldown && (
<Text <Text
size="1"
color={ color={
cooldownDelta < 0 cooldownDelta < 0
? 'green' ? 'green'
@@ -1,4 +1,6 @@
import { use } from 'react';
import { asset } from '../../core/blitzkit/asset'; import { asset } from '../../core/blitzkit/asset';
import { equipmentDefinitions } from '../../core/blitzkit/equipmentDefinitions';
import { GenericTankComponentButton } from './GenericTankComponentButton'; import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton'; import { TankComponentButtonProps } from './TankComponentButton';
@@ -7,8 +9,11 @@ interface EquipmentButtonProps extends TankComponentButtonProps {
} }
export function EquipmentButton({ equipment, ...props }: EquipmentButtonProps) { export function EquipmentButton({ equipment, ...props }: EquipmentButtonProps) {
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
return ( return (
<GenericTankComponentButton <GenericTankComponentButton
tooltip={awaitedEquipmentDefinitions.equipments[equipment].name}
icon={asset(`icons/equipment/${equipment}.webp`)} icon={asset(`icons/equipment/${equipment}.webp`)}
{...props} {...props}
/> />
@@ -1,4 +1,6 @@
import { use } from 'react';
import { asset } from '../../core/blitzkit/asset'; import { asset } from '../../core/blitzkit/asset';
import { provisionDefinitions } from '../../core/blitzkit/provisionDefinitions';
import { GenericTankComponentButton } from './GenericTankComponentButton'; import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton'; import { TankComponentButtonProps } from './TankComponentButton';
@@ -7,8 +9,11 @@ interface ProvisionButtonProps extends TankComponentButtonProps {
} }
export function ProvisionButton({ provision, ...props }: ProvisionButtonProps) { export function ProvisionButton({ provision, ...props }: ProvisionButtonProps) {
const awaitedProvisionDefinitions = use(provisionDefinitions);
return ( return (
<GenericTankComponentButton <GenericTankComponentButton
tooltip={awaitedProvisionDefinitions[provision].name}
icon={asset(`icons/provisions/${provision}.webp`)} icon={asset(`icons/provisions/${provision}.webp`)}
{...props} {...props}
/> />
@@ -1,34 +1,30 @@
import { Button, Text } from '@radix-ui/themes'; import { Button, Text, Tooltip } from '@radix-ui/themes';
import { ComponentProps, ReactNode } from 'react'; import { ComponentProps, ReactNode } from 'react';
export interface TankComponentButtonProps export interface TankComponentButtonProps
extends ComponentProps<typeof Button> { extends ComponentProps<typeof Button> {
selected?: boolean; selected?: boolean;
first?: boolean;
last?: boolean;
rowChild?: boolean;
children?: ReactNode; children?: ReactNode;
discriminator?: ReactNode; discriminator?: ReactNode;
disabled?: boolean; disabled?: boolean;
banner?: string; banner?: string;
special?: boolean; special?: boolean;
tooltip?: string;
} }
export function TankComponentButton({ export function TankComponentButton({
variant, variant,
selected, selected,
first = false,
last = false,
rowChild,
discriminator, discriminator,
special, special,
children, children,
banner, banner,
disabled, disabled,
style, style,
tooltip,
...props ...props
}: TankComponentButtonProps) { }: TankComponentButtonProps) {
return ( const button = (
<Button <Button
radius="medium" radius="medium"
color={selected ? (special ? 'amber' : undefined) : 'gray'} color={selected ? (special ? 'amber' : undefined) : 'gray'}
@@ -38,11 +34,6 @@ export function TankComponentButton({
minWidth: 48, minWidth: 48,
height: 40, height: 40,
position: 'relative', position: 'relative',
borderTopLeftRadius: first ? undefined : 0,
borderTopRightRadius: last ? undefined : 0,
borderBottomLeftRadius: first ? undefined : 0,
borderBottomRightRadius: last ? undefined : 0,
margin: rowChild ? -0.5 : 'unset',
overflow: 'hidden', overflow: 'hidden',
cursor: 'unset', cursor: 'unset',
pointerEvents: variant === 'ghost' ? 'none' : undefined, pointerEvents: variant === 'ghost' ? 'none' : undefined,
@@ -84,4 +75,7 @@ export function TankComponentButton({
)} )}
</Button> </Button>
); );
if (tooltip) return <Tooltip content={tooltip}>{button}</Tooltip>;
return button;
} }
-12
View File
@@ -41,9 +41,6 @@ export function ModuleManager({
<ModuleButton <ModuleButton
module="turret" module="turret"
discriminator={TIER_ROMAN_NUMERALS[modules.turret.tier]} discriminator={TIER_ROMAN_NUMERALS[modules.turret.tier]}
first
last
rowChild
onClick={() => setTurretMenuOpen(true)} onClick={() => setTurretMenuOpen(true)}
/> />
@@ -85,9 +82,6 @@ export function ModuleManager({
<ModuleButton <ModuleButton
module="gun" module="gun"
discriminator={TIER_ROMAN_NUMERALS[modules.gun.tier]} discriminator={TIER_ROMAN_NUMERALS[modules.gun.tier]}
first
last
rowChild
onClick={() => setGunMenuOpen(true)} onClick={() => setGunMenuOpen(true)}
/> />
@@ -128,9 +122,6 @@ export function ModuleManager({
<ModuleButton <ModuleButton
module="engine" module="engine"
discriminator={TIER_ROMAN_NUMERALS[modules.engine.tier]} discriminator={TIER_ROMAN_NUMERALS[modules.engine.tier]}
first
last
rowChild
onClick={() => setEngineMenuOpen(true)} onClick={() => setEngineMenuOpen(true)}
/> />
@@ -170,9 +161,6 @@ export function ModuleManager({
<ModuleButton <ModuleButton
module="chassis" module="chassis"
discriminator={TIER_ROMAN_NUMERALS[modules.track.tier]} discriminator={TIER_ROMAN_NUMERALS[modules.track.tier]}
first
last
rowChild
onClick={() => setTrackMenuOpen(true)} onClick={() => setTrackMenuOpen(true)}
/> />
@@ -1,42 +0,0 @@
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import { Card, TextField } from '@radix-ui/themes';
import { ComponentProps, useState } from 'react';
export function EverythingSearch({
style,
...props
}: ComponentProps<typeof TextField.Root>) {
const [search, setSearch] = useState('');
return (
<>
<TextField.Root
value={search}
placeholder="Search tanks, players, clans..."
style={{ position: 'relative', ...style }}
onChange={(event) => {
setSearch(event.target.value.trim());
}}
{...props}
>
<TextField.Slot>
<MagnifyingGlassIcon />
</TextField.Slot>
{search.length > 0 && (
<TextField.Slot>
<Card
style={{
position: 'absolute',
top: 'calc(100% + 8px)',
left: 0,
width: '100%',
zIndex: 1,
}}
/>
</TextField.Slot>
)}
</TextField.Root>
</>
);
}
+51 -38
View File
@@ -1,56 +1,69 @@
import { style, styleVariants } from '@vanilla-extract/css'; import { style, styleVariants } from '@vanilla-extract/css';
const MIN_EXPANDED_TOOLS = 752; const MIN_WIDTH = 640 + 128;
const HEIGHT = 48;
export const hamburger = style({
display: 'inline-flex',
export const hamburgerButton = style({
'@media': { '@media': {
[`screen and (min-width: ${MIN_EXPANDED_TOOLS}px)`]: { [`screen and (min-width: ${MIN_WIDTH}px)`]: {
display: 'none', display: 'none',
}, },
}, },
}); });
const navbarBase = style({ export const tools = style({
position: 'fixed', display: 'none',
'@media': {
[`screen and (min-width: ${MIN_WIDTH}px)`]: {
display: 'flex',
},
},
});
export const navbar = style({
height: HEIGHT,
marginBottom: -1, // 1px overlap with content to bleed color
position: 'sticky',
top: 0, top: 0,
left: 0, zIndex: 1,
});
const navbarExpanderBase = style({
transitionDuration: '250ms',
height: '1000px',
width: '100%', width: '100%',
zIndex: 2,
padding: '1rem',
backgroundColor: 'var(--color-panel-translucent)',
backdropFilter: 'blur(4rem)',
WebkitBackdropFilter: 'blur(4rem)',
boxSizing: 'border-box',
transition: 'max-height 0.2s ease-in-out',
overflow: 'hidden', overflow: 'hidden',
});
export const navbar = styleVariants({
expanded: [
navbarBase,
{
maxHeight: '100%',
},
],
collapsed: [
navbarBase,
{
maxHeight: 65,
},
],
});
export const toolCard = style({ backdropFilter: 'blur(4rem) brightness(0.75)',
boxShadow: `inset 0 -200px 128px -128px var(--color-overlay)`, WebkitBackdropFilter: 'blur(4rem) brightness(0.75)',
':hover': {
boxShadow: `inset 0 -160px 128px -128px #ffffff00`,
},
});
export const toolTexts = style({
'@media': { '@media': {
[`screen and (max-width: ${MIN_EXPANDED_TOOLS}px)`]: { [`screen and (min-width: ${MIN_WIDTH}px)`]: {
display: 'none', maxHeight: HEIGHT,
}, },
}, },
}); });
export const navbarExpander = styleVariants({
false: [
navbarExpanderBase,
{
maxHeight: HEIGHT,
},
],
true: [
navbarExpanderBase,
{
maxHeight: '100vh',
overscrollBehavior: 'contain',
},
],
});
export const navbarContent = style({
width: '100%',
maxHeight: '100vh',
});
+281 -268
View File
@@ -12,302 +12,315 @@ import {
Button, Button,
Dialog, Dialog,
Flex, Flex,
Grid,
IconButton, IconButton,
Inset, Inset,
Popover, Popover,
ScrollArea,
Separator, Separator,
Text, Text,
} from '@radix-ui/themes'; } from '@radix-ui/themes';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useState } from 'react'; import { Fragment, useState } from 'react';
import { REGIONS } from '../../constants/regions'; import { REGIONS } from '../../constants/regions';
import { TOOLS } from '../../constants/tools'; import { homeTool, TOOLS } from '../../constants/tools';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import { imgur, ImgurSize } from '../../core/blitzkit/imgur'; import { imgur, ImgurSize } from '../../core/blitzkit/imgur';
import { patreonLoginUrl } from '../../core/blitzkit/patreonLoginUrl'; import { patreonLoginUrl } from '../../core/blitzkit/patreonLoginUrl';
import { PatreonIcon } from '../../icons/Patreon'; import { PatreonIcon } from '../../icons/Patreon';
import { WargamingIcon } from '../../icons/Wargaming'; import { WargamingIcon } from '../../icons/Wargaming';
import strings from '../../lang/en-US.json'; import strings from '../../lang/en-US.json';
import { theme } from '../../stitches.config';
import * as App from '../../stores/app'; import * as App from '../../stores/app';
import { Link } from '../Link'; import { Link } from '../Link';
import * as styles from './index.css'; import * as styles from './index.css';
export const NAVBAR_HEIGHT = 64;
export default function Navbar() { export default function Navbar() {
const [showHamburgerMenu, setShowHamburgerMenu] = useState(false); const [showHamburgerMenu, setShowHamburgerMenu] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
const logins = App.use((state) => state.logins); const logins = App.use((state) => state.logins);
const filteredTools = TOOLS.filter((tool) => !tool.href);
const mutateApp = App.useMutation(); const mutateApp = App.useMutation();
const tools = [homeTool, ...TOOLS];
return ( return (
<Box <Flex className={styles.navbar}>
className={styles.navbar[showHamburgerMenu ? 'expanded' : 'collapsed']} <Flex
style={{ zIndex: 3 }} className={styles.navbarExpander[`${showHamburgerMenu}`]}
> onClick={() => setShowHamburgerMenu(false)}
<Flex direction="column" align="center"> direction="column"
<Flex gap="2"
position="absolute" >
top="34px" <Flex justify="center">
justify="between" <Flex flexGrow="1" maxWidth="1024px" p="3" align="center">
align="center" <Flex gap="3" align="center">
style={{ <IconButton
maxWidth: 1024, variant="ghost"
width: '100%', color="gray"
padding: '0 16px', className={styles.hamburger}
transform: 'translateY(-50%)', onClick={(event) => {
}} event.stopPropagation();
gap="8" setShowHamburgerMenu((state) => !state);
>
<Flex align="center" gap="4">
<IconButton
className={styles.hamburgerButton}
size="4"
variant="ghost"
color="gray"
onClick={() => setShowHamburgerMenu((state) => !state)}
>
{showHamburgerMenu ? <Cross1Icon /> : <HamburgerMenuIcon />}
</IconButton>
<Link
onClick={() => setShowHamburgerMenu(false)}
href="/"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<b>BlitzKit</b>
<Text color="gray" size="2" trim="both">
.app
</Text>
</Link>
</Flex>
<Flex justify="center" gap="4" className={styles.toolTexts}>
{filteredTools.map((tool) => {
const selected = pathname.startsWith(`/tools/${tool.id}`);
return (
<Link
key={tool.id}
color="gray"
size="2"
highContrast={selected}
underline={selected ? 'always' : 'hover'}
href={`/tools/${tool.id}`}
>
{tool.title}
</Link>
);
})}
</Flex>
<Flex align="center" gap="4">
<Link
onClick={() => setShowHamburgerMenu(false)}
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<DiscordLogoIcon width={16} height={16} />
</Link>
<Link
onClick={() => setShowHamburgerMenu(false)}
href="https://www.patreon.com/tresabhi"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PatreonIcon width={14} height={14} />
</Link>
<Link
href="/settings"
onClick={() => setShowHamburgerMenu(false)}
color="gray"
highContrast
>
<Flex style={{ width: '100%', height: '100%' }} justify="center">
<GearIcon />
</Flex>
</Link>
<Popover.Root>
<Popover.Trigger>
<PersonIcon style={{ cursor: 'pointer' }} />
</Popover.Trigger>
<Popover.Content
align="end"
width="320px"
onOpenAutoFocus={(event) => event.preventDefault()}
>
{(logins.patreon || logins.wargaming) && (
<Flex direction="column" gap="2">
<Text align="center" color="gray">
Logged in accounts
</Text>
{logins.patreon && (
<Flex align="center" gap="2" justify="center">
<PatreonIcon width={15} height={15} />
<Text>Patreon</Text>
<Button
color="red"
variant="ghost"
onClick={() =>
mutateApp((draft) => {
draft.logins.patreon = undefined;
})
}
>
Logout
</Button>
</Flex>
)}
{logins.wargaming && (
<Flex align="center" gap="2" justify="center">
<WargamingIcon width={15} height={15} />
<Text>Wargaming</Text>
<Button
color="red"
variant="ghost"
onClick={() =>
mutateApp((draft) => {
draft.logins.wargaming = undefined;
})
}
>
Logout
</Button>
</Flex>
)}
</Flex>
)}
{!logins.patreon !== !logins.wargaming && (
<Inset side="x">
<Separator my="4" size="4" />
</Inset>
)}
{(!logins.patreon || !logins.wargaming) && (
<Flex direction="column" gap="2">
<Text align="center" color="gray">
Log in with...
</Text>
{!logins.patreon && (
<Link style={{ width: '100%' }} href={patreonLoginUrl()}>
<Button style={{ width: '100%' }} color="tomato">
<PatreonIcon width={15} height={15} /> Patreon
</Button>
</Link>
)}
{!logins.wargaming && (
<Dialog.Root>
<Dialog.Trigger>
<Button color="red">
<WargamingIcon width={15} height={15} /> Wargaming
</Button>
</Dialog.Trigger>
<Dialog.Content width="fit-content">
<Flex direction="column" gap="4" align="center">
<Text color="gray">Choose your region</Text>
<Flex gap="2" wrap="wrap">
{REGIONS.map((region) => (
<Dialog.Close key={region}>
<Link
href={
typeof window !== 'undefined'
? `https://api.worldoftanks.${region}/wot/auth/login/?application_id=${WARGAMING_APPLICATION_ID}&redirect_uri=${encodeURIComponent(
`${location.origin}/auth/wargaming?return=${location.origin}${location.pathname}`,
)}`
: undefined
}
>
<Button color="red">
{strings.common.regions.normal[region]}
</Button>
</Link>
</Dialog.Close>
))}
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
)}
</Flex>
)}
</Popover.Content>
</Popover.Root>
</Flex>
</Flex>
<Flex mt="9" gap="3" justify="center" wrap="wrap">
{filteredTools.map((tool) => (
<Link
key={tool.id}
href={`/tools/${tool.id}`}
className={styles.toolCard}
style={{
backgroundImage: `url(${imgur(tool.image, {
format: 'jpeg',
size: ImgurSize.MediumThumbnail,
})})`,
cursor: tool.disabled ? 'default' : 'pointer',
opacity: tool.disabled ? 0.25 : 1,
height: 64,
minWidth: 176,
flex: 1,
borderRadius: 8,
textDecoration: 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: 8,
transition: `box-shadow ${theme.durations.regular}`,
}}
onClick={() => setShowHamburgerMenu(false)}
>
<Text
style={{
color: theme.colors.textHighContrast,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}} }}
> >
{tool.title} {showHamburgerMenu ? <Cross1Icon /> : <HamburgerMenuIcon />}
</Text> </IconButton>
</Link>
))} <Link
color="gray"
highContrast
href="/"
underline="hover"
weight="bold"
onClick={() => setShowHamburgerMenu(false)}
>
BlitzKit
</Link>
</Flex>
<Box flexGrow="1" />
<Flex
align="center"
gap="2"
justify="center"
className={styles.tools}
>
{TOOLS.map((tool, index) => {
const selected = pathname.startsWith(`/tools/${tool.id}`);
return (
<Fragment key={tool.title}>
{index > 0 && <Separator orientation="vertical" size="1" />}
<Link
onClick={() => setShowHamburgerMenu(false)}
color="gray"
highContrast={selected}
size="2"
href={`/tools/${tool.id}`}
underline={selected ? 'always' : 'hover'}
>
{tool.title}
</Link>
</Fragment>
);
})}
</Flex>
<Box flexGrow="1" />
<Flex align="center" gap="3">
<Link
style={{ display: 'flex', alignContent: 'center' }}
color="gray"
href="https://discord.gg/nDt7AjGJQH"
underline="none"
target="_blank"
onClick={() => setShowHamburgerMenu(false)}
>
<DiscordLogoIcon />
</Link>
<Link
style={{ display: 'flex', alignContent: 'center' }}
color="gray"
href="https://www.patreon.com/tresabhi"
underline="none"
target="_blank"
onClick={() => setShowHamburgerMenu(false)}
>
<PatreonIcon width="0.75em" height="0.75em" />
</Link>
<Link
style={{ display: 'flex', alignContent: 'center' }}
color="gray"
href="/settings"
underline="none"
target="_blank"
onClick={() => setShowHamburgerMenu(false)}
>
<GearIcon />
</Link>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost" color="gray">
<PersonIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content
align="end"
width="320px"
onOpenAutoFocus={(event) => event.preventDefault()}
>
{(logins.patreon || logins.wargaming) && (
<Flex direction="column" gap="2">
<Text align="center" color="gray">
Logged in accounts
</Text>
{logins.patreon && (
<Flex align="center" gap="2" justify="center">
<PatreonIcon width={15} height={15} />
<Text>Patreon</Text>
<Button
color="red"
variant="ghost"
onClick={() =>
mutateApp((draft) => {
draft.logins.patreon = undefined;
})
}
>
Logout
</Button>
</Flex>
)}
{logins.wargaming && (
<Flex align="center" gap="2" justify="center">
<WargamingIcon width={15} height={15} />
<Text>Wargaming</Text>
<Button
color="red"
variant="ghost"
onClick={() =>
mutateApp((draft) => {
draft.logins.wargaming = undefined;
})
}
>
Logout
</Button>
</Flex>
)}
</Flex>
)}
{!logins.patreon !== !logins.wargaming && (
<Inset side="x">
<Separator my="4" size="4" />
</Inset>
)}
{(!logins.patreon || !logins.wargaming) && (
<Flex direction="column" gap="2">
<Text align="center" color="gray">
Log in with...
</Text>
{!logins.patreon && (
<Link
style={{ width: '100%' }}
href={patreonLoginUrl()}
>
<Button style={{ width: '100%' }} color="tomato">
<PatreonIcon width={15} height={15} /> Patreon
</Button>
</Link>
)}
{!logins.wargaming && (
<Dialog.Root>
<Dialog.Trigger>
<Button color="red">
<WargamingIcon width={15} height={15} /> Wargaming
</Button>
</Dialog.Trigger>
<Dialog.Content width="fit-content">
<Flex direction="column" gap="4" align="center">
<Text color="gray">Choose your region</Text>
<Flex gap="2" wrap="wrap">
{REGIONS.map((region) => (
<Dialog.Close key={region}>
<Link
href={
typeof window !== 'undefined'
? `https://api.worldoftanks.${region}/wot/auth/login/?application_id=${WARGAMING_APPLICATION_ID}&redirect_uri=${encodeURIComponent(
`${location.origin}/auth/wargaming?return=${location.origin}${location.pathname}`,
)}`
: undefined
}
>
<Button color="red">
{strings.common.regions.normal[region]}
</Button>
</Link>
</Dialog.Close>
))}
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
)}
</Flex>
)}
</Popover.Content>
</Popover.Root>
</Flex>
</Flex>
</Flex> </Flex>
<ScrollArea>
<Grid
columns="repeat(auto-fill, minmax(192px, 1fr))"
flow="dense"
gap="2"
width="100%"
p="3"
pt="0"
>
{tools.map((tool) => (
<Flex
key={tool.id}
onClick={() => setShowHamburgerMenu(false)}
style={{
position: 'relative',
borderRadius: 'var(--radius-2)',
overflow: 'hidden',
backgroundImage: `url(${imgur(tool.image, { format: 'jpeg', size: ImgurSize.MediumThumbnail })})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
aspectRatio: '2 / 1',
}}
>
<Link
href={tool.id.length === 0 ? '/' : `/tools/${tool.id}`}
style={{
display: 'flex',
width: '100%',
height: '100%',
flexDirection: 'column',
textDecoration: 'none',
color: 'inherit',
}}
>
<Box
flexGrow="1"
style={{
backgroundImage: `url(${imgur(tool.image, { format: 'jpeg', size: ImgurSize.MediumThumbnail })})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<Flex
p="2"
align="center"
width="100%"
style={{
backgroundColor: 'var(--color-panel-translucent)',
backdropFilter: 'blur(4rem)',
WebkitBackdropFilter: 'blur(4rem)',
}}
>
<Text weight="medium">{tool.title}</Text>
</Flex>
</Link>
</Flex>
))}
</Grid>
</ScrollArea>
</Flex> </Flex>
</Box> </Flex>
); );
} }
+1 -4
View File
@@ -15,16 +15,13 @@ export function ProvisionsManager({
disabled, disabled,
}: ProvisionsManagerProps) { }: ProvisionsManagerProps) {
return ( return (
<Flex wrap="wrap"> <Flex wrap="wrap" gap="2">
{provisions.map((provision, index) => { {provisions.map((provision, index) => {
const isSelected = selected.includes(provision); const isSelected = selected.includes(provision);
return ( return (
<ProvisionButton <ProvisionButton
key={provision} key={provision}
first={index === 0}
last={index === provisions.length - 1}
rowChild
disabled={disabled && !isSelected} disabled={disabled && !isSelected}
provision={provision} provision={provision}
selected={isSelected} selected={isSelected}
+2
View File
@@ -95,6 +95,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
type={spaced ? ArmorType.Spaced : ArmorType.Primary} type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness} thickness={thickness}
node={node} node={node}
onPointerDown={(event) => event.stopPropagation()}
/> />
); );
})} })}
@@ -116,6 +117,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
thickness={trackModelDefinition.thickness} thickness={trackModelDefinition.thickness}
variant="track" variant="track"
node={node} node={node}
onPointerDown={(event) => event.stopPropagation()}
/> />
); );
})} })}
+16
View File
@@ -0,0 +1,16 @@
import { ComponentProps } from 'react';
export function VerifiedIcon(props: ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
{...props}
>
<path d="m344-60-76-128-144-32 14-148-98-112 98-112-14-148 144-32 76-128 136 58 136-58 76 128 144 32-14 148 98 112-98 112 14 148-144 32-76 128-136-58-136 58Zm34-102 102-44 104 44 56-96 110-26-10-112 74-84-74-86 10-112-110-24-58-96-102 44-104-44-56 96-110 24 10 112-74 86 74 84-10 114 110 24 58 96Zm102-318Zm-42 142 226-226-56-58-170 170-86-84-56 56 142 142Z" />
</svg>
);
}
+7 -15
View File
@@ -90,7 +90,7 @@ export const moreTool: Tool = {
image: '1nPm6VI', image: '1nPm6VI',
button: { button: {
text: 'Join Discord', text: 'Join Discord',
color: 'indigo', color: 'plum',
}, },
}; };
@@ -119,27 +119,19 @@ export const tankPerformanceTool: Tool = {
image: 'vOKFB03', image: 'vOKFB03',
}; };
export const embedTool: Tool = { export const homeTool: Tool = {
id: 'embed', id: '',
title: 'Embed', title: 'Home',
description: 'Embeds for streaming', button: { color: 'blue', text: '' },
pageDescription: description: '',
'Create embeds for streaming World of Tanks Blitz with extreme customization', image: 'rUPie9G',
button: {
text: 'Create',
color: 'red',
},
image: 'Q0faRYg',
}; };
export const TOOLS: Tool[] = [ export const TOOLS: Tool[] = [
tankopediaTool, tankopediaTool,
compareTool, compareTool,
tankPerformanceTool, tankPerformanceTool,
playerStatsTool,
sessionTool, sessionTool,
embedTool, embedTool,
ratingTool, ratingTool,
discordTool,
moreTool,
]; ];
+5
View File
@@ -0,0 +1,5 @@
export const youtubers = [
{ id: 'UC9lE09jxmMgMoG13WrNHPmA', name: 'World of Tanks Blitz' },
{ id: 'UCrQ-Dy8lVsm11u8pCJ8W7Tw', name: 'HisRoyalFatness' },
{ id: 'UCxRpZqMElssTdPYDK3x7wyg', name: 'N90' },
];
+3 -2
View File
@@ -2,7 +2,8 @@ import { Locale } from 'discord.js';
import { go } from 'fuzzysort'; import { go } from 'fuzzysort';
import markdownEscape from 'markdown-escape'; import markdownEscape from 'markdown-escape';
import { UserError } from '../../hooks/userError'; import { UserError } from '../../hooks/userError';
import { tankDefinitions, tankNames } from '../blitzkit/tankDefinitions'; import { tankDefinitions } from '../blitzkit/tankDefinitions';
import { tankNames } from '../blitzkit/tankDefinitions/tankNames';
import { tankNamesTechTreeOnly } from '../discord/autocompleteTanks'; import { tankNamesTechTreeOnly } from '../discord/autocompleteTanks';
import { translator } from '../localization/translator'; import { translator } from '../localization/translator';
@@ -19,7 +20,7 @@ export default async function resolveTankId(
`${tank}`, `${tank}`,
await (techTreeOnly ? tankNamesTechTreeOnly : tankNames), await (techTreeOnly ? tankNamesTechTreeOnly : tankNames),
{ {
keys: ['combined'], keys: ['searchableName', 'searchableNameDeburr', 'camouflages'],
limit: 1, limit: 1,
}, },
); );
@@ -52,7 +52,7 @@ export const averageDefinitions = fetch(asset('averages/manifest.json'), {
.then((response) => response.arrayBuffer()) .then((response) => response.arrayBuffer())
.then((buffer) => .then((buffer) =>
decode<AverageDefinitions>( decode<AverageDefinitions>(
'blitzkit.AverageDefinitions', 'blitzkit/blitzkit.AverageDefinitions',
new Uint8Array(buffer), new Uint8Array(buffer),
), ),
); );
@@ -0,0 +1,16 @@
import { asset } from './asset';
import { fetchCdonLz4 } from './fetchCdonLz4';
export type CamouflageDefinitions = Record<
number,
{
id: number;
name: string;
tankName?: string;
tankNameFull?: string;
}
>;
export const camouflageDefinitions = fetchCdonLz4<CamouflageDefinitions>(
asset('definitions/camouflages.cdon.lz4'),
);
@@ -7,41 +7,50 @@ export function checkConsumableProvisionInclusivity(
tank: TankDefinition, tank: TankDefinition,
gun: GunDefinition, gun: GunDefinition,
) { ) {
const included = consumableProvision.include.every((rule) => { const included =
switch (rule.type) { !consumableProvision.gameMode &&
case 'tier': consumableProvision.include?.every((rule) => {
return rule.min <= tank.tier && tank.tier <= rule.max; switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
case 'ids': case 'ids':
return rule.ids.includes(tank.id); return rule.ids.includes(tank.id);
case 'nation': case 'nation':
return rule.nations.includes(tank.nation); return rule.nations.includes(tank.nation);
case 'category': case 'category':
throw new SyntaxError('Category filtering found in include rule'); throw new SyntaxError('Category filtering found in include rule');
} }
}); });
const excluded = consumableProvision.exclude?.some((rule) => { const excluded =
switch (rule.type) { consumableProvision.gameMode ||
case 'tier': consumableProvision.exclude?.some((rule) => {
return rule.min <= tank.tier && tank.tier <= rule.max; switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
case 'ids': case 'ids':
return rule.ids.includes(tank.id); return rule.ids.includes(tank.id);
case 'nation': case 'nation':
return rule.nations.includes(tank.nation); return rule.nations.includes(tank.nation);
case 'category': case 'category':
return rule.categories.some((category) => { return rule.categories.some((category) => {
switch (category) { switch (category) {
case 'clip': case 'clip':
return gun.type !== 'regular'; return gun.type !== 'regular';
} }
}); });
} }
}); });
return consumableProvision.include.length > 0 && included && !excluded; return (
!consumableProvision.gameMode &&
consumableProvision.include?.length > 0 &&
included &&
!excluded
);
} }
+11 -4
View File
@@ -22,14 +22,21 @@ export type TankFilterDefinition =
nations: string[]; nations: string[];
}; };
export interface ConsumableEntry { export type ConsumableEntry = {
id: number; id: number;
name: string; name: string;
cooldown: number; cooldown: number;
duration?: number; duration?: number;
include: TankFilterDefinition[]; } & (
exclude?: TankFilterDefinition[]; | {
} gameMode: false;
include: TankFilterDefinition[];
exclude?: TankFilterDefinition[];
}
| {
gameMode: true;
}
);
export interface ConsumableDefinitions { export interface ConsumableDefinitions {
[key: string]: ConsumableEntry; [key: string]: ConsumableEntry;
+12
View File
@@ -1,9 +1,21 @@
import { asset } from './asset'; import { asset } from './asset';
import { fetchCdonLz4 } from './fetchCdonLz4'; import { fetchCdonLz4 } from './fetchCdonLz4';
interface GameMode {
name: string;
}
export interface GameDefinitions { export interface GameDefinitions {
version: string; version: string;
nations: string[]; nations: string[];
gameModes: Record<number, GameMode>;
roles: Record<
number,
{
provisions: number[];
consumables: number[];
}
>;
} }
export const gameDefinitions = fetchCdonLz4<GameDefinitions>( export const gameDefinitions = fetchCdonLz4<GameDefinitions>(
+2
View File
@@ -1,3 +1,4 @@
import { invalidate } from '@react-three/fiber';
import { EventManager } from './eventManager'; import { EventManager } from './eventManager';
export interface ModelTransformEventData { export interface ModelTransformEventData {
@@ -6,3 +7,4 @@ export interface ModelTransformEventData {
} }
export const modelTransformEvent = new EventManager<ModelTransformEventData>(); export const modelTransformEvent = new EventManager<ModelTransformEventData>();
modelTransformEvent.on(() => invalidate());
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more