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:
build:
runs-on: windows-latest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -29,23 +29,12 @@ jobs:
run: bun scripts/setupDepotDownloader.ts
- 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
run: |
$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
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
- name: Build assets
run: |
$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
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
+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",
"donotremove",
"drei",
"Droodles",
"dvpd",
"dvpl",
"DVPLCRC",
@@ -68,7 +67,6 @@
"Fullscreen",
"fullstats",
"fuzzysort",
"gamemode",
"gameplay",
"glsl",
"GLTF",
@@ -190,6 +188,7 @@
"XMLDVPL",
"YAMLDVPL",
"yolo",
"youtuber",
"youtubers",
"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 |
| 3-caliber rule edge cases | Minitelrose, ChickenMan7777 |
| Embed UX | \_34, HisRoyalFatness, Synx |
| Tankopedia pre-release testing | [OPTML], Aeson, \_34, Droodles |
| Website advertisement platform | Aeson, Droodles |
| Compare pre-release testing | [OPTML], Droodles |
| Tankopedia pre-release testing | [OPTML], Aeson, \_34 |
| Website advertisement platform | Aeson |
| Compare pre-release testing | [OPTML] |
| 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",
"version": "1.22.0",
"version": "1.22.3",
"description": "🎉 Tools for everything World of Tanks Blitz",
"main": "src/server.ts",
"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 { 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 { readYAMLDVPL } from '../src/core/blitz/readYAMLDVPL';
import { writeDVPL } from '../src/core/blitz/writeDVPL';
import { secrets } from '../src/core/blitzkit/secrets';
import { dvp } from '../submodules/blitzkit-closed/src/dvp';
@@ -33,6 +35,10 @@ while (true) {
`${secrets.WOTB_DLC_CDN}/dlc/${data.dx11.replace('.dvpm', '.dvpd')}`,
).then((response) => response.arrayBuffer());
const files = await dvp(dvpm, dvpd);
const bar = new ProgressBar(
`Patching ${files.length} files :bar`,
files.length,
);
await Promise.all(
files.map(async ({ path, data }) => {
@@ -40,9 +46,30 @@ while (true) {
await mkdir(`${DATA}/${dir}`, { recursive: true });
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++;
} else break;
}
+1 -1
View File
@@ -229,7 +229,7 @@ async function postWork() {
{
path: `averages/${latest}.pb`,
content: await encodeToBase64(
'blitzkit.AverageDefinitions',
'blitzkit/blitzkit.AverageDefinitions',
averageDefinitions,
),
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 { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants';
import { DATA } from './constants';
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'),
);
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[] = [];
@@ -22,7 +22,7 @@ export async function boosterIcons(production: boolean) {
if (name === undefined) return;
const sizes = (
await readStringDVPL(`${DATA}/${POI.boosterIcons}/${file}`)
await readStringDVPL(`${DATA}/Gfx/Shared/boosters/${file}`)
)
.split('\n')[4]
.split(' ')
+2 -2
View File
@@ -1,12 +1,12 @@
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';
import { DATA } from './constants';
export async function camouflageIcons(production: boolean) {
console.log('Building camouflage icons...');
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'));
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';
const isDepot = argv.includes('--depot');
export const WOTB_WIN32_DEPOT = 444202;
export const DATA = isDepot
? await (async () => {
const [installationVersion] = await readdir(`depots/${WOTB_WIN32_DEPOT}`);
return `depots/${WOTB_WIN32_DEPOT}/${installationVersion}/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 { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
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 { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants';
import { DATA } from './constants';
import { ConsumablesCommon, ProvisionsCommon } from './definitions';
interface Mappings {
@@ -16,149 +17,176 @@ interface Mappings {
}[];
}
const listItemsPattern = /<items path="(.+)\.xml"\/>/g;
export async function consumableProvisionIcons(production: boolean) {
console.log('Building consumable and provision icons...');
const changes: FileChange[] = [];
const consumablesTexture = sharp(
await readDVPLFile(
`${DATA}/${POI.consumableIcons}/texture0.packed.webp.dvpl`,
const styles = [
'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,
),
),
);
const provisionsTexture = sharp(
await readDVPLFile(
`${DATA}/${POI.provisionIcons}/texture0.packed.webp.dvpl`,
),
);
).then((array) => array.flat());
const equipmentItemImageMappings = await readYAMLDVPL<Mappings>(
`${DATA}/${POI.equipmentItemImageMappings}.dvpl`,
);
const consumablesCommon = await readXMLDVPL<{
root: ConsumablesCommon;
}>(`${DATA}/${POI.consumablesCommon}.dvpl`);
const provisionsCommon = await readXMLDVPL<{
root: ProvisionsCommon;
}>(`${DATA}/${POI.provisionsCommon}.dvpl`);
const styleSheets = Object.values(equipmentItemImageMappings.StyleSheets);
for (const match of (
await readStringDVPL(
`${DATA}/XML/item_defs/vehicles/common/consumables/list.xml.dvpl`,
)
).matchAll(listItemsPattern)) {
const consumablesCommon = await readXMLDVPL<{
root: ConsumablesCommon;
}>(
`${DATA}/XML/item_defs/vehicles/common/consumables/${match[1]}.xml.dvpl`,
);
await Promise.all(
Object.values(consumablesCommon.root).map(async (consumable) => {
const styleSheet = styleSheets.find((styleSheet) =>
styleSheet.selectors.includes(
`.equipment_item.${consumable.icon} * .equipment-img`,
),
);
if (!styleSheet) {
console.warn(
`No style sheet found for consumable ${consumable.icon}; skipping...`,
await Promise.all(
Object.values(consumablesCommon.root).map(async (consumable) => {
const styleSheet = styleSheets.find((styleSheet) =>
styleSheet.selectors.some((selector) =>
selector.includes(`${consumable.icon} `),
),
);
return;
}
const configPath = styleSheet.properties['bg-sprite']
.replace('~res:/', '')
.replace('.psd', '')
.replace('.txt', '');
if (!styleSheet) {
console.warn(
`No style sheet found for consumable ${consumable.icon}; skipping...`,
);
return;
}
if (configPath.startsWith('Gfx/Shared')) {
const image = sharp(
await readDVPLFile(`${DATA}/${configPath}.packed.webp.dvpl`),
const configPath = styleSheet.properties['bg-sprite']
.replace('~res:/', '')
.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({
path: `icons/consumables/${consumable.id}.webp`,
encoding: 'base64',
content,
});
} 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');
if (!styleSheet) {
console.warn(
`No style sheet found for provision ${provision.icon}; skipping...`,
);
return;
}
changes.push({
path: `icons/consumables/${consumable.id}.webp`,
encoding: 'base64',
content,
});
}
}),
);
const configPath = styleSheet.properties['bg-sprite']
.replace('~res:/', '')
.replace('.psd', '')
.replace('.txt', '');
await Promise.all(
Object.values(provisionsCommon.root).map(async (provision) => {
const styleSheet = styleSheets.find((styleSheet) =>
styleSheet.selectors.includes(
`.equipment_item.${provision.icon} * .equipment-img`,
),
);
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');
if (!styleSheet) {
console.warn(
`No style sheet found for provision ${provision.icon}; skipping...`,
);
return;
}
changes.push({
path: `icons/provisions/${provision.id}.webp`,
encoding: 'base64',
content,
});
} 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']
.replace('~res:/', '')
.replace('.psd', '')
.replace('.txt', '');
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,
});
}
}),
);
changes.push({
path: `icons/provisions/${provision.id}.webp`,
encoding: 'base64',
content,
});
}
}),
);
}
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'],
];
type BlitzGlossary = Record<
string,
{
image_url: null | string;
}
>;
export async function currencies(production: boolean) {
console.log('Building currency icons...');
const changes = await Promise.all(
const changes: FileChange[] = [];
await Promise.all(
ICONS.map(async ([file, name]) => {
const content = (
await sharp(
@@ -27,13 +36,43 @@ export async function currencies(production: boolean) {
.toBuffer()
).toString('base64');
return {
changes.push({
content,
encoding: 'base64',
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);
}
+365 -119
View File
@@ -8,9 +8,11 @@ import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { toUniqueId } from '../../src/core/blitz/toUniqueId';
import { CamouflageDefinitions } from '../../src/core/blitzkit/camouflageDefinitions';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import {
ConsumableDefinitions,
ConsumableEntry,
TankFilterDefinitionCategory,
} from '../../src/core/blitzkit/consumableDefinitions';
import {
@@ -24,7 +26,10 @@ import {
ModelArmor,
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 { superCompress } from '../../src/core/blitzkit/superCompress';
import {
@@ -32,12 +37,12 @@ import {
CrewMember,
GunDefinition,
ShellType,
TankDefinitionPrice,
TankDefinitions,
TankPrice,
Tier,
Unlock,
} from '../../src/core/blitzkit/tankDefinitions';
import { DATA, POI } from './constants';
import { DATA } from './constants';
import { Avatar } from './skillIcons';
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 =
| { minLevel: number; maxLevel: number }
| { name: string }
@@ -357,6 +404,24 @@ interface AvailableNationsYaml {
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> = {
ARMOR_PIERCING: ShellType.AP,
ARMOR_PIERCING_CR: ShellType.APCR,
@@ -376,6 +441,7 @@ export async function definitions(production: boolean) {
).then((response) => response.json())) as {
data: { [key: number]: { description: null | string } };
};
const gameDefinitions: GameDefinitions = {
version: (await readStringDVPL(`${DATA}/version.txt.dvpl`))
.split(' ')[0]
@@ -387,8 +453,11 @@ export async function definitions(production: boolean) {
`${DATA}/available_nations.yaml.dvpl`,
)
).available_nations,
gameModes: {},
roles: {},
};
const tankDefinitions: TankDefinitions = {};
const camouflageDefinitions: CamouflageDefinitions = {};
const modelDefinitions: ModelDefinitions = {};
const mapDefinitions: MapDefinitions = {};
const equipmentDefinitions: EquipmentDefinitions = {
@@ -400,18 +469,20 @@ export async function definitions(production: boolean) {
const skillDefinitions: SkillDefinitions = {
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'),
);
const tankStringIdMap: Record<string, number> = {};
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((string) => parseYaml(string) as BlitzStrings);
const optionalDevices = await readXMLDVPL<{ root: OptionalDevices }>(
`${DATA}/${POI.optionalDevices}.dvpl`,
`${DATA}/XML/item_defs/vehicles/common/optional_devices.xml.dvpl`,
);
const strings = {
...stringsPreInstalled,
@@ -419,48 +490,136 @@ export async function definitions(production: boolean) {
};
const optionalDeviceSlots = await readXMLDVPL<{
root: OptionalDeviceSlots;
}>(`${DATA}/${POI.optionalDeviceSlots}.dvpl`);
const consumables = await readXMLDVPL<{ root: ConsumablesCommon }>(
`${DATA}/${POI.consumablesCommon}.dvpl`,
);
const provisions = await readXMLDVPL<{ root: ProvisionsCommon }>(
`${DATA}/${POI.provisionsCommon}.dvpl`,
);
}>(`${DATA}/XML/item_defs/vehicles/common/optional_device_slots.xml.dvpl`);
const consumables: ConsumablesCommon = {};
const provisions: ProvisionsCommon = {};
for (const match of (
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 }>(
`${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`,
);
const maps = await readYAMLDVPL<Maps>(`${DATA}/maps.yaml.dvpl`);
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(
nations.map(async (nation) => {
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<{
root: TurretDefinitionsList;
}>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/components/turrets.xml.dvpl`,
`${DATA}/XML/item_defs/vehicles/${nation}/components/turrets.xml.dvpl`,
);
const gunList = await readXMLDVPL<{
root: GunDefinitionsList;
}>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/components/guns.xml.dvpl`,
);
}>(`${DATA}/XML/item_defs/vehicles/${nation}/components/guns.xml.dvpl`);
const shellList = await readXMLDVPL<{
root: ShellDefinitionsList;
}>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/components/shells.xml.dvpl`,
);
}>(`${DATA}/XML/item_defs/vehicles/${nation}/components/shells.xml.dvpl`);
const enginesList = await readXMLDVPL<{
root: EngineDefinitionsList;
}>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/components/engines.xml.dvpl`,
`${DATA}/XML/item_defs/vehicles/${nation}/components/engines.xml.dvpl`,
);
const chassisList = await readXMLDVPL<{
root: ChassisDefinitionsList;
}>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/components/chassis.xml.dvpl`,
`${DATA}/XML/item_defs/vehicles/${nation}/components/chassis.xml.dvpl`,
);
function resolveUnlocks(unlocks?: Unlocks) {
@@ -511,15 +670,12 @@ export async function definitions(production: boolean) {
const engineXps = new Map<number, number>();
const trackXps = new Map<number, number>();
const tank = tankList.root[tankKey];
const tankPrice: TankDefinitionPrice =
typeof tank.price === 'number'
? { type: 'credits', value: tank.price }
: { type: 'gold', value: tank.price['#text'] };
let tankPrice: TankPrice;
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>(
`${DATA}/${POI.tankParameters}/${nation}/${tankKey}.yaml.dvpl`,
`${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml.dvpl`,
);
const turretOrigin = tankDefinition.root.hull.turretPositions.turret
.split(' ')
@@ -530,6 +686,23 @@ export async function definitions(production: boolean) {
const equipment = tankDefinition.root.optDevicePreset;
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)
.filter((name) => name.startsWith('armor_'))
.forEach((name) => {
@@ -554,11 +727,11 @@ export async function definitions(production: boolean) {
const fixedCamouflage = tankTags.includes('eventCamouflage_user');
const totalUnlocks: UnlocksListing[] = [];
Object.entries(tankDefinition.root.crew).forEach(([key, value]) => {
Object.entries(tankDefinition.root.crew).forEach(([crewKey, value]) => {
let entry: Crew;
const index = crew.findIndex(({ type }) => type === key);
const index = crew.findIndex(({ type }) => type === crewKey);
if (index === -1) {
entry = { type: key as CrewMember };
entry = { type: crewKey as CrewMember };
crew.push(entry);
} else {
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] = {
id: tankId,
roles: {},
camouflages: camouflages.length === 0 ? undefined : camouflages,
description:
wargamingTankopedia.data[tankId]?.description ?? undefined,
fixedCamouflage: fixedCamouflage ? true : undefined,
@@ -630,6 +825,22 @@ export async function definitions(production: boolean) {
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) {
delete tankDefinitions[tankId].nameFull;
}
@@ -1079,126 +1290,140 @@ export async function definitions(production: boolean) {
},
);
Object.values(consumables.root).forEach((consumable) => {
consumableDefinitions[consumable.id] = {
Object.entries(consumables).forEach(([key, consumable]) => {
consumableNativeNames[key] = consumable.id;
const entry = {
id: consumable.id,
gameMode: 'gameModeFilter' in consumable,
cooldown: consumable.script.cooldown,
duration: consumable.script.duration,
name:
strings[consumable.userString] ??
missingStrings[consumable.userString] ??
`Unknown ${consumable.id}`,
include: [],
};
} as ConsumableEntry;
consumableDefinitions[consumable.id] = entry;
const includeRaw = consumable.vehicleFilter?.include.vehicle;
const excludeRaw = consumable.vehicleFilter?.exclude?.vehicle;
if (includeRaw) {
if ('minLevel' in includeRaw) {
consumableDefinitions[consumable.id].include.push({
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 (!entry.gameMode) {
if (includeRaw) {
entry.include = [];
if (consumable.vehicleFilter?.include.nations) {
consumableDefinitions[consumable.id].include.push({
type: 'nation',
nations: consumable.vehicleFilter.include.nations.split(' '),
});
if ('minLevel' in includeRaw) {
entry.include.push({
type: 'tier',
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) {
consumableDefinitions[consumable.id].exclude = [];
if (excludeRaw) {
entry.exclude = [];
if ('name' in excludeRaw) {
consumableDefinitions[consumable.id].exclude!.push({
type: 'ids',
ids: excludeRaw.name.split(' ').map((key) => tankStringIdMap[key]),
});
} else if ('extendedTags' in excludeRaw) {
consumableDefinitions[consumable.id].exclude!.push({
type: 'category',
categories: excludeRaw.extendedTags.split(
' ',
) as TankFilterDefinitionCategory[],
});
} else throw new SyntaxError('Unhandled exclude type');
if ('name' in excludeRaw) {
entry.exclude!.push({
type: 'ids',
ids: excludeRaw.name.split(' ').map((key) => tankStringIdMap[key]),
});
} else if ('extendedTags' in excludeRaw) {
entry.exclude!.push({
type: 'category',
categories: excludeRaw.extendedTags.split(
' ',
) as TankFilterDefinitionCategory[],
});
} else throw new SyntaxError('Unhandled exclude type');
if (consumable.vehicleFilter?.exclude?.nations) {
consumableDefinitions[consumable.id].exclude!.push({
type: 'nation',
nations: consumable.vehicleFilter.exclude.nations.split(' '),
});
if (consumable.vehicleFilter?.exclude?.nations) {
entry.exclude!.push({
type: 'nation',
nations: consumable.vehicleFilter.exclude.nations.split(' '),
});
}
}
}
});
Object.values(provisions.root).forEach((provision) => {
provisionDefinitions[provision.id] = {
Object.entries(provisions).forEach(([key, provision]) => {
provisionNativeNames[key] = provision.id;
const entry = {
id: provision.id,
gameMode: 'gameModeFilter' in provision,
name:
strings[provision.userString] ??
missingStrings[provision.userString] ??
`Unknown ${provision.id}`,
include: [],
};
} as ProvisionEntry;
provisionDefinitions[provision.id] = entry;
const includeRaw = provision.vehicleFilter?.include.vehicle;
const excludeRaw = provision.vehicleFilter?.exclude?.vehicle;
if (includeRaw) {
if ('minLevel' in includeRaw) {
provisionDefinitions[provision.id].include.push({
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 (!entry.gameMode) {
if (includeRaw) {
entry.include = [];
if (provision.vehicleFilter?.include.nations) {
provisionDefinitions[provision.id].include.push({
type: 'nation',
nations: provision.vehicleFilter.include.nations.split(' '),
});
if ('minLevel' in includeRaw) {
entry.include.push({
type: 'tier',
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) {
provisionDefinitions[provision.id].exclude = [];
if (excludeRaw) {
entry.exclude = [];
if ('name' in excludeRaw) {
provisionDefinitions[provision.id].exclude!.push({
type: 'ids',
ids: excludeRaw.name.split(' ').map((key) => tankStringIdMap[key]),
});
} else if ('extendedTags' in excludeRaw) {
provisionDefinitions[provision.id].exclude!.push({
type: 'category',
categories: excludeRaw.extendedTags.split(
' ',
) as TankFilterDefinitionCategory[],
});
} else throw new SyntaxError('Unhandled exclude type');
if ('name' in excludeRaw) {
entry.exclude!.push({
type: 'ids',
ids: excludeRaw.name.split(' ').map((key) => tankStringIdMap[key]),
});
} else if ('extendedTags' in excludeRaw) {
entry.exclude!.push({
type: 'category',
categories: excludeRaw.extendedTags.split(
' ',
) as TankFilterDefinitionCategory[],
});
} else throw new SyntaxError('Unhandled exclude type');
if (provision.vehicleFilter?.exclude?.nations) {
provisionDefinitions[provision.id].exclude!.push({
type: 'nation',
nations: provision.vehicleFilter.exclude.nations.split(' '),
});
if (provision.vehicleFilter?.exclude?.nations) {
entry.exclude!.push({
type: 'nation',
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]) => {
skillDefinitions.classes[tankClass as TankClass] = skills.split(' ');
});
@@ -1262,6 +1503,11 @@ export async function definitions(production: boolean) {
encoding: 'base64',
path: 'definitions/maps.cdon.lz4',
},
{
content: superCompress(camouflageDefinitions),
encoding: 'base64',
path: 'definitions/camouflages.cdon.lz4',
},
],
production,
);
+6 -4
View File
@@ -5,7 +5,7 @@ import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants';
import { DATA } from './constants';
import { OptionalDevices } from './definitions';
interface Mappings {
@@ -22,13 +22,15 @@ export async function equipmentIcons(production: boolean) {
const changes: FileChange[] = [];
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>(
`${DATA}/${POI.optionalDeviceImageMappings}.dvpl`,
`${DATA}/UI/Screens3/Lobby/Inventory/OptionalDevices/OptionalDevicesItemImage.style.yaml.dvpl`,
);
const image = sharp(
await readDVPLFile(`${DATA}/${POI.optionalDevicesImage}.dvpl`),
await readDVPLFile(
`${DATA}/Gfx/UI/InventoryIcons/Big/OptionalDevices/texture0.packed.webp.dvpl`,
),
);
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 { camouflageIcons } from './camouflageIcons';
import { circleFlags } from './circleFlags';
import { consumableProvisionIcons } from './consumableProvisionIcons';
import { currencies } from './currencies';
import { definitions } from './definitions';
import { equipmentIcons } from './equipmentIcons';
import { flags } from './flags';
import { gameModeBanners } from './gameModeBanners';
import { moduleIcons } from './moduleIcons';
import { scratchedFlags } from './scratchedFlags';
import { shellIcons } from './shellIcons';
import { skillIcons } from './skillIcons';
import { tankArmors } from './tankArmors';
import { tankIcons } from './tankIcons';
import { tankModels } from './tankModels';
import { videos } from './videos';
if (!targets && !allTargets) throw new Error('No target(s) specified');
const methods = [
gameModeBanners,
definitions,
videos,
scratchedFlags,
tankIcons,
currencies,
skillIcons,
circleFlags,
flags,
shellIcons,
moduleIcons,
equipmentIcons,
consumableProvisionIcons,
camouflageIcons,
boosterIcons,
tankArmors,
tankModels,
tankIcons,
tankArmors,
];
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 { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants';
import { DATA } from './constants';
export async function moduleIcons(production: boolean) {
console.log('Building module icons...');
const changes = await Promise.all(
(await readdir(`${DATA}/${POI.moduleIcons}`))
(await readdir(`${DATA}/Gfx/UI/ModulesTechTree`))
.filter(
(file) =>
!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 name = nameRaw[0].toLowerCase() + nameRaw.slice(1);
const content = (
await sharp(await readDVPLFile(`${DATA}/${POI.moduleIcons}/${file}`))
await sharp(
await readDVPLFile(`${DATA}/Gfx/UI/ModulesTechTree/${file}`),
)
.trim()
.toBuffer()
).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 { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants';
import { DATA } from './constants';
export async function shellIcons(production: boolean) {
console.log('Building shell icons...');
const image = sharp(
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(
(await readdir(`${DATA}/${POI.bigShellIcons}`))
(await readdir(`${DATA}/Gfx/Shared/tank-supply/ammunition/big`))
.filter((file) => file.endsWith('_l.txt.dvpl'))
.map(async (file) => {
const name = file.match(/(.+)_l\.txt\.dvpl/)![1];
const sizes = (
await readStringDVPL(`${DATA}/${POI.bigShellIcons}/${file}`)
await readStringDVPL(
`${DATA}/Gfx/Shared/tank-supply/ammunition/big/${file}`,
)
)
.split('\n')[4]
.split(' ')
+3 -3
View File
@@ -5,7 +5,7 @@ import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { toUniqueId } from '../../src/core/blitz/toUniqueId';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants';
import { DATA } from './constants';
import { VehicleDefinitionList } from './definitions';
export async function tankArmors(production: boolean) {
@@ -13,14 +13,14 @@ export async function tankArmors(production: boolean) {
const changes: FileChange[] = [];
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'),
);
await Promise.all(
nations.map(async (nation) => {
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(
+4 -4
View File
@@ -7,7 +7,7 @@ import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants';
import { DATA } from './constants';
import { VehicleDefinitionList } from './definitions';
export interface TankParameters {
@@ -46,14 +46,14 @@ export async function tankIcons(production: boolean) {
console.log('Building tank icons...');
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'),
);
await Promise.all(
nations.map(async (nation) => {
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(
@@ -63,7 +63,7 @@ export async function tankIcons(production: boolean) {
const nationVehicleId = tank.id;
const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1;
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
.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 { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants';
import { DATA } from './constants';
import { VehicleDefinitionList } from './definitions';
import { TankParameters } from './tankIcons';
@@ -14,7 +14,7 @@ export async function tankModels(production: boolean) {
console.log('Building tank models...');
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'),
);
@@ -22,7 +22,7 @@ export async function tankModels(production: boolean) {
const nation = nations[nationIndex];
const changes: FileChange[] = [];
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}`);
@@ -33,7 +33,7 @@ export async function tankModels(production: boolean) {
const id = toUniqueId(nation, tank.id);
const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/${POI.tankParameters}/${nation}/${tankKey}.yaml.dvpl`,
`${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml.dvpl`,
);
const model = await extractModel(
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 }) => {
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');
return fetch(asset.browser_download_url);
})
.then((response) => response.arrayBuffer())
.then((arrayBuffer) =>
writeFile('temp/depotDownloader.zip', Buffer.from(arrayBuffer)),
writeFile('temp/depotDownloader.zip', new Uint8Array(arrayBuffer)),
);
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 { PatreonAuthResponse } from '../auth/[provider]/page';
interface Extension {
data: {
access_token: string;
account_id: number;
expires_at: number;
};
}
type Extension =
| {
status: 'ok';
data: {
access_token: string;
account_id: number;
expires_at: number;
};
}
| {
status: 'error';
};
/**
* Wargaming: 14 (refresh: 7)
@@ -80,11 +85,15 @@ export function Checks() {
.then((response) => response.json() as Promise<Extension>)
.then((json) => {
mutateApp((draft) => {
draft.logins.wargaming = {
id: wargaming.id,
token: json.data.access_token,
expires: json.data.expires_at * 1000,
};
if (json.status === 'error') {
draft.logins.wargaming = undefined;
} else {
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}
>
<Link
href={`/tools/${tool.id}`}
href={tool.href ?? `/tools/${tool.id}`}
target={tool.href ? '_blank' : undefined}
style={{
display: 'flex',
width: '100%',
@@ -78,7 +79,7 @@ export function ToolCard({ tool, style, ...props }: ToolCardProps) {
width="100%"
direction={{
initial: 'column',
sm: 'row',
xs: 'row',
}}
style={{
backgroundColor: 'var(--color-panel-translucent)',
@@ -91,7 +92,7 @@ export function ToolCard({ tool, style, ...props }: ToolCardProps) {
justify="center"
align={{
initial: 'center',
sm: 'start',
xs: 'start',
}}
>
<Heading
+3 -3
View File
@@ -7,7 +7,7 @@ import { Roboto_Flex } from 'next/font/google';
import { usePathname } from 'next/navigation';
import { ReactNode, Suspense } from 'react';
import { Footer } from '../components/Footer';
import Navbar, { NAVBAR_HEIGHT } from '../components/Navbar';
import Navbar from '../components/Navbar';
import { Party3 } from '../components/Party3';
import * as App from '../stores/app';
import { Checks } from './components/Checks';
@@ -48,7 +48,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
</>
)}
<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 />
</head>
@@ -71,7 +71,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
direction="column"
style={{
minHeight: '100vh',
paddingTop: hideNav ? undefined : NAVBAR_HEIGHT,
// paddingTop: isEmbed ? undefined : NAVBAR_HEIGHT,
}}
>
{!hideNav && <Navbar />}
+1 -1
View File
@@ -5,7 +5,7 @@ import PageWrapper from '../../components/PageWrapper';
import * as App from '../../stores/app';
export default function Page() {
const developerMode = App.use((state) => state.developerMode);
const developerMode = App.useDeferred(false, (state) => state.developerMode);
const appStore = App.useStore();
return (
@@ -1,6 +1,6 @@
import { ComponentPlaceholderIcon } from '@radix-ui/react-icons';
import { Button, Flex, Popover, Table } from '@radix-ui/themes';
import { times } from 'lodash';
import { Button, Flex, Popover, Slider, Table, Text } from '@radix-ui/themes';
import { debounce, times } from 'lodash';
import { use } from 'react';
import { ConsumablesManager } from '../../../../components/ConsumablesManager';
import { CrewSkillManager } from '../../../../components/CrewSkillManager';
@@ -17,7 +17,7 @@ import { equipmentDefinitions } from '../../../../core/blitzkit/equipmentDefinit
import { provisionDefinitions } from '../../../../core/blitzkit/provisionDefinitions';
import { skillDefinitions } from '../../../../core/blitzkit/skillDefinitions';
import { TankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
import { BlitzkitButtonWatermark } from '../../../../icons/BlitzkitButtonWatermark';
import { BlitzkitButtonGray } from '../../../../icons/BlitzkitButtonGray';
import { theme } from '../../../../stitches.config';
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
import { EquipmentMatrix } from '../../../../stores/duel';
@@ -56,7 +56,7 @@ export function CompareTable({ stats }: CompareTableProps) {
align="center"
justify="center"
>
<BlitzkitButtonWatermark width={64} height={64} />
<BlitzkitButtonGray opacity="0.25" width={64} height={64} />
<InsertionMarker index={0} />
</Flex>
</StickyColumnHeaderCell>
@@ -167,6 +167,7 @@ export function CompareTable({ stats }: CompareTableProps) {
engine,
track,
consumables,
crewMastery,
},
index,
) => {
@@ -509,7 +510,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<ConsumablesManager
consumables={consumablesList}
selected={consumables}
onChange={(consumables) => {
onConsumablesChange={(consumables) => {
mutateCompareEphemeral((draft) => {
draft.members[index].consumables = consumables;
});
@@ -532,6 +533,23 @@ export function CompareTable({ stats }: CompareTableProps) {
</Popover.Content>
</Popover.Root>
</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>
</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';
interface ConfigurationChildWrapperProps {
type ConfigurationChildWrapperProps = FlexProps & {
children: ReactNode;
}
};
export function ConfigurationChildWrapper({
children,
...props
}: ConfigurationChildWrapperProps) {
return (
<Flex gap="2" direction="column">
<Flex
gap="2"
direction="column"
align={{ initial: 'center', sm: 'start' }}
{...props}
>
{children}
</Flex>
);
@@ -77,11 +77,12 @@ export function Consumables() {
hasConsumableDeliverySystem={hasConsumableDeliverySystem}
hasHighEndConsumables={hasHighEndConsumables}
timers
onChange={(consumables) => {
onConsumablesChange={(consumables) => {
mutateDuel((draft) => {
draft.protagonist.consumables = consumables;
});
}}
justify={{ initial: 'center', sm: 'start' }}
/>
</ConfigurationChildWrapper>
);
@@ -47,8 +47,6 @@ export function Miscellaneous() {
left: '50%',
transform: 'translate(calc(-50% + 4px), calc(-50% + 4px))',
}}
first
last
onClick={() => {
mutateDuel((draft) => {
draft.protagonist.camouflage = !camouflage;
@@ -59,8 +57,6 @@ export function Miscellaneous() {
<GenericTankComponentButton
icon={asset('icons/boosters/equipment.webp')}
selected={cooldownBooster > 0}
first
last
banner={
cooldownBooster === 0
? undefined
@@ -214,9 +214,7 @@ export function Modules() {
align="center"
direction="column"
gap="2"
style={{
position: 'relative',
}}
position="relative"
>
{unlock.cost.value > 0 && (
<>
@@ -310,6 +308,7 @@ export function Modules() {
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Heading size="4">Modules</Heading>
{hasUpgrades && (
<>
<Button
@@ -348,7 +347,7 @@ export function Modules() {
)}
</Flex>
<Flex gap="2">
<Flex gap="2" wrap="wrap" gapY="6">
{tree('engine', [
{
cost: { type: 'xp', value: 0 },
@@ -7,12 +7,12 @@ import {
Slider,
Text,
TextField,
Tooltip,
} from '@radix-ui/themes';
import { debounce } from 'lodash';
import { Fragment, use, useEffect, useRef, useState } from 'react';
import { lerp } from 'three/src/math/MathUtils';
import { Ad, AdType } from '../../../../../../components/Ad';
import { Link } from '../../../../../../components/Link';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
@@ -44,6 +44,9 @@ export function Characteristics() {
const hasImprovedVentilation = useEquipment(102);
const crewMastery = Duel.use((state) => state.protagonist.crewMastery);
const [penetrationDistance, setPenetrationDistance] = useState(250);
const setPenetrationDistanceDebounced = debounce((value: number) => {
setPenetrationDistance(value);
}, 500);
const provisions = Duel.use((state) => state.protagonist.provisions);
const provisionCrewBonus =
@@ -145,32 +148,33 @@ export function Characteristics() {
<Flex>
{gun.shells.map((thisShell, shellIndex) => (
<IconButton
color={thisShell.id === shell.id ? undefined : 'gray'}
variant="soft"
key={thisShell.id}
style={{
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
borderBottomLeftRadius: shellIndex === 0 ? undefined : 0,
borderTopRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0,
borderBottomRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0,
marginLeft: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist.shell = thisShell;
});
}}
>
<img
alt={thisShell.name}
width={16}
height={16}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
/>
</IconButton>
<Tooltip content={thisShell.name} key={thisShell.id}>
<IconButton
color={thisShell.id === shell.id ? undefined : 'gray'}
variant="soft"
style={{
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
borderBottomLeftRadius: shellIndex === 0 ? undefined : 0,
borderTopRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0,
borderBottomRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0,
marginLeft: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist.shell = thisShell;
});
}}
>
<img
alt={thisShell.name}
width={16}
height={16}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
/>
</IconButton>
</Tooltip>
))}
</Flex>
</Flex>
@@ -179,35 +183,22 @@ export function Characteristics() {
{stats.dpm}
</InfoWithDelta>
{gun.type === 'autoReloader' && (
<>
<InfoWithDelta decimals={0} indent name="Maximum" unit="hp / min">
{stats.dpmMaximum!}
</InfoWithDelta>
<InfoWithDelta
decimals={0}
indent
name="Effective at 60s"
unit="hp / min"
>
{stats.dpmEffective!}
</InfoWithDelta>
<Info
indent
name={
<Link target="_blank" href="/docs/guide/dpm">
What's the difference?
</Link>
}
/>
</>
<InfoWithDelta
decimals={0}
indent
name="Effective at 60s"
unit="hp / min"
>
{stats.dpmEffective!}
</InfoWithDelta>
)}
{gun.type !== 'regular' && (
<InfoWithDelta name="Shells">{stats.shells}</InfoWithDelta>
)}
{gun.type === 'autoReloader' ? (
<>
<Info indent name="Most optimal">
{stats.mostOptimalShellIndex}
<Info indent name="Most optimal shell index">
{stats.mostOptimalShellIndex! + 1}
</Info>
<Info name="Shell reloads" unit="s" />
{stats.shellReloads!.map((reload, index) => (
@@ -269,9 +260,13 @@ export function Characteristics() {
max={500}
style={{ flex: 1 }}
defaultValue={[penetrationDistance]}
onValueChange={debounce(([value]) => {
setPenetrationDistance(value);
}, 500)}
onValueChange={([value]) => {
setPenetrationDistanceDebounced(value);
if (!penetrationDistanceInput.current) return;
penetrationDistanceInput.current.value = value.toString();
}}
/>
<TextField.Root
style={{ width: 64 }}
@@ -477,15 +472,18 @@ export function Characteristics() {
>
{(member.type === 'commander'
? commanderMastery
: crewMemberMastery) * 100}
: commanderMastery * 1.1) * 100}
</InfoWithDelta>
{member.substitute && (
<Info
<InfoWithDelta
key={`${member.type}-substitute`}
decimals={0}
unit="%"
indent
name={
<>
<Flex align="center" gap="1">
<Flex align="center" gap="1" display="inline-flex">
<AccessibilityIcon />
{member.substitute
.map((sub, index) =>
@@ -495,7 +493,9 @@ export function Characteristics() {
</Flex>
</>
}
/>
>
{commanderMastery * 1.05 * 100}
</InfoWithDelta>
)}
</Fragment>
);
@@ -8,7 +8,7 @@ export function HistorySection() {
if (!tank.description) return null;
return (
<PageWrapper noFlex1>
<PageWrapper noFlex1 align="center">
<Heading>History of the {tank.name}</Heading>
<Text>{tank.description}</Text>
</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 { Ad, AdType } from '../../../../../../components/Ad';
import { useAdExempt } from '../../../../../../hooks/useAdExempt';
import { Characteristics } from '../Characteristics';
import { Consumables } from '../Characteristics/components/Consumables';
import { Equipment } from '../Characteristics/components/Equipment';
@@ -10,33 +8,21 @@ import { Provisions } from '../Characteristics/components/Provisions';
import { Skills } from '../Characteristics/components/Skills';
export function CharacteristicsSection() {
const exempt = useAdExempt();
return (
<Flex
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 mt="4" px="4" style={{ boxSizing: 'border-box' }} gap="6">
<Flex
flexGrow="1"
gap="6"
gap="9"
justify="center"
align={{ initial: 'center', sm: 'start' }}
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>
<Modules />
<Equipment />
@@ -45,33 +31,19 @@ export function CharacteristicsSection() {
<Skills />
<Miscellaneous />
</Flex>
<Flex
flexGrow="1"
maxWidth={{
initial: 'unset',
xs: exempt ? undefined : '320px',
md: '640px',
}}
maxWidth={{ initial: '20rem', md: '40rem' }}
direction="column"
gap="4"
gap="6"
width="100%"
>
<Heading>Characteristics</Heading>
<Heading align={{ initial: 'center', sm: 'left' }}>
Characteristics
</Heading>
<Characteristics />
</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>
);
}
@@ -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 { Box, Button, Flex, Heading, Text } from '@radix-ui/themes';
import { Box, Button, Flex, Heading } from '@radix-ui/themes';
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 { ThicknessRange } from '../../../../../../components/StaticArmor';
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
import { tankDefinitions } from '../../../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../core/blitzkit/tankDefinitions/constants';
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 { Options } from './components/Options';
import { MetaSection } from './MetaSection';
import { TankSandbox } from './TankSandbox';
import { TankSandboxLoader } from './TankSandboxLoader';
@@ -19,6 +19,8 @@ interface 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 antagonist = Duel.use((state) => state.antagonist.tank);
const compareTanks =
@@ -36,6 +38,7 @@ export function HeroSection({ id }: HeroSectionProps) {
: undefined;
const awaitedTankDefinitions = use(tankDefinitions);
const tank = awaitedTankDefinitions[id];
const ClassIcon = classIcons[tank.class];
const thicknessRange = useMemo(() => {
const entries = Object.values(awaitedTankDefinitions);
const filtered = entries.filter((thisTank) => thisTank.tier === tank.tier);
@@ -63,12 +66,14 @@ export function HeroSection({ id }: HeroSectionProps) {
justify="center"
pt={{ initial: '6', md: '0' }}
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"
>
<Flex
direction={{ initial: 'column', md: 'row' }}
style={{ maxWidth: 1600, flex: 1 }}
style={{ maxWidth: '140rem', flex: 1 }}
gap={{ initial: '0', md: '6' }}
>
<Flex
align="center"
@@ -77,39 +82,14 @@ export function HeroSection({ id }: HeroSectionProps) {
flexBasis="0"
>
<Flex
gap={{ initial: '2', md: '4' }}
gap="2"
direction="column"
ml={{ initial: '0', md: '8' }}
align={{ initial: 'center', md: 'start' }}
justify="center"
style={{
height: '100%',
userSelect: 'none',
}}
style={{ height: '100%', userSelect: 'none' }}
>
<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">
<Button variant="ghost" size="1" ml="-1">
<ChevronLeftIcon />
@@ -124,6 +104,22 @@ export function HeroSection({ id }: HeroSectionProps) {
</Button>
</Link>
</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>
@@ -156,11 +152,11 @@ export function HeroSection({ id }: HeroSectionProps) {
/>
}
>
<TankSandbox thicknessRange={thicknessRange} />
<TankSandbox ref={canvas} thicknessRange={thicknessRange} />
</Suspense>
</Box>
<Options thicknessRange={thicknessRange} />
<Options canvas={canvas} thicknessRange={thicknessRange} />
</Box>
</Box>
</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 { Suspense, useEffect, useRef } from 'react';
import {
forwardRef,
Suspense,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import { Armor } from '../../../../../../components/Armor';
import { ArmorPlateDisplay } from '../../../../../../components/Armor/components/ArmorPlateDisplay';
import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay';
@@ -11,6 +17,7 @@ import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawL
import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform';
import { Pose, poseEvent } from '../../../../../../core/blitzkit/pose';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import useOnScreen from '../../../../../../hooks/useOnScreen';
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
import * as Duel from '../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../stores/tankopediaEphemeral';
@@ -19,6 +26,7 @@ import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent
import { Controls } from '../Control';
import { Lighting } from '../Lighting';
import { SceneProps } from '../SceneProps';
import { AutoClear } from './AutoClear';
import { ModelLoader } from './components/ModelLoader';
import { TankModel } from './components/TankModel';
@@ -26,153 +34,166 @@ interface TankSandboxProps {
thicknessRange: ThicknessRange;
}
export function TankSandbox({ thicknessRange }: TankSandboxProps) {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const canvas = useRef<HTMLCanvasElement>(null);
const hasImprovedVerticalStabilizer = useEquipment(122);
const protagonist = Duel.use((state) => state.protagonist);
const mutateDuel = Duel.useMutation();
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const display = TankopediaPersistent.use((state) => state.display);
export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
({ thicknessRange }, ref) => {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const canvas = useRef<HTMLCanvasElement>(null);
const hasImprovedVerticalStabilizer = useEquipment(122);
const protagonist = Duel.use((state) => state.protagonist);
const mutateDuel = Duel.useMutation();
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const display = TankopediaPersistent.use((state) => state.display);
const onScreen = useOnScreen(canvas);
function handlePointerDown() {
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
event.preventDefault();
}
function handlePointerUp(event: PointerEvent) {
event.preventDefault();
console.log(onScreen);
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
}
useImperativeHandle(ref, () => canvas.current!, []);
useEffect(() => {
function handlePoseEvent(pose: Pose) {
switch (pose) {
case Pose.HullDown: {
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
-Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
function handlePointerDown() {
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
event.preventDefault();
}
function handlePointerUp(event: PointerEvent) {
event.preventDefault();
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
}
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 () => {
poseEvent.off(handlePoseEvent);
};
});
useEffect(() => {
mutateDuel((draft) => {
[draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits(
draft.protagonist.pitch,
draft.protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
return () => {
poseEvent.off(handlePoseEvent);
};
});
}, [protagonist.gun, protagonist.turret]);
useEffect(() => {
if (display !== TankopediaDisplay.DynamicArmor) {
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
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]);
if (display !== TankopediaDisplay.StaticArmor) {
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
});
}
}, [display]);
return (
<Canvas
gl={{ clippingPlanes: Object.freeze([]), localClippingEnabled: true }}
shadows
ref={canvas}
onPointerDown={handlePointerDown}
onPointerMissed={() => {
useEffect(() => {
if (display !== TankopediaDisplay.DynamicArmor) {
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}
if (display !== TankopediaDisplay.StaticArmor) {
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
});
}}
style={{ userSelect: 'none' }}
camera={{ fov: 25, far: 32 }}
>
<Controls />
<SceneProps />
{display !== TankopediaDisplay.StaticArmor && <TankModel />}
}
}, [display]);
<ShotDisplay />
<ArmorPlateDisplay />
return (
<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 */}
<Suspense fallback={<ModelLoader />}>
{display === TankopediaDisplay.DynamicArmor && <Armor />}
{display === TankopediaDisplay.StaticArmor && (
<StaticArmor thicknessRange={thicknessRange} />
)}
<Lighting />
</Suspense>
</Canvas>
);
}
<ShotDisplay />
<ArmorPlateDisplay />
<AutoClear />
{/* idk why the shot display doesn't work without suspense here lol */}
<Suspense fallback={<ModelLoader />}>
{display === TankopediaDisplay.DynamicArmor && <Armor />}
{display === TankopediaDisplay.StaticArmor && (
<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 {
Cross1Icon,
CameraIcon,
CopyIcon,
DownloadIcon,
EnterFullScreenIcon,
ExitFullScreenIcon,
EyeOpenIcon,
@@ -11,15 +13,16 @@ import {
Dialog,
DropdownMenu,
Flex,
Heading,
IconButton,
Popover,
SegmentedControl,
Strong,
Tabs,
Text,
Tooltip,
} 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 { SmallTankIcon } from '../../../../../../../components/SmallTankIcon';
import { ThicknessRange } from '../../../../../../../components/StaticArmor';
@@ -28,10 +31,7 @@ import { resolvePenetrationCoefficient } from '../../../../../../../core/blitz/r
import { asset } from '../../../../../../../core/blitzkit/asset';
import { imgur } from '../../../../../../../core/blitzkit/imgur';
import { Pose, poseEvent } from '../../../../../../../core/blitzkit/pose';
import {
SHELL_NAMES,
TIER_ROMAN_NUMERALS,
} from '../../../../../../../core/blitzkit/tankDefinitions/constants';
import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkit/tankDefinitions/constants';
import { uniqueGuns } from '../../../../../../../core/blitzkit/uniqueGuns';
import { useEquipment } from '../../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../../hooks/useFullScreen';
@@ -48,9 +48,10 @@ import { Thicknesses } from '../../Thicknesses';
interface OptionsProps {
thicknessRange: ThicknessRange;
canvas: RefObject<HTMLCanvasElement>;
}
export function Options({ thicknessRange }: OptionsProps) {
export function Options({ thicknessRange, canvas }: OptionsProps) {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const displayRaw = TankopediaPersistent.use((state) => state.display);
@@ -75,12 +76,12 @@ export function Options({ thicknessRange }: OptionsProps) {
const environment = TankopediaPersistent.use(
(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 antagonistShell = Duel.use((state) => state.antagonist.shell);
const [antagonistSelectorOpen, setAntagonistSelectorOpen] = useState(false);
const antagonistTank = Duel.use((state) => state.antagonist.tank);
const antagonistTurret = Duel.use((state) => state.antagonist.turret);
const hasCalibratedShells = useEquipment(103, true);
const [tab, setTab] = useState('search');
const mutateDuel = Duel.useMutation();
@@ -126,12 +127,9 @@ export function Options({ thicknessRange }: OptionsProps) {
<Flex
gap="2"
direction="column"
style={{
position: 'absolute',
right: 16,
top: '50%',
transform: 'translateY(-50%)',
}}
top="50%"
right="3"
style={{ position: 'absolute', transform: 'translateY(-50%)' }}
align="end"
>
<Text color="gray" size={{ initial: '1', sm: '2' }}>
@@ -181,12 +179,11 @@ export function Options({ thicknessRange }: OptionsProps) {
</Popover.Trigger>
<Popover.Content side="left" align="center">
<Flex>
<Flex gap="2">
{[...antagonistUniqueGuns]
.reverse()
.map(({ gun, turret }, index) => (
<ModuleButton
rowChild
module="gun"
key={gun.id}
onClick={() =>
@@ -196,8 +193,6 @@ export function Options({ thicknessRange }: OptionsProps) {
draft.antagonist.shell = gun.shells[0];
})
}
first={index === 0}
last={index === antagonistUniqueGuns.length - 1}
selected={gun.id === antagonistGun.id}
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
/>
@@ -225,6 +220,7 @@ export function Options({ thicknessRange }: OptionsProps) {
marginTop: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
invalidate();
mutateDuel((draft) => {
draft.antagonist.shell = thisShell;
});
@@ -308,6 +304,59 @@ export function Options({ thicknessRange }: OptionsProps) {
</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
style={{
position: 'absolute',
@@ -343,136 +392,21 @@ export function Options({ thicknessRange }: OptionsProps) {
onValueChange={setTab}
style={{ position: 'relative' }}
>
<Dialog.Close>
<Button
variant="ghost"
style={{ position: 'absolute', right: 0, top: 8 }}
>
<Cross1Icon />
</Button>
</Dialog.Close>
<Flex gap="4" direction="column">
<Tabs.List>
<Tabs.Trigger value="search">Search</Tabs.Trigger>
<Tabs.Trigger value="configure">Configure</Tabs.Trigger>
</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>
<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);
}}
/>
</Tabs.Root>
</Dialog.Content>
</Dialog.Root>
@@ -489,19 +423,25 @@ export function Options({ thicknessRange }: OptionsProps) {
}}
>
<SegmentedControl.Item value={`${TankopediaDisplay.Model}`}>
<Flex height="100%" align="center">
<img src={imgur('jAdYf0m')} style={{ height: '1.25em' }} />
</Flex>
<Tooltip content="Model">
<Flex height="100%" align="center">
<img src={imgur('jAdYf0m')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaDisplay.DynamicArmor}`}>
<Flex height="100%" align="center">
<img src={imgur('oe4Cq0g')} style={{ height: '1.25em' }} />
</Flex>
<Tooltip content="Dynamic armor">
<Flex height="100%" align="center">
<img src={imgur('oe4Cq0g')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaDisplay.StaticArmor}`}>
<Flex height="100%" align="center">
<img src={imgur('VQ4uDno')} style={{ height: '1.25em' }} />
</Flex>
<Tooltip content="Static armor">
<Flex height="100%" align="center">
<img src={imgur('VQ4uDno')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item>
</SegmentedControl.Root>
@@ -8,13 +8,7 @@ export function ShotDisplaySection() {
if (!shot) return null;
return (
<Flex
justify="center"
display={{
initial: 'flex',
sm: 'none',
}}
>
<Flex justify="center" display={{ initial: 'flex', sm: 'none' }}>
<ShotDisplayCard shot={shot} />
</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 { Link } from '../../../../../components/Link';
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';
export function VideoSection() {
const tank = Duel.use((state) => state.protagonist.tank);
const awaitedVideoDefinitions = use(videoDefinitions);
const videos = awaitedVideoDefinitions[tank.id]?.videos ?? [];
const awaitedReviews = use(reviews);
const videos = awaitedReviews.reviews[tank.id]?.videos;
if (!videos) return null;
return (
<PageWrapper noFlex1>
<Flex direction="column" gap="4" align="start">
<Flex direction="column">
<Flex direction="column" gap="6" align="center">
<Flex direction="column" align="center">
<Heading size="6">Review videos</Heading>
<Text color="gray">BlitzKit does not endorse any review videos.</Text>
</Flex>
<Flex gap="3" wrap="wrap">
{videos.map((video) => (
<Link
key={video}
href={`https://www.youtube.com/watch?v=${video}`}
target="_blank"
<Flex gap="3" wrap="wrap" justify="center">
{videos.map((video) => {
const image = `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`;
const youtuber = youtubers.find(
(youtuber) => youtuber.id === video.author,
);
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
alt="Video thumbnail"
src={`https://i.ytimg.com/vi/${video}/hqdefault.jpg`}
<VerifiedIcon />
<Text
align="center"
style={{
aspectRatio: '16 / 9',
objectFit: 'cover',
height: 128,
borderRadius: 8,
width: '9rem',
}}
/>
</Link>
))}
>
Apply to BlitzKit YouTuber program
</Text>
</Flex>
</Link>
</Flex>
</Flex>
</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 strings from '../../../../lang/en-US.json';
import * as Duel from '../../../../stores/duel';
import * as TankFilters from '../../../../stores/tankFilters';
import * as TankopediaEphemeral from '../../../../stores/tankopediaEphemeral';
export default async function Layout({
@@ -25,26 +24,24 @@ export default async function Layout({
const title = `${tank.name} - Tier ${TIER_ROMAN_NUMERALS[tank.tier]} ${
(strings.common.nations_adjectives as Record<string, string>)[tank.nation]
} ${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 (
<TankopediaEphemeral.Provider data={tankModelDefinition}>
<TankFilters.Provider>
<Duel.Provider
data={{
tank: awaitedTankDefinitions[id],
provisionDefinitions: awaitedProvisionDefinitions,
}}
>
<title>{title}</title>
<meta name="description" content={description} />
<meta property="og:title" content={title} />
<meta property="og:image" content={tankIcon(id)} />
<meta property="og:description" content={description} />
<Duel.Provider
data={{
tank: awaitedTankDefinitions[id],
provisionDefinitions: awaitedProvisionDefinitions,
}}
>
<title>{title}</title>
<meta name="description" content={description} />
<meta property="og:title" content={title} />
<meta property="og:image" content={tankIcon(id)} />
<meta property="og:description" content={description} />
{children}
</Duel.Provider>
</TankFilters.Provider>
{children}
</Duel.Provider>
</TankopediaEphemeral.Provider>
);
}
+14 -4
View File
@@ -1,5 +1,6 @@
'use client';
import { invalidate } from '@react-three/fiber';
import { useEffect } from 'react';
import { AdMidSectionResponsive } from '../../../../components/AdMidSectionResponsive';
import PageWrapper from '../../../../components/PageWrapper';
@@ -8,9 +9,12 @@ import * as Duel from '../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../stores/tankopediaEphemeral';
import { HistorySection } from './components/HistorySection';
import { CharacteristicsSection } from './components/Model/CharacteristicsSection';
import { GameModeSection } from './components/Model/GameModeSection';
import { HeroSection } from './components/Model/HeroSection';
import { MetaSection } from './components/Model/MetaSection';
import { TechTreeSection } from './components/Model/TechTreeSection';
import { ShotDisplaySection } from './components/ShotDisplaySection';
import { TestingSection } from './components/TestingSection';
import { VideoSection } from './components/VideoSection';
export default function Page({ params }: { params: { id: string } }) {
@@ -31,11 +35,13 @@ export default function Page({ params }: { params: { id: string } }) {
}
if (event.key === '1') {
invalidate();
wipeShot();
mutateDuel((draft) => {
draft.antagonist.shell = draft.antagonist.gun.shells[0];
});
} else if (event.key === '2') {
invalidate();
wipeShot();
mutateDuel((draft) => {
if (draft.antagonist.gun.shells[1]) {
@@ -43,6 +49,7 @@ export default function Page({ params }: { params: { id: string } }) {
}
});
} else if (event.key === '3') {
invalidate();
wipeShot();
mutateDuel((draft) => {
if (draft.antagonist.gun.shells[2]) {
@@ -73,16 +80,19 @@ export default function Page({ params }: { params: { id: string } }) {
}, []);
return (
<PageWrapper p="0" noMaxWidth color="purple" size={1600}>
<PageWrapper p="0" noMaxWidth color="purple" size={1600} gap="9">
<HeroSection id={id} />
<ShotDisplaySection />
{!exempt && <AdMidSectionResponsive />}
<CharacteristicsSection />
<MetaSection />
{!exempt && <AdMidSectionResponsive />}
<TechTreeSection />
<TestingSection />
<CharacteristicsSection />
{!exempt && <AdMidSectionResponsive />}
<GameModeSection />
<VideoSection />
<HistorySection />
{!exempt && <AdMidSectionResponsive />}
{!exempt && <AdMidSectionResponsive mb="6" />}
</PageWrapper>
);
}
@@ -1,7 +1,7 @@
'use client';
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 { use } from 'react';
import { classIcons } from '../../../../components/ClassIcon';
@@ -14,6 +14,7 @@ import { asset } from '../../../../core/blitzkit/asset';
import { gameDefinitions } from '../../../../core/blitzkit/gameDefinitions';
import { Tier } from '../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/constants';
import strings from '../../../../lang/en-US.json';
import * as TankFilters from '../../../../stores/tankFilters';
interface FilterControlProps {
@@ -39,24 +40,26 @@ export function FilterControl({ compact }: FilterControlProps) {
const selected = filters.tiers?.includes(tier);
return (
<IconButton
key={tier}
variant={selected ? 'solid' : 'soft'}
radius="none"
color={selected ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
if (draft.tiers?.includes(tier)) {
draft.tiers = draft.tiers?.filter((t) => t !== tier);
} else {
draft.tiers = [...(draft.tiers ?? []), tier];
}
})
}
>
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
</IconButton>
<Tooltip key={tier} content={`Tier ${tier}`}>
<IconButton
key={tier}
variant={selected ? 'solid' : 'soft'}
radius="none"
color={selected ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
if (draft.tiers?.includes(tier)) {
draft.tiers = draft.tiers?.filter((t) => t !== tier);
} else {
draft.tiers = [...(draft.tiers ?? []), tier];
}
})
}
>
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
</IconButton>
</Tooltip>
);
})}
</Flex>
@@ -66,24 +69,26 @@ export function FilterControl({ compact }: FilterControlProps) {
const selected = filters.tiers?.includes(tier);
return (
<IconButton
key={tier}
variant={selected ? 'solid' : 'soft'}
radius="none"
color={selected ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
if (draft.tiers?.includes(tier)) {
draft.tiers = draft.tiers?.filter((t) => t !== tier);
} else {
draft.tiers = [...(draft.tiers ?? []), tier];
}
})
}
>
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
</IconButton>
<Tooltip key={tier} content={`Tier ${tier}`}>
<IconButton
key={tier}
variant={selected ? 'solid' : 'soft'}
radius="none"
color={selected ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
if (draft.tiers?.includes(tier)) {
draft.tiers = draft.tiers?.filter((t) => t !== tier);
} else {
draft.tiers = [...(draft.tiers ?? []), tier];
}
})
}
>
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
</IconButton>
</Tooltip>
);
})}
</Flex>
@@ -101,29 +106,37 @@ export function FilterControl({ compact }: FilterControlProps) {
const selected = filters.nations?.includes(nation);
return (
<IconButton
key={nation}
variant={selected ? 'solid' : 'soft'}
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];
}
})
<Tooltip
content={
strings.common.nations[
nation as keyof typeof strings.common.nations
]
}
key={nation}
>
<img
style={{ width: '1em', height: '1em' }}
src={asset(`flags/circle/${nation}.webp`)}
/>
</IconButton>
<IconButton
variant={selected ? 'solid' : 'soft'}
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>
@@ -134,30 +147,39 @@ export function FilterControl({ compact }: FilterControlProps) {
const selected = filters.nations?.includes(nation);
return (
<IconButton
key={nation}
style={{ flex: 1 }}
variant={selected ? 'solid' : 'soft'}
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];
}
})
<Tooltip
content={
strings.common.nations[
nation as keyof typeof strings.common.nations
]
}
key={nation}
>
<img
style={{ width: '1em', height: '1em' }}
src={asset(`flags/circle/${nation}.webp`)}
/>
</IconButton>
<IconButton
key={nation}
style={{ flex: 1 }}
variant={selected ? 'solid' : 'soft'}
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>
@@ -173,26 +195,30 @@ export function FilterControl({ compact }: FilterControlProps) {
const selected = filters.classes?.includes(tankClass);
return (
<IconButton
<Tooltip
content={strings.common.tank_class_medium[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>
@@ -202,67 +228,75 @@ export function FilterControl({ compact }: FilterControlProps) {
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { sm: 'row', initial: 'column' }}
>
<IconButton
variant={filters.types?.includes('researchable') ? 'solid' : 'soft'}
radius="none"
color={filters.types?.includes('researchable') ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
if (draft.types?.includes('researchable')) {
draft.types = draft.types?.filter((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' }}
<Tooltip content="Researchable">
<IconButton
variant={filters.types?.includes('researchable') ? 'solid' : 'soft'}
radius="none"
color={filters.types?.includes('researchable') ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
if (draft.types?.includes('researchable')) {
draft.types = draft.types?.filter(
(t) => t !== 'researchable',
);
} else {
draft.types = [...(draft.types ?? []), 'researchable'];
}
})
}
>
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
</Text>
</IconButton>
<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' }}
</IconButton>
</Tooltip>
<Tooltip content="Premium">
<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'];
}
})
}
>
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
</Text>
</IconButton>
<Text
color={filters.types?.includes('premium') ? undefined : 'amber'}
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
@@ -270,51 +304,57 @@ export function FilterControl({ compact }: FilterControlProps) {
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { sm: 'row', initial: 'column' }}
>
<IconButton
variant={filters.testing === 'include' ? 'solid' : 'soft'}
radius="none"
color={filters.testing === 'include' ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
draft.testing = 'include';
})
}
>
<ExperimentIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
<IconButton
variant={filters.testing === 'exclude' ? 'solid' : 'soft'}
radius="none"
color={filters.testing === 'exclude' ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
draft.testing = 'exclude';
})
}
>
<ScienceOffIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
<IconButton
variant={filters.testing === 'only' ? 'solid' : 'soft'}
radius="none"
color={filters.testing === 'only' ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
draft.testing = 'only';
})
}
>
<ScienceIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
<Tooltip content="Include test tank">
<IconButton
variant={filters.testing === 'include' ? 'solid' : 'soft'}
radius="none"
color={filters.testing === 'include' ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
draft.testing = 'include';
})
}
>
<ExperimentIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
</Tooltip>
<Tooltip content="Exclude test tank">
<IconButton
variant={filters.testing === 'exclude' ? 'solid' : 'soft'}
radius="none"
color={filters.testing === 'exclude' ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
draft.testing = 'exclude';
})
}
>
<ScienceOffIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
</Tooltip>
<Tooltip content="Only test tank">
<IconButton
variant={filters.testing === 'only' ? 'solid' : 'soft'}
radius="none"
color={filters.testing === 'only' ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankFilters((draft) => {
draft.testing = 'only';
})
}
>
<ScienceIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
</Tooltip>
</Flex>
<Box>
@@ -15,6 +15,7 @@ interface SearchBarProps {
export function SearchBar({ topResult, onSelect }: SearchBarProps) {
const tankFiltersStore = TankFilters.useStore();
const lastSearch = tankFiltersStore.getState().search;
const router = useRouter();
const input = useRef<HTMLInputElement>(null);
const searching = TankFilters.use((state) => state.searching);
@@ -58,6 +59,7 @@ export function SearchBar({ topResult, onSelect }: SearchBarProps) {
<Flex justify="center" mt="4">
<Flex gap="2" flexGrow="1">
<TextField.Root
defaultValue={lastSearch}
style={{ flex: 1 }}
ref={input}
placeholder="Search tanks..."
@@ -9,6 +9,7 @@ export const card = style({
alignItems: 'center',
gap: 'var(--space-2)',
background: cardBackgroundVar,
overflow: 'hidden',
});
export const image = style({
@@ -34,6 +35,7 @@ export const name = style({
export const link = style({
color: 'inherit',
textDecoration: 'none',
width: '100%',
':hover': {
textDecoration: 'underline',
@@ -162,7 +162,6 @@ export function TankCard({ tank, onSelect }: TankCardProps) {
align="center"
overflow="hidden"
width="100%"
maxWidth="100%"
>
<Icon className={styles.classIcon} />
<Text align="center" className={styles.name}>
@@ -18,8 +18,8 @@ import {
TankDefinition,
tankDefinitions,
tankDefinitionsArray,
tankNames,
} from '../../../../../core/blitzkit/tankDefinitions';
import { tankNames } from '../../../../../core/blitzkit/tankDefinitions/tankNames';
import { unionBoundingBox } from '../../../../../core/blitzkit/unionBoundingBox';
import { useAdExempt } from '../../../../../hooks/useAdExempt';
import * as TankFilters from '../../../../../stores/tankFilters';
@@ -375,8 +375,7 @@ export const TankSearch = memo<TankSearchProps>(
return sort.direction === 'ascending' ? sorted : sorted.reverse();
} else {
const searchedRaw = go(filters.search, awaitedTankNames, {
key: 'combined',
limit: 25,
keys: ['searchableName', 'searchableNameDeburr', 'camouflages'],
});
const searchedTanks = searchedRaw.map(
(result) => awaitedTankDefinitions[result.obj.id],
@@ -434,8 +433,8 @@ export const TankSearch = memo<TankSearchProps>(
<ExperimentIcon style={{ width: '1em', height: '1em' }} />
</Callout.Icon>
<Callout.Text>
Tanks in testing are subject to change and many not represent
the final product.
Tanks in testing are subject to change and may not represent the
final product.
</Callout.Text>
</Callout.Root>
</Flex>
+5 -2
View File
@@ -1,10 +1,13 @@
import * as TankFilters from '../../../stores/tankFilters';
import * as TankopediaPersistent from '../../../stores/tankopediaPersistent';
import * as TankopediaSort from '../../../stores/tankopediaSort';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<TankopediaPersistent.Provider>
<TankFilters.Provider>
<TankopediaPersistent.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 * as TankFilters from '../../../stores/tankFilters';
import { TankSearch } from './components/TankSearch';
export default function Page() {
return (
<TankFilters.Provider>
<PageWrapper color="purple" size={1028}>
<TankSearch />
</PageWrapper>
</TankFilters.Provider>
);
}
@@ -22,8 +22,7 @@ export function ArmorPlateDisplay() {
(state) => state.highlightArmor,
);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const developerMode = App.use((state) => state.developerMode);
const devThicknessInput = useRef<HTMLInputElement>(null);
const developerMode = App.useDeferred(false, (state) => state.developerMode);
const duelStore = Duel.useStore();
const input = useRef<HTMLInputElement>(null);
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
@@ -7,7 +7,6 @@ import { useArmor } from '../../../hooks/useArmor';
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition';
import { useTankTransform } from '../../../hooks/useTankTransform';
import * as Duel from '../../../stores/duel';
import * as TankopediaPersistent from '../../../stores/tankopediaPersistent';
import { ModelTankWrapper } from './ModelTankWrapper';
import { PrimaryArmorSceneComponent } from './PrimaryArmorSceneComponent';
@@ -29,13 +28,6 @@ export const PrimaryArmorScene = memo(() => {
const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turretOrigin);
const gunOrigin = correctZYTuple(turretModelDefinition.gunOrigin);
const tankopediaPersistentStore = TankopediaPersistent.useStore();
console.log(
tankModelDefinition.armor,
turretModelDefinition.armor,
gunModelDefinition.armor,
);
useTankTransform(protagonist, turretContainer, gunContainer);
@@ -140,8 +140,8 @@ export function SpacedArmorSceneComponent({
if (
!allowRicochet &&
!noDuplicateIntersections.some((intersection) => {
intersection.object.userData.type === ArmorType.Primary;
noDuplicateIntersections.every((intersection) => {
intersection.object.userData.type !== ArmorType.Primary;
})
) {
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 { ConsumableButton } from './ModuleButtons/ConsumableButton';
interface ConsumablesManagerProps {
type ConsumablesManagerProps = FlexProps & {
consumables: ConsumableEntry[];
selected: number[];
onChange?: (consumables: number[]) => void;
onConsumablesChange?: (consumables: number[]) => void;
disabled?: boolean;
timers?: boolean;
hasHighEndConsumables?: boolean;
hasConsumableDeliverySystem?: boolean;
cooldownBooster?: number;
}
};
export function ConsumablesManager({
consumables,
selected,
onChange,
onConsumablesChange,
disabled,
timers,
hasConsumableDeliverySystem,
cooldownBooster,
hasHighEndConsumables,
...props
}: ConsumablesManagerProps) {
return (
<Flex
wrap="wrap"
style={{
maxWidth: 288,
borderRadius: 4,
overflow: 'hidden',
}}
>
<Flex wrap="wrap" gap="2" {...props}>
{consumables.map((consumable, index) => {
const isSelected = selected.some((id) => id === consumable.id);
@@ -53,14 +47,11 @@ export function ConsumablesManager({
: undefined
}
key={consumable.id}
first={index === 0}
last={index === consumables.length - 1}
rowChild
disabled={disabled && !isSelected}
consumable={consumable.id}
selected={isSelected}
onClick={() => {
if (!onChange) return;
if (!onConsumablesChange) return;
const draft = [...selected];
@@ -70,7 +61,7 @@ export function ConsumablesManager({
draft.push(consumable.id);
}
onChange(draft);
onConsumablesChange(draft);
}}
/>
);
-2
View File
@@ -30,8 +30,6 @@ export function CrewSkillManager({
<GenericTankComponentButton
key={skill}
special={!permanentSkills.includes(skill)}
first
last
selected={level > 0}
discriminator={
level === 0 ? undefined : TIER_ROMAN_NUMERALS[level as Tier]
+8 -4
View File
@@ -23,8 +23,10 @@ export function EquipmentManager({
<Flex key={columnIndex}>
<EquipmentButton
equipment={equipment[0]}
first
rowChild
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
selected={matrix[rowIndex][columnIndex] === -1}
onClick={() => {
const draft = cloneDeep(matrix);
@@ -40,8 +42,10 @@ export function EquipmentManager({
/>
<EquipmentButton
equipment={equipment[1]}
last
rowChild
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
selected={matrix[rowIndex][columnIndex] === 1}
onClick={() => {
const draft = cloneDeep(matrix);
+2 -2
View File
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
import { forwardRef } from 'react';
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
({ href, onClick, ...props }, ref) => {
({ href, onClick, target, ...props }, ref) => {
const router = useRouter();
return (
@@ -15,7 +15,7 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
onClick={(event) => {
event.preventDefault();
if (href) {
if (event.ctrlKey || event.metaKey) {
if (event.ctrlKey || event.metaKey || target === '_blank') {
window.open(href);
} else {
router.push(href);
@@ -1,6 +1,8 @@
import { ClockIcon, ReloadIcon } from '@radix-ui/react-icons';
import { Flex, Text } from '@radix-ui/themes';
import { use } from 'react';
import { asset } from '../../core/blitzkit/asset';
import { consumableDefinitions } from '../../core/blitzkit/consumableDefinitions';
import { useDelta } from '../../hooks/useDelta';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
@@ -17,23 +19,22 @@ export function ConsumableButton({
duration,
...props
}: ConsumableButtonProps) {
const awaitedConsumableDefinitions = use(consumableDefinitions);
const cooldownDelta = useDelta(cooldown ?? 0);
const durationDelta = useDelta(duration ?? 0);
return (
<GenericTankComponentButton
style={{ width: '6rem' }}
tooltip={awaitedConsumableDefinitions[consumable].name}
icon={asset(`icons/consumables/${consumable}.webp`)}
style={{
flex: 1,
minWidth: 'fit-content',
borderRadius: 0,
}}
{...props}
>
{(cooldown || duration) && (
<Flex direction="column" style={{ transform: 'translateX(-10px)' }}>
<Flex direction="column" gap="1" ml="-2" mr="2">
{duration && (
<Text
size="1"
color={
durationDelta > 0
? 'green'
@@ -50,6 +51,7 @@ export function ConsumableButton({
)}
{cooldown && (
<Text
size="1"
color={
cooldownDelta < 0
? 'green'
@@ -1,4 +1,6 @@
import { use } from 'react';
import { asset } from '../../core/blitzkit/asset';
import { equipmentDefinitions } from '../../core/blitzkit/equipmentDefinitions';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
@@ -7,8 +9,11 @@ interface EquipmentButtonProps extends TankComponentButtonProps {
}
export function EquipmentButton({ equipment, ...props }: EquipmentButtonProps) {
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
return (
<GenericTankComponentButton
tooltip={awaitedEquipmentDefinitions.equipments[equipment].name}
icon={asset(`icons/equipment/${equipment}.webp`)}
{...props}
/>
@@ -1,4 +1,6 @@
import { use } from 'react';
import { asset } from '../../core/blitzkit/asset';
import { provisionDefinitions } from '../../core/blitzkit/provisionDefinitions';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
@@ -7,8 +9,11 @@ interface ProvisionButtonProps extends TankComponentButtonProps {
}
export function ProvisionButton({ provision, ...props }: ProvisionButtonProps) {
const awaitedProvisionDefinitions = use(provisionDefinitions);
return (
<GenericTankComponentButton
tooltip={awaitedProvisionDefinitions[provision].name}
icon={asset(`icons/provisions/${provision}.webp`)}
{...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';
export interface TankComponentButtonProps
extends ComponentProps<typeof Button> {
selected?: boolean;
first?: boolean;
last?: boolean;
rowChild?: boolean;
children?: ReactNode;
discriminator?: ReactNode;
disabled?: boolean;
banner?: string;
special?: boolean;
tooltip?: string;
}
export function TankComponentButton({
variant,
selected,
first = false,
last = false,
rowChild,
discriminator,
special,
children,
banner,
disabled,
style,
tooltip,
...props
}: TankComponentButtonProps) {
return (
const button = (
<Button
radius="medium"
color={selected ? (special ? 'amber' : undefined) : 'gray'}
@@ -38,11 +34,6 @@ export function TankComponentButton({
minWidth: 48,
height: 40,
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',
cursor: 'unset',
pointerEvents: variant === 'ghost' ? 'none' : undefined,
@@ -84,4 +75,7 @@ export function TankComponentButton({
)}
</Button>
);
if (tooltip) return <Tooltip content={tooltip}>{button}</Tooltip>;
return button;
}
-12
View File
@@ -41,9 +41,6 @@ export function ModuleManager({
<ModuleButton
module="turret"
discriminator={TIER_ROMAN_NUMERALS[modules.turret.tier]}
first
last
rowChild
onClick={() => setTurretMenuOpen(true)}
/>
@@ -85,9 +82,6 @@ export function ModuleManager({
<ModuleButton
module="gun"
discriminator={TIER_ROMAN_NUMERALS[modules.gun.tier]}
first
last
rowChild
onClick={() => setGunMenuOpen(true)}
/>
@@ -128,9 +122,6 @@ export function ModuleManager({
<ModuleButton
module="engine"
discriminator={TIER_ROMAN_NUMERALS[modules.engine.tier]}
first
last
rowChild
onClick={() => setEngineMenuOpen(true)}
/>
@@ -170,9 +161,6 @@ export function ModuleManager({
<ModuleButton
module="chassis"
discriminator={TIER_ROMAN_NUMERALS[modules.track.tier]}
first
last
rowChild
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';
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': {
[`screen and (min-width: ${MIN_EXPANDED_TOOLS}px)`]: {
[`screen and (min-width: ${MIN_WIDTH}px)`]: {
display: 'none',
},
},
});
const navbarBase = style({
position: 'fixed',
export const tools = style({
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,
left: 0,
zIndex: 1,
});
const navbarExpanderBase = style({
transitionDuration: '250ms',
height: '1000px',
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',
});
export const navbar = styleVariants({
expanded: [
navbarBase,
{
maxHeight: '100%',
},
],
collapsed: [
navbarBase,
{
maxHeight: 65,
},
],
});
export const toolCard = style({
boxShadow: `inset 0 -200px 128px -128px var(--color-overlay)`,
backdropFilter: 'blur(4rem) brightness(0.75)',
WebkitBackdropFilter: 'blur(4rem) brightness(0.75)',
':hover': {
boxShadow: `inset 0 -160px 128px -128px #ffffff00`,
},
});
export const toolTexts = style({
'@media': {
[`screen and (max-width: ${MIN_EXPANDED_TOOLS}px)`]: {
display: 'none',
[`screen and (min-width: ${MIN_WIDTH}px)`]: {
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,
Dialog,
Flex,
Grid,
IconButton,
Inset,
Popover,
ScrollArea,
Separator,
Text,
} from '@radix-ui/themes';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import { Fragment, useState } from 'react';
import { REGIONS } from '../../constants/regions';
import { TOOLS } from '../../constants/tools';
import { homeTool, TOOLS } from '../../constants/tools';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import { imgur, ImgurSize } from '../../core/blitzkit/imgur';
import { patreonLoginUrl } from '../../core/blitzkit/patreonLoginUrl';
import { PatreonIcon } from '../../icons/Patreon';
import { WargamingIcon } from '../../icons/Wargaming';
import strings from '../../lang/en-US.json';
import { theme } from '../../stitches.config';
import * as App from '../../stores/app';
import { Link } from '../Link';
import * as styles from './index.css';
export const NAVBAR_HEIGHT = 64;
export default function Navbar() {
const [showHamburgerMenu, setShowHamburgerMenu] = useState(false);
const pathname = usePathname();
const logins = App.use((state) => state.logins);
const filteredTools = TOOLS.filter((tool) => !tool.href);
const mutateApp = App.useMutation();
const tools = [homeTool, ...TOOLS];
return (
<Box
className={styles.navbar[showHamburgerMenu ? 'expanded' : 'collapsed']}
style={{ zIndex: 3 }}
>
<Flex direction="column" align="center">
<Flex
position="absolute"
top="34px"
justify="between"
align="center"
style={{
maxWidth: 1024,
width: '100%',
padding: '0 16px',
transform: 'translateY(-50%)',
}}
gap="8"
>
<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',
<Flex className={styles.navbar}>
<Flex
className={styles.navbarExpander[`${showHamburgerMenu}`]}
onClick={() => setShowHamburgerMenu(false)}
direction="column"
gap="2"
>
<Flex justify="center">
<Flex flexGrow="1" maxWidth="1024px" p="3" align="center">
<Flex gap="3" align="center">
<IconButton
variant="ghost"
color="gray"
className={styles.hamburger}
onClick={(event) => {
event.stopPropagation();
setShowHamburgerMenu((state) => !state);
}}
>
{tool.title}
</Text>
</Link>
))}
{showHamburgerMenu ? <Cross1Icon /> : <HamburgerMenuIcon />}
</IconButton>
<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>
<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>
</Box>
</Flex>
);
}
+1 -4
View File
@@ -15,16 +15,13 @@ export function ProvisionsManager({
disabled,
}: ProvisionsManagerProps) {
return (
<Flex wrap="wrap">
<Flex wrap="wrap" gap="2">
{provisions.map((provision, index) => {
const isSelected = selected.includes(provision);
return (
<ProvisionButton
key={provision}
first={index === 0}
last={index === provisions.length - 1}
rowChild
disabled={disabled && !isSelected}
provision={provision}
selected={isSelected}
+2
View File
@@ -95,6 +95,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
onPointerDown={(event) => event.stopPropagation()}
/>
);
})}
@@ -116,6 +117,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
thickness={trackModelDefinition.thickness}
variant="track"
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',
button: {
text: 'Join Discord',
color: 'indigo',
color: 'plum',
},
};
@@ -119,27 +119,19 @@ export const tankPerformanceTool: Tool = {
image: 'vOKFB03',
};
export const embedTool: Tool = {
id: 'embed',
title: 'Embed',
description: 'Embeds for streaming',
pageDescription:
'Create embeds for streaming World of Tanks Blitz with extreme customization',
button: {
text: 'Create',
color: 'red',
},
image: 'Q0faRYg',
export const homeTool: Tool = {
id: '',
title: 'Home',
button: { color: 'blue', text: '' },
description: '',
image: 'rUPie9G',
};
export const TOOLS: Tool[] = [
tankopediaTool,
compareTool,
tankPerformanceTool,
playerStatsTool,
sessionTool,
embedTool,
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 markdownEscape from 'markdown-escape';
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 { translator } from '../localization/translator';
@@ -19,7 +20,7 @@ export default async function resolveTankId(
`${tank}`,
await (techTreeOnly ? tankNamesTechTreeOnly : tankNames),
{
keys: ['combined'],
keys: ['searchableName', 'searchableNameDeburr', 'camouflages'],
limit: 1,
},
);
@@ -52,7 +52,7 @@ export const averageDefinitions = fetch(asset('averages/manifest.json'), {
.then((response) => response.arrayBuffer())
.then((buffer) =>
decode<AverageDefinitions>(
'blitzkit.AverageDefinitions',
'blitzkit/blitzkit.AverageDefinitions',
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,
gun: GunDefinition,
) {
const included = consumableProvision.include.every((rule) => {
switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
const included =
!consumableProvision.gameMode &&
consumableProvision.include?.every((rule) => {
switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
case 'ids':
return rule.ids.includes(tank.id);
case 'ids':
return rule.ids.includes(tank.id);
case 'nation':
return rule.nations.includes(tank.nation);
case 'nation':
return rule.nations.includes(tank.nation);
case 'category':
throw new SyntaxError('Category filtering found in include rule');
}
});
const excluded = consumableProvision.exclude?.some((rule) => {
switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
case 'category':
throw new SyntaxError('Category filtering found in include rule');
}
});
const excluded =
consumableProvision.gameMode ||
consumableProvision.exclude?.some((rule) => {
switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
case 'ids':
return rule.ids.includes(tank.id);
case 'ids':
return rule.ids.includes(tank.id);
case 'nation':
return rule.nations.includes(tank.nation);
case 'nation':
return rule.nations.includes(tank.nation);
case 'category':
return rule.categories.some((category) => {
switch (category) {
case 'clip':
return gun.type !== 'regular';
}
});
}
});
case 'category':
return rule.categories.some((category) => {
switch (category) {
case 'clip':
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[];
};
export interface ConsumableEntry {
export type ConsumableEntry = {
id: number;
name: string;
cooldown: number;
duration?: number;
include: TankFilterDefinition[];
exclude?: TankFilterDefinition[];
}
} & (
| {
gameMode: false;
include: TankFilterDefinition[];
exclude?: TankFilterDefinition[];
}
| {
gameMode: true;
}
);
export interface ConsumableDefinitions {
[key: string]: ConsumableEntry;
+12
View File
@@ -1,9 +1,21 @@
import { asset } from './asset';
import { fetchCdonLz4 } from './fetchCdonLz4';
interface GameMode {
name: string;
}
export interface GameDefinitions {
version: string;
nations: string[];
gameModes: Record<number, GameMode>;
roles: Record<
number,
{
provisions: number[];
consumables: number[];
}
>;
}
export const gameDefinitions = fetchCdonLz4<GameDefinitions>(
+2
View File
@@ -1,3 +1,4 @@
import { invalidate } from '@react-three/fiber';
import { EventManager } from './eventManager';
export interface ModelTransformEventData {
@@ -6,3 +7,4 @@ export interface 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