Compare commits

...
66 Commits
Author SHA1 Message Date
TrèsAbhi 4062e56b4c feat: migrated to new db 2024-04-03 15:19:48 -05:00
TrèsAbhi 0885e7e68a feat: logs 2024-04-02 10:37:39 -05:00
TrèsAbhi 3cfa837682 Merge branch 'main' of https://github.com/tresabhi/blitzkrieg into dev 2024-04-02 10:26:31 -05:00
TrèsAbhi 1982604878 fix: some more floating component removals 2024-04-02 10:21:51 -05:00
TrèsAbhi 9ef6eea4a9 fix: remove useless tooltips 2024-04-02 10:10:15 -05:00
TrèsAbhi 78e6b2696c fix: word tolerant pitch and yaw 2024-04-02 10:04:54 -05:00
TrèsAbhi 40b6f30092 fix: synced armor states 2 2024-04-02 10:02:21 -05:00
TrèsAbhi de2d9a1a3e fix: synced armor states 2024-04-02 09:52:46 -05:00
TrèsAbhi b7ad12c698 Create ads.txt 2024-04-02 08:24:37 -05:00
TrèsAbhi 886c2aa1d1 fix: access document on client 2 2024-04-01 22:02:15 -05:00
TrèsAbhi 9818c9868a fix: access document on client 2024-04-01 22:00:38 -05:00
TrèsAbhi 31055f1d66 fix: use link for big tank search 2024-04-01 21:57:54 -05:00
TrèsAbhi 6cef753053 feat: donation icons 2024-04-01 21:46:17 -05:00
TrèsAbhi 5b889f8483 feat: more accessible full screen mode 2024-04-01 21:33:47 -05:00
TrèsAbhi d0d905ccfb feat: better antagonist bar 2024-04-01 19:30:06 -05:00
TrèsAbhi 9a91a8bb13 fix: excessive antagonist bar gap 2024-04-01 19:17:59 -05:00
TrèsAbhi c12cc9b8c5 feat: length sorting 2024-04-01 19:12:59 -05:00
TrèsAbhi fdbfd0bc10 feat: rename size to volume 2024-04-01 19:10:42 -05:00
TrèsAbhi 286b178218 feat: some cleanup 2024-04-01 19:09:21 -05:00
TrèsAbhi 54e012f736 feat: dropdown modules 2024-04-01 19:05:55 -05:00
TrèsAbhi cc6de50de6 feat: upgrade to radix 3.0 2024-04-01 18:36:14 -05:00
TrèsAbhi 194e5fceca feat: crew training info 2024-04-01 09:31:02 -05:00
TrèsAbhi 879a5d534c feat: consumable info 2024-04-01 09:27:54 -05:00
TrèsAbhi 29aaf60da0 feat: cleaner info 2024-04-01 09:24:12 -05:00
TrèsAbhi 3c8f8b49e4 feat: condensed 2 2024-03-31 22:32:43 -05:00
TrèsAbhi 0b88f71f26 feat: condensed 2024-03-31 22:27:11 -05:00
TrèsAbhi 65e239a22b feat: hide module actions on no upgrades 2024-03-31 22:16:29 -05:00
TrèsAbhi 7727e64931 feat: quick actions 2024-03-31 22:14:14 -05:00
TrèsAbhi 7b0465322d feat: special skills 2024-03-31 19:27:37 -05:00
TrèsAbhi 099e5eb228 feat: skill stats 2024-03-31 19:17:30 -05:00
TrèsAbhi eaff26c8d9 feat: crew skills ui 2024-03-31 18:49:17 -05:00
TrèsAbhi d79129df91 feat: skill definitions 2024-03-31 18:24:19 -05:00
TrèsAbhi b363c76639 feat: crew skill icons 2024-03-31 18:17:48 -05:00
TrèsAbhi 034429cd47 Merge branch 'main' of https://github.com/tresabhi/blitzkrieg 2024-03-31 17:48:30 -05:00
TrèsAbhi 57867317cc fix: missing info 2024-03-31 17:48:15 -05:00
TrèsAbhi c2e314b193 feat: log 2024-03-31 17:48:01 -05:00
TrèsAbhi 9d2af1de13 fix: quick equipments 2024-03-31 17:45:22 -05:00
TrèsAbhi 232823566b fix: missing info 2024-03-30 12:48:17 -05:00
TrèsAbhi 958f0f2a52 fix: log version 2024-03-30 12:44:46 -05:00
TrèsAbhi b34fef2147 feat: logs 2024-03-30 12:44:34 -05:00
TrèsAbhi fe661124fa Merge branch 'dev' of https://github.com/tresabhi/blitzkrieg into dev 2024-03-30 12:39:19 -05:00
TrèsAbhi 457dcbcad5 fix: 0 battle handling 2024-03-30 12:38:53 -05:00
TrèsAbhi c18ee70eba fix: /rating use midnight leaderboard 2024-03-30 12:38:53 -05:00
TrèsAbhi 095ffc3d6a fix: sync on click inspector 2024-03-30 12:38:53 -05:00
TrèsAbhi e8b91648b1 fix: click through post inputs 2024-03-30 12:38:53 -05:00
TrèsAbhi 1b43f6ba99 fix: use final thickness 2024-03-30 12:38:53 -05:00
TrèsAbhi fc5360363c fix: uniform inv proj mat 2024-03-30 12:38:53 -05:00
TrèsAbhi 47b3c3029e fix: more accurate HE 2024-03-30 12:38:53 -05:00
TrèsAbhi dea7f34ca3 fix: use normal proj mat 2024-03-30 12:38:53 -05:00
TrèsAbhi 1e5026ea4a fix: persistent equipment 2024-03-30 12:38:53 -05:00
TrèsAbhi 98f568ff6a fix: use three js inverse view proj 2024-03-30 12:38:53 -05:00
TrèsAbhi 10a6f8371d fix: 0 battle handling 2024-03-30 12:33:17 -05:00
TrèsAbhi 0933aa6615 fix: /rating use midnight leaderboard 2024-03-30 12:32:48 -05:00
TrèsAbhi 746592418d Update cd-rating-latest.yml 2024-03-30 12:22:20 -05:00
TrèsAbhi e2404b063c fix: sync on click inspector 2024-03-30 12:14:49 -05:00
TrèsAbhi bae1301556 fix: click through post inputs 2024-03-30 12:13:47 -05:00
TrèsAbhi c2de4952ad fix: use final thickness 2024-03-30 11:34:39 -05:00
TrèsAbhi cce61d8075 fix: uniform inv proj mat 2024-03-30 11:31:44 -05:00
TrèsAbhi 34a0388f45 fix: more accurate HE 2024-03-30 11:28:34 -05:00
TrèsAbhi 9d62965fc3 fix: use normal proj mat 2024-03-30 10:39:58 -05:00
TrèsAbhi 892434e28f fix: persistent equipment 2024-03-30 10:30:30 -05:00
TrèsAbhi 2597fe4bcd fix: use three js inverse view proj 2024-03-30 10:23:23 -05:00
TrèsAbhi f85b4b36bd fix: async exec 2024-03-29 16:45:17 -05:00
TrèsAbhi f777dafefc feat: generated list 2024-03-29 16:42:56 -05:00
TrèsAbhi 6ef65ccea3 fix: don't install fixed sharp twice 2024-03-29 16:40:07 -05:00
TrèsAbhi 974b27a313 fix: production flag 2024-03-29 16:38:51 -05:00
72 changed files with 1838 additions and 1094 deletions

No files matched your search

+2 -2
View File
@@ -53,7 +53,7 @@ jobs:
run: | run: |
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}" $env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}" $env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DISCORD_BLITZ_DB = "${{ secrets.DISCORD_BLITZ_DB }}" $env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
$env:WOTB_DLC_CDN = "${{ secrets.WOTB_DLC_CDN }}" $env:WOTB_DLC_CDN = "${{ secrets.WOTB_DLC_CDN }}"
yarn setup:patches --depot yarn setup:patches --depot
@@ -61,5 +61,5 @@ jobs:
run: | run: |
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}" $env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}" $env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DISCORD_BLITZ_DB = "${{ secrets.DISCORD_BLITZ_DB }}" $env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }} yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }}
+1 -1
View File
@@ -33,4 +33,4 @@ jobs:
unzip bot.zip unzip bot.zip
- name: Run bot - name: Run bot
run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} node --enable-source-maps index.cjs run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} node --enable-source-maps index.cjs
+1 -1
View File
@@ -31,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingleaderboard --region=asia --target=midnight --production run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=asia --target=midnight --production
+1 -1
View File
@@ -31,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingleaderboard --region=eu --target=midnight --production run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=eu --target=midnight --production
+2 -2
View File
@@ -1,4 +1,4 @@
name: CD Rating North America name: CD Rating Latest
on: on:
schedule: schedule:
@@ -31,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate all leaderboards - name: Generate all leaderboards
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingleaderboard --region=na,asia,eu --target=latest --production run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=na,asia,eu --target=latest --production
+1 -1
View File
@@ -31,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingleaderboard --region=na --target=midnight --production run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=na --target=midnight --production
+1 -4
View File
@@ -33,11 +33,8 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: yarn run: yarn
- name: Add fixed sharp
run: yarn add [email protected] --ignore-engines
- name: Build - name: Build
run: yarn build:bot run: yarn build:bot --production
- name: Archive build - name: Archive build
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+2
View File
@@ -125,6 +125,7 @@
"Synx", "Synx",
"tankaverages", "tankaverages",
"Tankerch", "Tankerch",
"tankmen",
"tankopedia", "tankopedia",
"tankopedias", "tankopedias",
"tankstats", "tankstats",
@@ -143,6 +144,7 @@
"Vovko", "Vovko",
"wargaming", "wargaming",
"webp", "webp",
"whitespaces",
"winrate", "winrate",
"wireframe", "wireframe",
"WOTB", "WOTB",
+11
View File
@@ -0,0 +1,11 @@
# Blitzkrieg 1.18.1
HE gets more accurate and say goodbye to NaNaNaNaNaN.
## Fixes
- Armor highlighting now works on Apple MacBooks
- Applied equipments now persist after switching shells
- The bottom of the tank sandbox panel can now be interacted with as it was previously blocked by an invisible layer
- `/rating` now uses the midnight leaderboard instead of the absolute latest one
- 0-battle stats are now hidden
+7
View File
@@ -0,0 +1,7 @@
# Blitzkrieg 1.18.2
Redefining how micro a micro-path is.
## Fixes
- The pitch and yaw controls, along with the quick equipment buttons, can once again be interacted with
+29
View File
@@ -0,0 +1,29 @@
# Blitzkrieg 1.18.3
Making Tankopedia more pleasing to use. This update only affects the website.
## Features
- Added crew skills
- Added quick actions
- For example, crew skills have the "clear" and "maximize" actions and equipment simply has a "clear" action
- Added info buttons
- Beside the titles of a few sections, a new info buttons have been added for extended descriptions
- Tank configurations have been condensed thanks to a new layout that works well on most screen sizes
- The Tankopedia now takes advantage of wider screens than before; hence, getting rid of the useless whitespaces on the sides
- Tank modules are now neatly wrapped into dropdown menus
- Sorting tanks by "size" has been renamed to "volume" and length has been added as a new option
- The antagonist (the shooting tank) bar has been shrunk to only fit the its contents
- Fullscreen mode now features all the missing dropdowns and options that were previously hidden
- Added icons for donations on Patreon and Ko-fi in the navigation bar
- Tank search now uses regular links allowing for things like opening in new tabs, copying links, etc.
## Fixes
- Armor highlighting once again works properly even after switching around tank modules
- Inputting invalid numbers into the pitch and yaw fields is now handled correctly
- Removed unnecessary tooltips that didn't show up anyway due to Radix UI 3
## Technical Changes
- Upgraded to Radix UI 3
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "blitzkrieg", "name": "blitzkrieg",
"version": "1.18.0", "version": "1.18.3",
"description": "🎉 Tools for everything World of Tanks Blitz", "description": "🎉 Tools for everything World of Tanks Blitz",
"main": "src/server.ts", "main": "src/server.ts",
"type": "module", "type": "module",
@@ -11,7 +11,7 @@
"build:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard", "build:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard",
"build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index", "build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index",
"build:test": "vite-node --script scripts/buildTest", "build:test": "vite-node --script scripts/buildTest",
"dev:bot": "vite-node --script scripts/build --production && yarn start:bot", "dev:bot": "vite-node --script scripts/build && yarn start:bot",
"dev:docs": "vitepress dev docs", "dev:docs": "vitepress dev docs",
"dev:website": "next dev", "dev:website": "next dev",
"start:bot": "node --enable-source-maps dist/bot/index.cjs", "start:bot": "node --enable-source-maps dist/bot/index.cjs",
@@ -36,7 +36,7 @@
"@prisma/client": "^5.4.1", "@prisma/client": "^5.4.1",
"@radix-ui/colors": "^3.0.0", "@radix-ui/colors": "^3.0.0",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/themes": "^2.0.1", "@radix-ui/themes": "^3.0.1",
"@react-three/drei": "^9.93.0", "@react-three/drei": "^9.93.0",
"@react-three/fiber": "^8.15.14", "@react-three/fiber": "^8.15.14",
"@resvg/resvg-js": "^2.4.1", "@resvg/resvg-js": "^2.4.1",
@@ -1,8 +0,0 @@
-- CreateTable
CREATE TABLE "discord_blitz" (
"discord" INT8 NOT NULL,
"region" STRING NOT NULL,
"blitz" INT4 NOT NULL,
CONSTRAINT "discord_blitz_pkey" PRIMARY KEY ("discord")
);
-3
View File
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "cockroachdb"
+7 -3
View File
@@ -4,11 +4,15 @@ generator client {
datasource db { datasource db {
provider = "cockroachdb" provider = "cockroachdb"
url = env("DISCORD_BLITZ_DB") url = env("DATABASE_URL")
} }
model discord_blitz { model Users {
blitz Int @id
last_used DateTime? @default(now())
}
model DiscordUsers {
discord BigInt @id discord BigInt @id
region String
blitz Int blitz Int
} }
+1
View File
@@ -0,0 +1 @@
google.com, pub-1289406790225052, DIRECT, f08c47fec0942fa0
+8 -4
View File
@@ -2,10 +2,12 @@ import { exec } from 'child_process';
import { build } from 'esbuild'; import { build } from 'esbuild';
import { cp, readdir } from 'fs/promises'; import { cp, readdir } from 'fs/promises';
import { argv } from 'process'; import { argv } from 'process';
import { promisify } from 'util';
const execPromise = promisify(exec);
const isProduction = argv.includes('--production'); const isProduction = argv.includes('--production');
readdir('src/workers').then((workers) => { await readdir('src/workers').then((workers) => {
const files = [ const files = [
'bot', 'bot',
'index', 'index',
@@ -41,8 +43,8 @@ readdir('src/workers').then((workers) => {
}); });
// copy schema.prisma // copy schema.prisma
cp('prisma/schema.prisma', 'dist/bot/schema.prisma'); await cp('prisma/schema.prisma', 'dist/bot/schema.prisma');
readdir('node_modules/prisma').then((files) => await readdir('node_modules/prisma').then((files) =>
files files
.filter((file) => file.endsWith('.node')) .filter((file) => file.endsWith('.node'))
.map((file) => cp(`node_modules/prisma/${file}`, `dist/bot/${file}`)), .map((file) => cp(`node_modules/prisma/${file}`, `dist/bot/${file}`)),
@@ -51,7 +53,9 @@ readdir('node_modules/prisma').then((files) =>
if (isProduction) { if (isProduction) {
// install fixed sharp // install fixed sharp
console.log('Installing fixed sharp...'); console.log('Installing fixed sharp...');
exec('yarn init -y && yarn add [email protected] --ignore-engines', { await execPromise('yarn init -y && yarn add [email protected] --ignore-engines', {
cwd: 'dist/bot', cwd: 'dist/bot',
}); });
} }
console.log('Generated:', await readdir('dist/bot'));
+20 -5
View File
@@ -1,4 +1,4 @@
import { readdir, writeFile } from 'fs/promises'; import { readdir } from 'fs/promises';
import { parse as parsePath } from 'path'; import { parse as parsePath } from 'path';
import { Vector3Tuple } from 'three'; import { Vector3Tuple } from 'three';
import { parse as parseYaml } from 'yaml'; import { parse as parseYaml } from 'yaml';
@@ -21,6 +21,7 @@ import {
ModelDefinitions, ModelDefinitions,
} from '../../src/core/blitzkrieg/modelDefinitions'; } from '../../src/core/blitzkrieg/modelDefinitions';
import { ProvisionDefinitions } from '../../src/core/blitzkrieg/provisionDefinitions'; import { ProvisionDefinitions } from '../../src/core/blitzkrieg/provisionDefinitions';
import { SkillDefinitions } from '../../src/core/blitzkrieg/skillDefinitions';
import { superCompress } from '../../src/core/blitzkrieg/superCompress'; import { superCompress } from '../../src/core/blitzkrieg/superCompress';
import { import {
Crew, Crew,
@@ -32,6 +33,7 @@ import {
Tier, Tier,
} from '../../src/core/blitzkrieg/tankDefinitions'; } from '../../src/core/blitzkrieg/tankDefinitions';
import { DATA, POI } from './constants'; import { DATA, POI } from './constants';
import { Avatar } from './skillIcons';
import { TankParameters } from './tankIcons'; import { TankParameters } from './tankIcons';
interface Strings { interface Strings {
@@ -116,6 +118,7 @@ interface VehicleDefinitions {
weight: number; weight: number;
terrainResistance: string; terrainResistance: string;
rotationSpeed: number; rotationSpeed: number;
userString: string;
shotDispersionFactors: { shotDispersionFactors: {
vehicleMovement: number; vehicleMovement: number;
vehicleRotation: number; vehicleRotation: number;
@@ -341,6 +344,9 @@ export async function definitions(production: boolean) {
}; };
const consumableDefinitions: ConsumableDefinitions = {}; const consumableDefinitions: ConsumableDefinitions = {};
const provisionDefinitions: ProvisionDefinitions = {}; const provisionDefinitions: ProvisionDefinitions = {};
const skillDefinitions: SkillDefinitions = {
classes: { lightTank: [], mediumTank: [], heavyTank: [], 'AT-SPG': [] },
};
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then( const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then(
(nations) => nations.filter((nation) => nation !== 'common'), (nations) => nations.filter((nation) => nation !== 'common'),
); );
@@ -367,6 +373,9 @@ export async function definitions(production: boolean) {
const provisions = await readXMLDVPL<{ root: ProvisionsCommon }>( const provisions = await readXMLDVPL<{ root: ProvisionsCommon }>(
`${DATA}/${POI.provisionsCommon}.dvpl`, `${DATA}/${POI.provisionsCommon}.dvpl`,
); );
const avatar = await readXMLDVPL<{ root: Avatar }>(
`${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`,
);
const tankXps = new Map<number, number>(); const tankXps = new Map<number, number>();
@@ -558,6 +567,7 @@ export async function definitions(production: boolean) {
tankDefinitions[tankId].tracks.push({ tankDefinitions[tankId].tracks.push({
id: trackId, id: trackId,
weight: track.weight, weight: track.weight,
name: strings[track.userString],
traverseSpeed: track.rotationSpeed, traverseSpeed: track.rotationSpeed,
dispersion: { dispersion: {
move: track.shotDispersionFactors.vehicleMovement, move: track.shotDispersionFactors.vehicleMovement,
@@ -1084,10 +1094,10 @@ export async function definitions(production: boolean) {
} }
}); });
writeFile( Object.entries(avatar.root.skillsByClasses).forEach(([tankClass, skills]) => {
'test.tanks.cdon.lz4', skillDefinitions.classes[tankClass as TankClass] = skills.split(' ');
Buffer.from(superCompress(tankDefinitions), 'base64'), });
);
await commitAssets( await commitAssets(
'definitions', 'definitions',
[ [
@@ -1116,6 +1126,11 @@ export async function definitions(production: boolean) {
encoding: 'base64', encoding: 'base64',
path: 'definitions/provisions.cdon.lz4', path: 'definitions/provisions.cdon.lz4',
}, },
{
content: superCompress(skillDefinitions),
encoding: 'base64',
path: 'definitions/skills.cdon.lz4',
},
], ],
production, production,
); );
+2
View File
@@ -20,6 +20,7 @@ import { equipmentIcons } from './equipmentIcons';
import { moduleIcons } from './moduleIcons'; import { moduleIcons } from './moduleIcons';
import { scratchedFlags } from './scratchedFlags'; import { scratchedFlags } from './scratchedFlags';
import { shellIcons } from './shellIcons'; import { shellIcons } from './shellIcons';
import { skillIcons } from './skillIcons';
import { tankArmors } from './tankArmors'; import { tankArmors } from './tankArmors';
import { tankIcons } from './tankIcons'; import { tankIcons } from './tankIcons';
import { tankModels } from './tankModels'; import { tankModels } from './tankModels';
@@ -40,6 +41,7 @@ const methods = [
tankArmors, tankArmors,
tankModels, tankModels,
currencies, currencies,
skillIcons,
]; ];
for (const method of methods) { for (const method of methods) {
+51
View File
@@ -0,0 +1,51 @@
import sharp from 'sharp';
import { TankClass } from '../../src/components/Tanks';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
import { DATA } from './constants';
interface SkillIcon {
name: string;
state: number;
}
export interface Avatar {
roles: unknown;
skillsByClasses: Record<TankClass, string>;
skills: {
[name: string]: {
userString: string;
effectDescription: string;
tipDescription: string;
icon: SkillIcon | SkillIcon[];
type: 'continuous' | 'trigger';
};
};
}
export async function skillIcons(production: boolean) {
console.log('Building skill icons...');
const avatar = await readXMLDVPL<{ root: Avatar }>(
`${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`,
);
const changes = await Promise.all(
Object.values(avatar.root.skills).map(async (skill) => {
const icon = Array.isArray(skill.icon) ? skill.icon[0] : skill.icon;
const name = icon.name.split('/').at(-1)!.replace(/_\d$/, '');
const path = `${DATA}${icon.name.replace('~res:', '')}.packed.webp.dvpl`;
const image = sharp(await readDVPLFile(path)).trim();
const content = (await image.toBuffer()).toString('base64');
return {
content,
encoding: 'base64',
path: `icons/skills/${name}.webp`,
} satisfies FileChange;
}),
);
commitAssets('skill icons', changes, production);
}
+4 -2
View File
@@ -13,6 +13,7 @@ import Navbar from '../components/Navbar';
import PageWrapper from '../components/PageWrapper'; import PageWrapper from '../components/PageWrapper';
import isDev from '../core/blitzkrieg/isDev'; import isDev from '../core/blitzkrieg/isDev';
import { isLocalhost } from '../core/blitzkrieg/isLocalhost'; import { isLocalhost } from '../core/blitzkrieg/isLocalhost';
import { useFullScreen } from '../hooks/useFullScreen';
import { useApp } from '../stores/app'; import { useApp } from '../stores/app';
import { mintTheme } from '../themes/mint'; import { mintTheme } from '../themes/mint';
@@ -33,6 +34,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
const pathname = usePathname(); const pathname = usePathname();
const isEmbed = pathname.split('/')[1] === 'embeds'; const isEmbed = pathname.split('/')[1] === 'embeds';
const [showDevBuildAlert, setShowDevBuildAlert] = useState(false); const [showDevBuildAlert, setShowDevBuildAlert] = useState(false);
const isFullScreen = useFullScreen();
useEffect(() => { useEffect(() => {
setShowDevBuildAlert( setShowDevBuildAlert(
@@ -100,11 +102,11 @@ export default function RootLayout({ children }: RootLayoutProps) {
<Flex direction="column">{children}</Flex> <Flex direction="column">{children}</Flex>
{!isEmbed && ( {!isEmbed && !isFullScreen && (
<PageWrapper> <PageWrapper>
<a <a
target="_blank" target="_blank"
href="https://ko-fi.com/tresabhi" href="https://www.patreon.com/tresabhi"
style={{ textDecoration: 'none' }} style={{ textDecoration: 'none' }}
> >
<Flex <Flex
+1 -2
View File
@@ -15,11 +15,10 @@ export default function Page() {
return ( return (
<PageWrapper> <PageWrapper>
<TextField.Root> <TextField.Root placeholder="Search tools...">
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot> </TextField.Slot>
<TextField.Input ref={input} placeholder="Search tools..." />
</TextField.Root> </TextField.Root>
<Flex gap="3" wrap="wrap" align="center" justify="center"> <Flex gap="3" wrap="wrap" align="center" justify="center">
@@ -53,29 +53,26 @@ export function PageTurner({
<CaretLeftIcon /> <CaretLeftIcon />
)} )}
</Button> </Button>
<TextField.Root>
<TextField.Root
type="number"
ref={pageInput}
style={{ textAlign: 'center' }}
min={1}
max={pages}
onBlur={(event) => {
onPageChange(
Math.max(0, Math.min(pages - 1, event.target.valueAsNumber - 1)),
false,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
>
<TextField.Slot>Page</TextField.Slot> <TextField.Slot>Page</TextField.Slot>
<TextField.Input
type="number"
ref={pageInput}
style={{ width: 64, textAlign: 'center' }}
min={1}
max={pages}
onBlur={(event) => {
onPageChange(
Math.max(
0,
Math.min(pages - 1, event.target.valueAsNumber - 1),
),
false,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
/>
<TextField.Slot>out of {pages}</TextField.Slot> <TextField.Slot>out of {pages}</TextField.Slot>
</TextField.Root> </TextField.Root>
<Button <Button
+93 -98
View File
@@ -605,7 +605,7 @@ export default function Page() {
<Dialog.Content> <Dialog.Content>
<Flex gap="4" justify="center"> <Flex gap="4" justify="center">
<TextField.Input <TextField.Root
ref={scoreInput} ref={scoreInput}
type="number" type="number"
placeholder="Type a score..." placeholder="Type a score..."
@@ -638,7 +638,7 @@ export default function Page() {
<Dialog.Content> <Dialog.Content>
<Flex gap="4" justify="center"> <Flex gap="4" justify="center">
<TextField.Input <TextField.Root
ref={positionInput} ref={positionInput}
type="number" type="number"
placeholder="Type a position..." placeholder="Type a position..."
@@ -759,105 +759,100 @@ export default function Page() {
<Dialog.Content> <Dialog.Content>
<Flex gap="4" direction="column"> <Flex gap="4" direction="column">
<TextField.Root> <TextField.Root
onChange={debounce(
async (event: ChangeEvent<HTMLInputElement>) => {
const trimmedSearch = event.target.value.trim();
const encodedSearch = encodeURIComponent(trimmedSearch);
if (trimmedSearch) {
if (season === 0) {
setSearchLoading(true);
const accountList = await searchCurrentRatingPlayers(
region,
trimmedSearch,
);
const values = Object.values(accountList).filter(
(searchedPlayer) => !searchedPlayer.skip,
);
const valuesWithTypes =
values as ((typeof values)[number] & {
skip: false;
})[];
useLeaderboardCache.setState(
produce((draft: LeaderboardCache) => {
if (draft) {
values.forEach((player) => {
const playerWithTypes =
player as typeof player & { skip: false };
draft[region][season][
playerWithTypes.number - 1
] = playerWithTypes.spa_id;
});
}
}),
);
useNameCache.setState(
produce((draft: typeof names) => {
valuesWithTypes.forEach((searchedPlayer) => {
if (searchedPlayer.nickname) {
draft[region][searchedPlayer.spa_id] = {
nickname: searchedPlayer.nickname,
clan: searchedPlayer.clan_tag,
};
}
});
}),
);
setSearchResults(
valuesWithTypes.map((searchedPlayer) => ({
account_id: searchedPlayer.spa_id,
nickname: searchedPlayer.nickname,
number: searchedPlayer.number,
})),
);
setSearchLoading(false);
} else {
setSearchLoading(true);
const accountList = await fetchBlitz<AccountList>(
`https://api.wotblitz.${region}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}`,
);
const playerKeys = Object.keys(
leaderboard[region][season],
);
setSearchResults(
accountList
.map((searchedPlayer) => ({
...searchedPlayer,
number: parseInt(
playerKeys.find(
(indexAsString) =>
leaderboard[region][season][
parseInt(indexAsString)
] === searchedPlayer.account_id,
)!,
),
}))
.filter((player) => !Number.isNaN(player.number)),
);
setSearchLoading(false);
}
} else {
setSearchResults(undefined);
}
},
500,
)}
placeholder="Search for player..."
>
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot> </TextField.Slot>
<TextField.Input
onChange={debounce(
async (event: ChangeEvent<HTMLInputElement>) => {
const trimmedSearch = event.target.value.trim();
const encodedSearch = encodeURIComponent(trimmedSearch);
if (trimmedSearch) {
if (season === 0) {
setSearchLoading(true);
const accountList =
await searchCurrentRatingPlayers(
region,
trimmedSearch,
);
const values = Object.values(accountList).filter(
(searchedPlayer) => !searchedPlayer.skip,
);
const valuesWithTypes =
values as ((typeof values)[number] & {
skip: false;
})[];
useLeaderboardCache.setState(
produce((draft: LeaderboardCache) => {
if (draft) {
values.forEach((player) => {
const playerWithTypes =
player as typeof player & { skip: false };
draft[region][season][
playerWithTypes.number - 1
] = playerWithTypes.spa_id;
});
}
}),
);
useNameCache.setState(
produce((draft: typeof names) => {
valuesWithTypes.forEach((searchedPlayer) => {
if (searchedPlayer.nickname) {
draft[region][searchedPlayer.spa_id] = {
nickname: searchedPlayer.nickname,
clan: searchedPlayer.clan_tag,
};
}
});
}),
);
setSearchResults(
valuesWithTypes.map((searchedPlayer) => ({
account_id: searchedPlayer.spa_id,
nickname: searchedPlayer.nickname,
number: searchedPlayer.number,
})),
);
setSearchLoading(false);
} else {
setSearchLoading(true);
const accountList = await fetchBlitz<AccountList>(
`https://api.wotblitz.${region}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}`,
);
const playerKeys = Object.keys(
leaderboard[region][season],
);
setSearchResults(
accountList
.map((searchedPlayer) => ({
...searchedPlayer,
number: parseInt(
playerKeys.find(
(indexAsString) =>
leaderboard[region][season][
parseInt(indexAsString)
] === searchedPlayer.account_id,
)!,
),
}))
.filter(
(player) => !Number.isNaN(player.number),
),
);
setSearchLoading(false);
}
} else {
setSearchResults(undefined);
}
},
500,
)}
placeholder="Search for player..."
/>
</TextField.Root> </TextField.Root>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
+12 -13
View File
@@ -37,19 +37,18 @@ export function Menu({ Builder, reset }: ContextMenuProps) {
<Heading size="4">Edit title</Heading> <Heading size="4">Edit title</Heading>
<Flex gap="2" style={{ width: '100%' }}> <Flex gap="2" style={{ width: '100%' }}>
<TextField.Root style={{ flex: 1 }}> <TextField.Root
<TextField.Input style={{ flex: 1 }}
ref={titleInput} ref={titleInput}
defaultValue={session.title} defaultValue={session.title}
placeholder="Enter title..." placeholder="Enter title..."
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' && titleInput.current) { if (event.key === 'Enter' && titleInput.current) {
setShowTitleEditor(false); setShowTitleEditor(false);
useSession.setState({ title: titleInput.current!.value }); useSession.setState({ title: titleInput.current!.value });
} }
}} }}
/> />
</TextField.Root>
<Dialog.Close> <Dialog.Close>
<Button <Button
+27 -29
View File
@@ -56,38 +56,36 @@ export default function Page() {
<PageWrapper color="blue"> <PageWrapper color="blue">
<div className={styles.toolBar}> <div className={styles.toolBar}>
<div style={{ flex: 1, boxSizing: 'border-box', position: 'relative' }}> <div style={{ flex: 1, boxSizing: 'border-box', position: 'relative' }}>
<TextField.Root> <TextField.Root
defaultValue={session.isTracking ? session.nickname : undefined}
ref={input}
onChange={(event) => {
event.stopPropagation();
if (event.target.value) {
setShowSearchResults(true);
setSearchResults(undefined);
} else {
setShowSearchResults(false);
}
handleChange(event);
}}
onBlur={(event) => {
// TODO: remove this hack when https://github.com/radix-ui/primitives/issues/2193 (https://github.com/radix-ui/primitives/issues/1969) is fixed
if (showSearchResults) event.target.focus();
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setShowSearchResults(false);
input.current?.blur();
}
}}
placeholder="Search for a player..."
>
<TextField.Slot> <TextField.Slot>
<PersonIcon height="16" width="16" /> <PersonIcon height="16" width="16" />
</TextField.Slot> </TextField.Slot>
<TextField.Input
defaultValue={session.isTracking ? session.nickname : undefined}
ref={input}
onChange={(event) => {
event.stopPropagation();
if (event.target.value) {
setShowSearchResults(true);
setSearchResults(undefined);
} else {
setShowSearchResults(false);
}
handleChange(event);
}}
onBlur={(event) => {
// TODO: remove this hack when https://github.com/radix-ui/primitives/issues/2193 is fixed
if (showSearchResults) event.target.focus();
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setShowSearchResults(false);
input.current?.blur();
}
}}
placeholder="Search for a player..."
/>
</TextField.Root> </TextField.Root>
<DropdownMenu.Root open={showSearchResults} modal={false}> <DropdownMenu.Root open={showSearchResults} modal={false}>
@@ -1,15 +1,5 @@
import { CaretRightIcon, Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons'; import { Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons';
import { import { Button, Card, Dialog, Flex, Heading, Tabs } from '@radix-ui/themes';
Button,
Card,
Dialog,
Flex,
Heading,
Tabs,
Text,
Tooltip,
} from '@radix-ui/themes';
import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { ModuleButton } from '../../../../../components/ModuleButtons/ModuleButton'; import { ModuleButton } from '../../../../../components/ModuleButtons/ModuleButton';
import { ShellButton } from '../../../../../components/ModuleButtons/ShellButton'; import { ShellButton } from '../../../../../components/ModuleButtons/ShellButton';
@@ -37,58 +27,70 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
const [tab, setTab] = useState('search'); const [tab, setTab] = useState('search');
const [antagonistSelectorOpen, setAntagonistSelectorVisible] = const [antagonistSelectorOpen, setAntagonistSelectorVisible] =
useState(false); useState(false);
const router = useRouter();
if (mode !== 'armor') return null; if (mode !== 'armor') return null;
return ( return (
<Card <Flex justify="center">
style={ <Card
floating style={
? { floating
position: 'absolute', ? {
bottom: 8, position: 'absolute',
left: '50%', bottom: 8,
transform: 'translateX(-50%)', left: '50%',
} transform: 'translateX(-50%)',
: undefined }
} : undefined
> }
<Flex align="center" justify="between" gap="6"> >
<Flex> <Flex
{antagonist.gun.shells.map((shell, index) => { align="center"
return ( justify="center"
<ShellButton wrap="wrap"
selected={antagonist.shell.id === shell.id} style={{
shell={shell.icon} gap: 'min(5vw, 32px)',
rowChild }}
first={index === 0} >
key={shell.id} <Flex>
last={index === antagonist.gun.shells.length - 1} {antagonist.gun.shells.map((shell, index) => {
onClick={() => { return (
mutateDuel((draft) => { <ShellButton
draft.antagonist!.shell = shell; selected={antagonist.shell.id === shell.id}
mutateTankopediaTemporary((draft) => { shell={shell.icon}
draft.shot = undefined; rowChild
first={index === 0}
key={shell.id}
last={index === antagonist.gun.shells.length - 1}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.shell = shell;
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}); });
}); }}
}} />
/> );
); })}
})} </Flex>
</Flex>
<Flex align="center" gap="4"> <Button
{!floating && <Text>Versus</Text>} variant="ghost"
onClick={() => {
mutateDuel((draft) => {
[draft.protagonist, draft.antagonist] = [
draft.antagonist,
draft.protagonist,
];
pushTankopediaPath(draft.antagonist!.tank.id);
});
}}
>
<ShuffleIcon /> Swap
</Button>
{floating && ( <Flex align="center" gap="4">
<Flex gap="2" align="center">
Versus {antagonist.tank.name}
<SmallTankIcon id={antagonist.tank.id} size={16} />
</Flex>
)}
{!floating && (
<Dialog.Root <Dialog.Root
open={antagonistSelectorOpen} open={antagonistSelectorOpen}
onOpenChange={setAntagonistSelectorVisible} onOpenChange={setAntagonistSelectorVisible}
@@ -96,9 +98,8 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
<Dialog.Trigger> <Dialog.Trigger>
<Button variant="ghost"> <Button variant="ghost">
<Flex gap="2" align="center"> <Flex gap="2" align="center">
{antagonist.tank.name} Versus {antagonist.tank.name}
<SmallTankIcon id={antagonist.tank.id} size={16} /> <SmallTankIcon id={antagonist.tank.id} size={16} />
<CaretRightIcon />
</Flex> </Flex>
</Button> </Button>
</Dialog.Trigger> </Dialog.Trigger>
@@ -160,61 +161,51 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
<Flex gap="2" wrap="wrap"> <Flex gap="2" wrap="wrap">
<Flex> <Flex>
{antagonist.tank.turrets.map((turret, index) => ( {antagonist.tank.turrets.map((turret, index) => (
<Tooltip content={turret.name} key={turret.id}> <ModuleButton
<ModuleButton rowChild
rowChild first={index === 0}
first={index === 0} last={
last={ index === antagonist.tank.turrets.length - 1
index === }
antagonist.tank.turrets.length - 1 key={turret.id}
} onClick={() => {
key={turret.id} mutateDuel((draft) => {
onClick={() => { draft.antagonist!.turret = turret;
mutateDuel((draft) => { draft.antagonist!.gun =
draft.antagonist!.turret = turret; turret.guns.at(-1)!;
draft.antagonist!.gun = draft.antagonist!.shell =
turret.guns.at(-1)!; draft.antagonist!.gun.shells[0];
draft.antagonist!.shell = });
draft.antagonist!.gun.shells[0]; mutateTankopediaTemporary((draft) => {
}); draft.shot = undefined;
mutateTankopediaTemporary((draft) => { });
draft.shot = undefined; }}
}); selected={antagonist.turret.id === turret.id}
}} discriminator={
selected={ TIER_ROMAN_NUMERALS[turret.tier]
antagonist.turret.id === turret.id }
} module="turret"
discriminator={ />
TIER_ROMAN_NUMERALS[turret.tier]
}
module="turret"
/>
</Tooltip>
))} ))}
</Flex> </Flex>
<Flex> <Flex>
{antagonist.turret.guns.map((gun, index) => ( {antagonist.turret.guns.map((gun, index) => (
<Tooltip content={gun.name} key={gun.id}> <ModuleButton
<ModuleButton rowChild
rowChild first={index === 0}
first={index === 0} last={
last={ index === antagonist.turret.guns.length - 1
index === }
antagonist.turret.guns.length - 1 onClick={() => {
} mutateDuel((draft) => {
onClick={() => { draft.antagonist!.gun = gun;
mutateDuel((draft) => { draft.antagonist!.shell = gun.shells[0];
draft.antagonist!.gun = gun; });
draft.antagonist!.shell = gun.shells[0]; }}
}); selected={antagonist.gun.id === gun.id}
}} discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
selected={antagonist.gun.id === gun.id} module="gun"
discriminator={ />
TIER_ROMAN_NUMERALS[gun.tier]
}
module="gun"
/>
</Tooltip>
))} ))}
</Flex> </Flex>
</Flex> </Flex>
@@ -251,24 +242,9 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
</Tabs.Root> </Tabs.Root>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
)} </Flex>
</Flex> </Flex>
</Card>
<Button </Flex>
variant="ghost"
onClick={() => {
mutateDuel((draft) => {
[draft.protagonist, draft.antagonist] = [
draft.antagonist,
draft.protagonist,
];
pushTankopediaPath(draft.antagonist!.tank.id);
});
}}
>
<ShuffleIcon /> Swap
</Button>
</Flex>
</Card>
); );
} }
@@ -1,5 +1,5 @@
import { Flex } from '@radix-ui/themes';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import * as styles from '../../../../page.css';
interface ConfigurationChildWrapperProps { interface ConfigurationChildWrapperProps {
children: ReactNode; children: ReactNode;
@@ -8,5 +8,9 @@ interface ConfigurationChildWrapperProps {
export function ConfigurationChildWrapper({ export function ConfigurationChildWrapper({
children, children,
}: ConfigurationChildWrapperProps) { }: ConfigurationChildWrapperProps) {
return <div className={styles.configurationChild}>{children}</div>; return (
<Flex gap="2" direction="column">
{children}
</Flex>
);
} }
@@ -1,4 +1,12 @@
import { Flex, Heading } from '@radix-ui/themes'; import { InfoCircledIcon } from '@radix-ui/react-icons';
import {
Button,
Flex,
Heading,
IconButton,
Popover,
Text,
} from '@radix-ui/themes';
import { use } from 'react'; import { use } from 'react';
import { ConsumableButton } from '../../../../../../../components/ModuleButtons/ConsumableButton'; import { ConsumableButton } from '../../../../../../../components/ModuleButtons/ConsumableButton';
import { checkConsumableProvisionInclusivity } from '../../../../../../../core/blitzkrieg/checkConsumableProvisionInclusivity'; import { checkConsumableProvisionInclusivity } from '../../../../../../../core/blitzkrieg/checkConsumableProvisionInclusivity';
@@ -27,7 +35,38 @@ export function Consumables() {
return ( return (
<ConfigurationChildWrapper> <ConfigurationChildWrapper>
<Heading size="4">Consumables</Heading> <Flex gap="4" align="center">
<Flex gap="2" align="center">
<Heading size="4">Consumables</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Text>
<Text color="amber">Yellow consumables</Text> do not affect the
statistics.
</Text>
</Popover.Content>
</Popover.Root>
</Flex>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.consumables = [];
});
}}
>
Clear
</Button>
</Flex>
<Flex wrap="wrap"> <Flex wrap="wrap">
{consumablesList.map((consumable, index) => { {consumablesList.map((consumable, index) => {
@@ -35,10 +74,11 @@ export function Consumables() {
return ( return (
<ConsumableButton <ConsumableButton
special={consumable.duration === undefined}
duration={ duration={
typeof consumable.duration === 'number' consumable.duration === undefined
? consumable.duration * (hasHighEndConsumables ? 0.7 : 1) ? undefined
: undefined : consumable.duration * (hasHighEndConsumables ? 0.7 : 1)
} }
cooldown={ cooldown={
consumable.cooldown * consumable.cooldown *
@@ -1,11 +1,11 @@
import { Flex, Heading } from '@radix-ui/themes'; import { Button, Flex, Heading } from '@radix-ui/themes';
import { use } from 'react'; import { use } from 'react';
import { EquipmentButton } from '../../../../../../../components/ModuleButtons/EquipmentButton'; import { EquipmentButton } from '../../../../../../../components/ModuleButtons/EquipmentButton';
import { equipmentDefinitions } from '../../../../../../../core/blitzkrieg/equipmentDefinitions'; import { equipmentDefinitions } from '../../../../../../../core/blitzkrieg/equipmentDefinitions';
import { mutateDuel, useDuel } from '../../../../../../../stores/duel'; import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper'; import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Equipments() { export function Equipment() {
const protagonist = useDuel((state) => state.protagonist!); const protagonist = useDuel((state) => state.protagonist!);
const awaitedEquipmentDefinitions = use(equipmentDefinitions); const awaitedEquipmentDefinitions = use(equipmentDefinitions);
const equipmentRows = const equipmentRows =
@@ -14,7 +14,24 @@ export function Equipments() {
return ( return (
<ConfigurationChildWrapper> <ConfigurationChildWrapper>
<Heading size="4">Equipments</Heading> <Flex gap="4" align="center">
<Heading size="4">Equipment</Heading>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.equipment.forEach((row) => {
row.forEach((_, index) => {
row[index] = 0;
});
});
});
}}
>
Clear
</Button>
</Flex>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
{equipmentRows.map((equipmentRow, rowIndex) => ( {equipmentRows.map((equipmentRow, rowIndex) => (
@@ -87,36 +87,35 @@ export function Miscellaneous() {
direction="column" direction="column"
gap="1" gap="1"
justify="end" justify="end"
style={{ width: 180 }} style={{ width: 120 }}
> >
<TextField.Root> <TextField.Root
ref={crewInput}
defaultValue={Math.round(crewMastery * 100)}
style={{ textAlign: 'right' }}
onBlur={() => {
const newValue = Number(crewInput.current!.value) / 100;
if (isNaN(newValue)) {
crewInput.current!.value = `${Math.round(crewMastery * 100)}`;
return;
}
mutateDuel((draft) => {
draft.protagonist!.crewMastery = clamp(
newValue / 100,
0.5,
1,
);
});
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
crewInput.current?.blur();
}
}}
>
<TextField.Slot>Crew</TextField.Slot> <TextField.Slot>Crew</TextField.Slot>
<TextField.Input
ref={crewInput}
defaultValue={Math.round(crewMastery * 100)}
style={{ textAlign: 'right' }}
onBlur={() => {
const newValue = Number(crewInput.current!.value) / 100;
if (isNaN(newValue)) {
crewInput.current!.value = `${Math.round(crewMastery * 100)}`;
return;
}
mutateDuel((draft) => {
draft.protagonist!.crewMastery = clamp(
newValue / 100,
0.5,
1,
);
});
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
crewInput.current?.blur();
}
}}
/>
<TextField.Slot>%</TextField.Slot> <TextField.Slot>%</TextField.Slot>
</TextField.Root> </TextField.Root>
@@ -1,4 +1,5 @@
import { Flex, Heading } from '@radix-ui/themes'; import { Button, DropdownMenu, Flex, Heading } from '@radix-ui/themes';
import { useState } from 'react';
import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton'; import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton';
import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkrieg/tankDefinitions/constants'; import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkrieg/tankDefinitions/constants';
import { mutateDuel, useDuel } from '../../../../../../../stores/duel'; import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
@@ -7,101 +8,236 @@ import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Modules() { export function Modules() {
const protagonist = useDuel((state) => state.protagonist!); const protagonist = useDuel((state) => state.protagonist!);
const hasUpgrades =
protagonist.tank.turrets.length > 1 ||
protagonist.tank.turrets[0].guns.length > 1 ||
protagonist.tank.engines.length > 1 ||
protagonist.tank.tracks.length > 1;
const [turretMenuOpen, setTurretMenuOpen] = useState(false);
const [gunMenuOpen, setGunMenuOpen] = useState(false);
const [engineMenuOpen, setEngineMenuOpen] = useState(false);
const [trackMenuOpen, setTrackMenuOpen] = useState(false);
return ( return (
<ConfigurationChildWrapper> <ConfigurationChildWrapper>
<Heading size="4">Modules</Heading> <Flex gap="4" align="center">
<Heading size="4">Modules</Heading>
{hasUpgrades && (
<>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.turret =
draft.protagonist!.tank.turrets[0];
draft.protagonist!.gun = draft.protagonist!.turret.guns[0];
draft.protagonist!.shell = draft.protagonist!.gun.shells[0];
draft.protagonist!.engine =
draft.protagonist!.tank.engines[0];
draft.protagonist!.track = draft.protagonist!.tank.tracks[0];
});
}}
>
Stock
</Button>
<Button
variant="ghost"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.turret =
draft.protagonist!.tank.turrets.at(-1)!;
draft.protagonist!.gun =
draft.protagonist!.turret.guns.at(-1)!;
draft.protagonist!.shell =
draft.protagonist!.gun.shells.at(-1)!;
draft.protagonist!.engine =
draft.protagonist!.tank.engines.at(-1)!;
draft.protagonist!.track =
draft.protagonist!.tank.tracks.at(-1)!;
});
}}
>
Upgrade
</Button>
</>
)}
</Flex>
<Flex gap="2" wrap="wrap"> <Flex gap="1" wrap="wrap">
<Flex> <ModuleButton
{protagonist.tank.turrets.map((turret, index) => { module="turret"
return ( discriminator={TIER_ROMAN_NUMERALS[protagonist.turret.tier]}
<ModuleButton first
key={turret.id} last
selected={protagonist.turret.id === turret.id} rowChild
module="turret" onClick={() => setTurretMenuOpen(true)}
discriminator={TIER_ROMAN_NUMERALS[turret.tier]} />
first={index === 0}
last={index === protagonist.tank.turrets.length - 1}
rowChild
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.turret = turret;
draft.protagonist!.gun = turret.guns.at(-1)!;
draft.protagonist!.shell = turret.guns.at(-1)!.shells[0];
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
/>
);
})}
</Flex>
<Flex> <DropdownMenu.Root
{protagonist.turret.guns.map((gun, index) => { open={turretMenuOpen}
return ( onOpenChange={setTurretMenuOpen}
<ModuleButton >
key={gun.id} <DropdownMenu.Trigger>
module="gun" <div />
discriminator={TIER_ROMAN_NUMERALS[gun.tier]} </DropdownMenu.Trigger>
selected={protagonist.gun.id === gun.id}
first={index === 0}
last={index === protagonist.turret.guns.length - 1}
rowChild
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.gun = gun;
draft.protagonist!.shell = gun.shells[0];
});
}}
/>
);
})}
</Flex>
<Flex> <DropdownMenu.Content>
{protagonist.tank.engines.map((engine, index) => { <DropdownMenu.RadioGroup value={`${protagonist.turret.id}`}>
return ( {[...protagonist.tank.turrets].reverse().map((turret) => {
<ModuleButton return (
key={engine.id} <DropdownMenu.RadioItem
module="engine" key={turret.id}
discriminator={TIER_ROMAN_NUMERALS[engine.tier]} value={`${turret.id}`}
selected={protagonist.engine.id === engine.id} onClick={() => {
first={index === 0} mutateDuel((draft) => {
last={index === protagonist.tank.engines.length - 1} draft.protagonist!.turret = turret;
rowChild draft.protagonist!.gun = turret.guns.at(-1)!;
onClick={() => { draft.protagonist!.shell =
mutateDuel((draft) => { turret.guns.at(-1)!.shells[0];
draft.protagonist!.engine = engine; });
}); mutateTankopediaTemporary((draft) => {
}} draft.shot = undefined;
/> });
); setTurretMenuOpen(false);
})} }}
</Flex> >
<ModuleButton
variant="ghost"
module="turret"
discriminator={TIER_ROMAN_NUMERALS[turret.tier]}
/>{' '}
{turret.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Flex> <ModuleButton
{protagonist.tank.tracks.map((track, index) => { module="gun"
return ( discriminator={TIER_ROMAN_NUMERALS[protagonist.gun.tier]}
<ModuleButton first
key={track.id} last
module="chassis" rowChild
discriminator={TIER_ROMAN_NUMERALS[track.tier]} onClick={() => setGunMenuOpen(true)}
selected={protagonist.track.id === track.id} />
first={index === 0}
last={index === protagonist.tank.tracks.length - 1} <DropdownMenu.Root open={gunMenuOpen} onOpenChange={setGunMenuOpen}>
rowChild <DropdownMenu.Trigger>
onClick={() => { <div />
mutateDuel((draft) => { </DropdownMenu.Trigger>
draft.protagonist!.track = track;
}); <DropdownMenu.Content>
}} <DropdownMenu.RadioGroup value={`${protagonist.gun.id}`}>
/> {[...protagonist.turret.guns].reverse().map((gun) => {
); return (
})} <DropdownMenu.RadioItem
</Flex> key={gun.id}
value={`${gun.id}`}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.gun = gun;
draft.protagonist!.shell = gun.shells[0];
});
}}
>
<ModuleButton
variant="ghost"
module="gun"
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
/>{' '}
{gun.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<ModuleButton
module="engine"
discriminator={TIER_ROMAN_NUMERALS[protagonist.engine.tier]}
first
last
rowChild
onClick={() => setEngineMenuOpen(true)}
/>
<DropdownMenu.Root
open={engineMenuOpen}
onOpenChange={setEngineMenuOpen}
>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${protagonist.engine.id}`}>
{[...protagonist.tank.engines].reverse().map((engine) => {
return (
<DropdownMenu.RadioItem
key={engine.id}
value={`${engine.id}`}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.engine = engine;
});
}}
>
<ModuleButton
variant="ghost"
module="engine"
discriminator={TIER_ROMAN_NUMERALS[engine.tier]}
/>{' '}
{engine.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<ModuleButton
module="chassis"
discriminator={TIER_ROMAN_NUMERALS[protagonist.track.tier]}
first
last
rowChild
onClick={() => setTrackMenuOpen(true)}
/>
<DropdownMenu.Root open={trackMenuOpen} onOpenChange={setTrackMenuOpen}>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${protagonist.track.id}`}>
{[...protagonist.tank.tracks].reverse().map((track) => {
return (
<DropdownMenu.RadioItem
key={track.id}
value={`${track.id}`}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.track = track;
});
}}
>
<ModuleButton
variant="ghost"
module="chassis"
discriminator={TIER_ROMAN_NUMERALS[track.tier]}
/>{' '}
{track.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Flex> </Flex>
</ConfigurationChildWrapper> </ConfigurationChildWrapper>
); );
@@ -1,4 +1,4 @@
import { Flex, Heading } from '@radix-ui/themes'; import { Button, Flex, Heading } from '@radix-ui/themes';
import { use } from 'react'; import { use } from 'react';
import { ProvisionButton } from '../../../../../../../components/ModuleButtons/ProvisionButton'; import { ProvisionButton } from '../../../../../../../components/ModuleButtons/ProvisionButton';
import { availableProvisions } from '../../../../../../../core/blitzkrieg/availableProvisions'; import { availableProvisions } from '../../../../../../../core/blitzkrieg/availableProvisions';
@@ -18,7 +18,20 @@ export function Provisions() {
return ( return (
<ConfigurationChildWrapper> <ConfigurationChildWrapper>
<Heading size="4">Provisions</Heading> <Flex gap="4" align="center">
<Heading size="4">Provisions</Heading>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.provisions = [];
});
}}
>
Clear
</Button>
</Flex>
<Flex wrap="wrap"> <Flex wrap="wrap">
{provisionsList.map((provision, index) => { {provisionsList.map((provision, index) => {
@@ -0,0 +1,8 @@
export const permanentSkills = [
'smooth_turn',
'soft_recoil',
'smooth_driving',
'smooth_turret',
'virtuoso',
'camouflage',
];
@@ -0,0 +1,134 @@
import { InfoCircledIcon } from '@radix-ui/react-icons';
import {
Button,
Flex,
Heading,
IconButton,
Kbd,
Popover,
Text,
} from '@radix-ui/themes';
import { use } from 'react';
import { GenericTankComponentButton } from '../../../../../../../../components/ModuleButtons/GenericTankComponentButton';
import { asset } from '../../../../../../../../core/blitzkrieg/asset';
import { skillDefinitions } from '../../../../../../../../core/blitzkrieg/skillDefinitions';
import { Tier } from '../../../../../../../../core/blitzkrieg/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../../../core/blitzkrieg/tankDefinitions/constants';
import {
mutateTankopediaTemporary,
useTankopediaTemporary,
} from '../../../../../../../../stores/tankopedia';
import { ConfigurationChildWrapper } from '../ConfigurationChildWrapper';
import { permanentSkills } from './constants';
export function Skills() {
const awaitedSkillDefinitions = use(skillDefinitions);
const skillLevels = useTankopediaTemporary((state) => state.skills);
if (Object.keys(skillLevels).length === 0) {
mutateTankopediaTemporary((draft) => {
Object.values(awaitedSkillDefinitions.classes).forEach((skills) => {
skills.forEach((skill) => {
draft.skills[skill] = permanentSkills.includes(skill) ? 7 : 0;
});
});
});
return null;
}
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Flex gap="2" align="center">
<Heading size="4">Crew skills</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex direction="column" gap="2">
<Text>
<Text color="amber">Yellow skills</Text> only apply under
special circumstances.
</Text>
<Text>
Hold <Kbd>Shift</Kbd> to quickly toggle between level 0 and 7.
</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateTankopediaTemporary((draft) => {
Object.keys(draft.skills).forEach((skill) => {
draft.skills[skill] = 0;
});
});
}}
>
Clear
</Button>
<Button
variant="ghost"
onClick={() => {
mutateTankopediaTemporary((draft) => {
Object.keys(draft.skills).forEach((skill) => {
draft.skills[skill] = 7;
});
});
}}
>
Maximize
</Button>
</Flex>
<Flex direction="column" gap="2">
{Object.entries(awaitedSkillDefinitions.classes).map(
([tankClass, skills]) => (
<Flex gap="2" key={tankClass}>
{skills.map((skill) => {
const level = skillLevels[skill];
return (
<GenericTankComponentButton
special={!permanentSkills.includes(skill)}
first
last
selected={level > 0}
discriminator={
level === 0
? undefined
: TIER_ROMAN_NUMERALS[level as Tier]
}
icon={asset(`icons/skills/${skill}.webp`)}
onClick={(event) => {
mutateTankopediaTemporary((draft) => {
if (event.shiftKey) {
draft.skills[skill] =
draft.skills[skill] === 0 ? 7 : 0;
} else {
draft.skills[skill] = level - 1;
if (draft.skills[skill] < 0) draft.skills[skill] = 7;
}
});
}}
/>
);
})}
</Flex>
),
)}
</Flex>
</ConfigurationChildWrapper>
);
}
@@ -1,5 +1,13 @@
import { AccessibilityIcon } from '@radix-ui/react-icons'; import { AccessibilityIcon, InfoCircledIcon } from '@radix-ui/react-icons';
import { Flex, Heading, Slider, Text, TextField } from '@radix-ui/themes'; import {
Flex,
Heading,
IconButton,
Popover,
Slider,
Text,
TextField,
} from '@radix-ui/themes';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { use, useEffect, useRef, useState } from 'react'; import { use, useEffect, useRef, useState } from 'react';
import { lerp } from 'three/src/math/MathUtils'; import { lerp } from 'three/src/math/MathUtils';
@@ -22,17 +30,21 @@ import {
import { unionBoundingBox } from '../../../../../../core/blitzkrieg/unionBoundingBox'; import { unionBoundingBox } from '../../../../../../core/blitzkrieg/unionBoundingBox';
import { useConsumable } from '../../../../../../hooks/useConsumable'; import { useConsumable } from '../../../../../../hooks/useConsumable';
import { useEquipment } from '../../../../../../hooks/useEquipment'; import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { useProvision } from '../../../../../../hooks/useProvision'; import { useProvision } from '../../../../../../hooks/useProvision';
import { useProvisions } from '../../../../../../hooks/useProvisions'; import { useProvisions } from '../../../../../../hooks/useProvisions';
import { mutateDuel, useDuel } from '../../../../../../stores/duel'; import { mutateDuel, useDuel } from '../../../../../../stores/duel';
import { useTankopediaTemporary } from '../../../../../../stores/tankopedia';
import { Info } from './components/Info'; import { Info } from './components/Info';
import { InfoWithDelta } from './components/InfoWithDelta'; import { InfoWithDelta } from './components/InfoWithDelta';
export function Characteristics() { export function Characteristics() {
const awaitedModelDefinitions = use(modelDefinitions); const awaitedModelDefinitions = use(modelDefinitions);
const crewSkills = useTankopediaTemporary((state) => state.skills);
const { tank, turret, gun, engine, track, shell } = useDuel( const { tank, turret, gun, engine, track, shell } = useDuel(
(state) => state.protagonist!, (state) => state.protagonist!,
); );
const isFullScreen = useFullScreen();
const hasImprovedVentilation = useEquipment(102); const hasImprovedVentilation = useEquipment(102);
const provisions = useProvisions(); const provisions = useProvisions();
const crewMastery = useDuel((state) => state.protagonist!.crewMastery); const crewMastery = useDuel((state) => state.protagonist!.crewMastery);
@@ -132,10 +144,26 @@ export function Characteristics() {
const dispersionStillCoefficient = const dispersionStillCoefficient =
coefficient([hasRefinedGun, -0.1], [hasReticleCalibration, -0.4]) * coefficient([hasRefinedGun, -0.1], [hasReticleCalibration, -0.4]) *
coefficient([true, degressiveStat(crewMemberMastery)]); coefficient([true, degressiveStat(crewMemberMastery)]);
const dispersionMovingCoefficient = coefficient([ const dispersionDamagedCoefficient = coefficient([
hasVerticalStabilizer, hasVerticalStabilizer,
-0.15, -0.15,
]); ]);
const dispersionHullTraverseCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.smooth_turn / 100],
);
const dispersionAfterShotCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.soft_recoil / 100],
);
const dispersionMovingCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.smooth_driving / 100],
);
const dispersionTurretTraverseCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.smooth_turret * (2 / 100)],
);
const enginePowerCoefficient = coefficient( const enginePowerCoefficient = coefficient(
[ [
hasEngineAccelerator && hasEngineAccelerator &&
@@ -161,6 +189,7 @@ export function Characteristics() {
coefficient( coefficient(
[hasImprovedControl, 0.1], [hasImprovedControl, 0.1],
[hasImprovedEnginePowerBoost, 0.05], [hasImprovedEnginePowerBoost, 0.05],
[true, crewSkills.virtuoso / 100],
) * coefficient([true, progressiveStat(crewMemberMastery)]); ) * coefficient([true, progressiveStat(crewMemberMastery)]);
const resistanceCoefficient = coefficient([hasImprovedSuspension, -0.25]); const resistanceCoefficient = coefficient([hasImprovedSuspension, -0.25]);
const viewRangeCoefficient = const viewRangeCoefficient =
@@ -204,6 +233,7 @@ export function Characteristics() {
camouflage, camouflage,
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02, tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
], ],
[true, crewSkills.camouflage * (3 / 100)],
); );
const resolvedEnginePower = engine.power * enginePowerCoefficient; const resolvedEnginePower = engine.power * enginePowerCoefficient;
@@ -232,6 +262,8 @@ export function Characteristics() {
}); });
}, [hasImprovedVerticalStabilizer]); }, [hasImprovedVerticalStabilizer]);
if (isFullScreen) return null;
return ( return (
<Flex direction="column" gap="8" style={{ width: '100%' }}> <Flex direction="column" gap="8" style={{ width: '100%' }}>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
@@ -390,25 +422,25 @@ export function Characteristics() {
setPenetrationDistance(value); setPenetrationDistance(value);
}, 500)} }, 500)}
/> />
<TextField.Root style={{ width: 64 }}> <TextField.Root
<TextField.Input style={{ width: 64 }}
ref={penetrationDistanceInput} ref={penetrationDistanceInput}
defaultValue={penetrationDistance} defaultValue={penetrationDistance}
onBlur={() => { onBlur={() => {
setPenetrationDistance( setPenetrationDistance(
Math.min( Math.min(
parseInt(penetrationDistanceInput.current!.value), parseInt(penetrationDistanceInput.current!.value),
500, 500,
), ),
); );
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.currentTarget.blur(); event.currentTarget.blur();
} }
}} }}
/> >
<TextField.Slot>m</TextField.Slot> <TextField.Slot side="right">m</TextField.Slot>
</TextField.Root> </TextField.Root>
</Flex> </Flex>
</> </>
@@ -419,6 +451,11 @@ export function Characteristics() {
<InfoWithDelta name="Module damage" unit="hp" decimals={0}> <InfoWithDelta name="Module damage" unit="hp" decimals={0}>
{shell.damage.module * damageCoefficient} {shell.damage.module * damageCoefficient}
</InfoWithDelta> </InfoWithDelta>
{isExplosive(shell.type) && (
<InfoWithDelta name="Splash radius" unit="m" decimals={0}>
{shell.explosionRadius!}
</InfoWithDelta>
)}
<InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}> <InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}>
{shell.speed * shellVelocityCoefficient} {shell.speed * shellVelocityCoefficient}
</InfoWithDelta> </InfoWithDelta>
@@ -456,7 +493,7 @@ export function Characteristics() {
name="Hull traversing" name="Hull traversing"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{track.dispersion.traverse * dispersionMovingCoefficient} {track.dispersion.traverse * dispersionHullTraverseCoefficient}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={3} decimals={3}
@@ -465,7 +502,7 @@ export function Characteristics() {
name="Turret traversing" name="Turret traversing"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.traverse * dispersionMovingCoefficient} {gun.dispersion.traverse * dispersionTurretTraverseCoefficient}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={3} decimals={3}
@@ -474,7 +511,7 @@ export function Characteristics() {
name="After shooting" name="After shooting"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.shot * dispersionMovingCoefficient} {gun.dispersion.shot * dispersionAfterShotCoefficient}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={1} decimals={1}
@@ -484,7 +521,7 @@ export function Characteristics() {
unit="scalar" unit="scalar"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.damaged * dispersionMovingCoefficient} {gun.dispersion.damaged * dispersionDamagedCoefficient}
</InfoWithDelta> </InfoWithDelta>
<Info name="Gun flexibility" unit="°" /> <Info name="Gun flexibility" unit="°" />
<InfoWithDelta decimals={1} indent name="Depression"> <InfoWithDelta decimals={1} indent name="Depression">
@@ -632,7 +669,24 @@ export function Characteristics() {
</Flex> </Flex>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Heading size="5">Crew training</Heading> <Flex gap="2" align="center">
<Heading size="5">Crew training</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex gap="1" align="center">
<AccessibilityIcon />
<Text>represents the other roles a crew member can take.</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
{tank.crew.map((member) => { {tank.crew.map((member) => {
const count = member.count ?? 1; const count = member.count ?? 1;
@@ -12,6 +12,7 @@ import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTran
import { Pose, poseEvent } from '../../../../../../core/blitzkrieg/pose'; import { Pose, poseEvent } from '../../../../../../core/blitzkrieg/pose';
import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon'; import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon';
import { useEquipment } from '../../../../../../hooks/useEquipment'; import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { useWideFormat } from '../../../../../../hooks/useWideFormat'; import { useWideFormat } from '../../../../../../hooks/useWideFormat';
import { mutateDuel, useDuel } from '../../../../../../stores/duel'; import { mutateDuel, useDuel } from '../../../../../../stores/duel';
import mutateTankopediaPersistent, { import mutateTankopediaPersistent, {
@@ -33,7 +34,6 @@ export function TankSandbox() {
const hasImprovedVerticalStabilizer = useEquipment(122); const hasImprovedVerticalStabilizer = useEquipment(122);
const awaitedModelDefinitions = use(modelDefinitions); const awaitedModelDefinitions = use(modelDefinitions);
const canvasWrapper = useRef<HTMLDivElement>(null); const canvasWrapper = useRef<HTMLDivElement>(null);
const [isFullScreen, setIsFullScreen] = useState(false);
const mode = useTankopediaPersistent((state) => state.mode); const mode = useTankopediaPersistent((state) => state.mode);
const protagonist = useDuel((state) => state.protagonist!); const protagonist = useDuel((state) => state.protagonist!);
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id]; const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
@@ -43,6 +43,7 @@ export function TankSandbox() {
const wideFormat = useWideFormat(); const wideFormat = useWideFormat();
const [loadModel, setLoadModel] = useState(wideFormat || !isMobile); const [loadModel, setLoadModel] = useState(wideFormat || !isMobile);
const duel = useDuel(); const duel = useDuel();
const isFullScreen = useFullScreen();
function handlePointerDown() { function handlePointerDown() {
window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointermove', handlePointerMove);
@@ -59,10 +60,6 @@ export function TankSandbox() {
} }
useEffect(() => { useEffect(() => {
function handleFullScreenChange() {
setIsFullScreen(document.fullscreenElement !== null);
}
function handlePoseEvent(pose: Pose) { function handlePoseEvent(pose: Pose) {
switch (pose) { switch (pose) {
case Pose.HullDown: { case Pose.HullDown: {
@@ -121,10 +118,8 @@ export function TankSandbox() {
} }
poseEvent.on(handlePoseEvent); poseEvent.on(handlePoseEvent);
document.addEventListener('fullscreenchange', handleFullScreenChange);
return () => { return () => {
document.removeEventListener('fullscreenchange', handleFullScreenChange);
poseEvent.off(handlePoseEvent); poseEvent.off(handlePoseEvent);
}; };
}); });
@@ -145,8 +140,13 @@ export function TankSandbox() {
<Theme radius={isFullScreen ? 'none' : undefined}> <Theme radius={isFullScreen ? 'none' : undefined}>
<Card <Card
style={{ style={{
position: 'relative', position: isFullScreen ? 'fixed' : 'relative',
border: isFullScreen ? 'none' : 'unset', border: isFullScreen ? 'none' : 'unset',
width: isFullScreen ? '100vw' : 'unset',
height: isFullScreen ? 'calc(100vh)' : 'unset',
top: isFullScreen ? 0 : 'unset',
left: isFullScreen ? 0 : 'unset',
zIndex: isFullScreen ? 2 : 'unset',
}} }}
ref={canvasWrapper} ref={canvasWrapper}
> >
@@ -18,7 +18,7 @@ interface OptionsProps {
canvasWrapper: RefObject<HTMLElement>; canvasWrapper: RefObject<HTMLElement>;
} }
export function Options({ isFullScreen, canvasWrapper }: OptionsProps) { export function Options({ isFullScreen }: OptionsProps) {
const showGrid = useTankopediaPersistent( const showGrid = useTankopediaPersistent(
(state) => state.model.visual.showGrid, (state) => state.model.visual.showGrid,
); );
@@ -50,136 +50,132 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
right: 18, right: 18,
}} }}
> >
{!isFullScreen && ( <DropdownMenu.Root>
<DropdownMenu.Root> <DropdownMenu.Trigger>
<DropdownMenu.Trigger> <Button variant="ghost">
<Button variant="ghost"> <EyeOpenIcon />
<EyeOpenIcon /> </Button>
</Button> </DropdownMenu.Trigger>
</DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}> <DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
Hull down Hull down
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}> <DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
Face hug Face hug
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}> <DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
Default Default
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
)}
{!isFullScreen && ( <DropdownMenu.Root>
<DropdownMenu.Root> <DropdownMenu.Trigger>
<DropdownMenu.Trigger> <Button variant="ghost">
<Button variant="ghost"> <GearIcon />
<GearIcon /> </Button>
</Button> </DropdownMenu.Trigger>
</DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Label>Armor</DropdownMenu.Label> <DropdownMenu.Label>Armor</DropdownMenu.Label>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={showSpacedArmor} checked={showSpacedArmor}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.showSpacedArmor = checked; draft.model.visual.showSpacedArmor = checked;
}); });
mutateTankopediaTemporary((draft) => { mutateTankopediaTemporary((draft) => {
draft.shot = undefined; draft.shot = undefined;
}); });
}} }}
> >
Spaced armor Spaced armor
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={greenPenetration} checked={greenPenetration}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.greenPenetration = checked; draft.model.visual.greenPenetration = checked;
}); });
}} }}
> >
Green penetration Green penetration
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={wireframe} checked={wireframe}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.wireframe = checked; draft.model.visual.wireframe = checked;
}); });
}} }}
> >
Wireframe Wireframe
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={opaque} checked={opaque}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.opaque = checked; draft.model.visual.opaque = checked;
}); });
}} }}
> >
Opaque Opaque
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.Label>Environment</DropdownMenu.Label> <DropdownMenu.Label>Environment</DropdownMenu.Label>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={showGrid} checked={showGrid}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.showGrid = checked; draft.model.visual.showGrid = checked;
}); });
}} }}
> >
Show grid Show grid
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={showEnvironment} checked={showEnvironment}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.showEnvironment = checked; draft.model.visual.showEnvironment = checked;
}); });
}} }}
> >
View environment View environment
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger> <DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent> <DropdownMenu.SubContent>
<DropdownMenu.RadioGroup value={environment}> <DropdownMenu.RadioGroup value={environment}>
{ENVIRONMENTS.map((environment) => ( {ENVIRONMENTS.map((environment) => (
<DropdownMenu.RadioItem <DropdownMenu.RadioItem
key={environment} key={environment}
value={environment} value={environment}
onClick={() => { onClick={() => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.environment = environment; draft.model.visual.environment = environment;
}); });
}} }}
> >
{environment[0].toUpperCase() + environment.slice(1)} {environment[0].toUpperCase() + environment.slice(1)}
</DropdownMenu.RadioItem> </DropdownMenu.RadioItem>
))} ))}
</DropdownMenu.RadioGroup> </DropdownMenu.RadioGroup>
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
)}
{fullScreenAvailable && ( {fullScreenAvailable && (
<Button <Button
@@ -187,7 +183,7 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
onClick={() => { onClick={() => {
if (isFullScreen) { if (isFullScreen) {
document.exitFullscreen(); document.exitFullscreen();
} else canvasWrapper.current?.requestFullscreen(); } else document.body.requestFullscreen();
}} }}
> >
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />} {isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
@@ -46,19 +46,47 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
position: 'absolute', position: 'absolute',
left: 8, left: 8,
bottom: isFullScreen ? 80 : 8, bottom: isFullScreen ? 80 : 8,
pointerEvents: 'none',
}} }}
gap="4" gap="4"
> >
<Flex <Flex gap="2" style={{ width: 256, pointerEvents: 'auto' }}>
gap="2"
style={{
width: 256,
}}
>
<TextField.Root <TextField.Root
style={{ style={{
flex: 1, flex: 1,
textAlign: 'right',
}} }}
onBlur={() => {
const value = Number(turretYawInput.current!.value);
if (isNaN(value)) {
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
degToRad(value),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
turretYawInput.current?.blur();
}
}}
onFocus={() => turretYawInput.current?.focus()}
ref={turretYawInput}
> >
<TextField.Slot <TextField.Slot
style={{ style={{
@@ -67,46 +95,45 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
> >
Yaw Yaw
</TextField.Slot> </TextField.Slot>
<TextField.Input <TextField.Slot style={{ userSelect: 'none' }}>°</TextField.Slot>
onBlur={() => {
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
degToRad(Number(turretYawInput.current!.value)),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
turretYawInput.current?.blur();
}
}}
onFocus={() => turretYawInput.current?.focus()}
ref={turretYawInput}
style={{ textAlign: 'right' }}
/>
<TextField.Slot
style={{
userSelect: 'none',
}}
>
°
</TextField.Slot>
</TextField.Root> </TextField.Root>
<TextField.Root <TextField.Root
style={{ style={{
flex: 1, flex: 1,
textAlign: 'right',
}} }}
onBlur={() => {
const value = Number(gunPitchInput.current!.value);
if (isNaN(value)) {
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
degToRad(-value + initialGunPitch),
protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
gunPitchInput.current?.blur();
}
}}
onFocus={() => gunPitchInput.current?.focus()}
ref={gunPitchInput}
> >
<TextField.Slot <TextField.Slot
style={{ style={{
@@ -115,35 +142,6 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
> >
Pitch Pitch
</TextField.Slot> </TextField.Slot>
<TextField.Input
onBlur={() => {
const [pitch, yaw] = applyPitchYawLimits(
degToRad(
-Number(gunPitchInput.current!.value) + initialGunPitch,
),
protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
gunPitchInput.current?.blur();
}
}}
onFocus={() => gunPitchInput.current?.focus()}
ref={gunPitchInput}
style={{ textAlign: 'right' }}
/>
<TextField.Slot <TextField.Slot
style={{ style={{
userSelect: 'none', userSelect: 'none',
@@ -155,7 +153,7 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
</Flex> </Flex>
{mode === 'armor' && ( {mode === 'armor' && (
<Flex gap="4"> <Flex gap="4" style={{ pointerEvents: 'auto' }}>
<QuickEquipmentButton <QuickEquipmentButton
equipment={103} equipment={103}
active={hasCalibratedShells} active={hasCalibratedShells}
+16 -10
View File
@@ -10,10 +10,11 @@ import { mutateTankopediaTemporary } from '../../../../stores/tankopedia';
import { AntagonistBar } from './components/AntagonistBar'; import { AntagonistBar } from './components/AntagonistBar';
import { Characteristics } from './components/Characteristics'; import { Characteristics } from './components/Characteristics';
import { Consumables } from './components/Characteristics/components/Consumables'; import { Consumables } from './components/Characteristics/components/Consumables';
import { Equipments } from './components/Characteristics/components/Equipments'; import { Equipment } from './components/Characteristics/components/Equipment';
import { Miscellaneous } from './components/Characteristics/components/Miscellaneous'; import { Miscellaneous } from './components/Characteristics/components/Miscellaneous';
import { Modules } from './components/Characteristics/components/Modules'; import { Modules } from './components/Characteristics/components/Modules';
import { Provisions } from './components/Characteristics/components/Provisions'; import { Provisions } from './components/Characteristics/components/Provisions';
import { Skills } from './components/Characteristics/components/Skills';
import { TankSandbox } from './components/Model/TankSandbox'; import { TankSandbox } from './components/Model/TankSandbox';
import { Title } from './components/Title'; import { Title } from './components/Title';
@@ -63,10 +64,8 @@ export default function Page({ params }: { params: { id: string } }) {
}; };
}, [initialId, assigned]); }, [initialId, assigned]);
return ( return (
<PageWrapper color="purple" size="double"> <PageWrapper color="purple" size={1600}>
{assigned && ( {assigned && (
<> <>
<Title /> <Title />
@@ -79,7 +78,7 @@ export default function Page({ params }: { params: { id: string } }) {
justify="center" justify="center"
> >
<Flex <Flex
gap="4" gap="8"
direction="column" direction="column"
style={{ style={{
flex: 1, flex: 1,
@@ -88,15 +87,22 @@ export default function Page({ params }: { params: { id: string } }) {
position: wideFormat ? 'sticky' : undefined, position: wideFormat ? 'sticky' : undefined,
}} }}
> >
<TankSandbox /> <Flex gap="4" direction="column">
<AntagonistBar /> <TankSandbox />
<AntagonistBar />
</Flex>
<Flex direction="column" gap="4"> <Flex gap="5" wrap="wrap">
<Modules /> <Modules />
<Provisions /> <Provisions />
<Equipments />
<Miscellaneous />
<Consumables /> <Consumables />
<Flex gap="5" wrap="wrap">
<Skills />
<Equipment />
</Flex>
<Miscellaneous />
</Flex> </Flex>
</Flex> </Flex>
@@ -32,32 +32,31 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
> >
<CaretLeftIcon /> <CaretLeftIcon />
</Button> </Button>
<TextField.Root> <TextField.Root
defaultValue={1}
type="number"
ref={pageInput}
min={1}
max={Math.floor(searchedList.length / tanksPerPage) + 1}
style={{ textAlign: 'center' }}
onBlur={(event) => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.max(
0,
Math.min(
Math.floor(searchedList.length / tanksPerPage),
event.target.valueAsNumber - 1,
),
);
});
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
>
<TextField.Slot>Page</TextField.Slot> <TextField.Slot>Page</TextField.Slot>
<TextField.Input
defaultValue={1}
type="number"
ref={pageInput}
min={1}
max={Math.floor(searchedList.length / tanksPerPage) + 1}
style={{ width: 64, textAlign: 'center' }}
onBlur={(event) => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.max(
0,
Math.min(
Math.floor(searchedList.length / tanksPerPage),
event.target.valueAsNumber - 1,
),
);
});
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
/>
<TextField.Slot> <TextField.Slot>
out of {Math.floor(searchedList.length / tanksPerPage) + 1} out of {Math.floor(searchedList.length / tanksPerPage) + 1}
</TextField.Slot> </TextField.Slot>
@@ -14,6 +14,7 @@ import {
TextField, TextField,
} from '@radix-ui/themes'; } from '@radix-ui/themes';
import { go } from 'fuzzysort'; import { go } from 'fuzzysort';
import Link from 'next/link';
import { use, useEffect, useMemo, useRef, useState } from 'react'; import { use, useEffect, useMemo, useRef, useState } from 'react';
import { TreeType } from '../../../../../components/Tanks'; import { TreeType } from '../../../../../components/Tanks';
import { TANK_CLASSES } from '../../../../../components/Tanks/components/Item/constants'; import { TANK_CLASSES } from '../../../../../components/Tanks/components/Item/constants';
@@ -151,7 +152,7 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
); );
break; break;
case 'survivability.size': case 'survivability.volume':
sorted = filtered.sort((a, b) => { sorted = filtered.sort((a, b) => {
const aTankModelDefinition = awaitedModelDefinitions[a.id]; const aTankModelDefinition = awaitedModelDefinitions[a.id];
const bTankModelDefinition = awaitedModelDefinitions[b.id]; const bTankModelDefinition = awaitedModelDefinitions[b.id];
@@ -178,6 +179,33 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
}); });
break; break;
case 'survivability.length':
sorted = filtered.sort((a, b) => {
const aTankModelDefinition = awaitedModelDefinitions[a.id];
const bTankModelDefinition = awaitedModelDefinitions[b.id];
const aTurretModelDefinition =
aTankModelDefinition.turrets[a.turrets.at(-1)!.id];
const bTurretModelDefinition =
bTankModelDefinition.turrets[b.turrets.at(-1)!.id];
const aSize = normalizeBoundingBox(
unionBoundingBox(
aTankModelDefinition.boundingBox,
aTurretModelDefinition.boundingBox,
),
);
const bSize = normalizeBoundingBox(
unionBoundingBox(
bTankModelDefinition.boundingBox,
bTurretModelDefinition.boundingBox,
),
);
const aLength = Math.max(aSize[0], aSize[1], aSize[2]);
const bLength = Math.max(bSize[0], bSize[1], bSize[2]);
return aLength - bLength;
});
break;
case 'fire.dpm': case 'fire.dpm':
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
@@ -392,27 +420,26 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
<> <>
<Flex direction="column" gap="4"> <Flex direction="column" gap="4">
<Flex gap="2"> <Flex gap="2">
<TextField.Root style={{ flex: 1 }}> <TextField.Root
style={{ flex: 1 }}
ref={input}
placeholder="Search tanks..."
onChange={(event) => {
const results = go(event.target.value, searchableTanks, {
keys: [
'name',
'nameFull',
'id',
] satisfies (keyof TankDefinition)[],
all: true,
}).map(({ obj }) => obj);
setSearchedList(results);
}}
>
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot> </TextField.Slot>
<TextField.Input
ref={input}
placeholder="Search tanks..."
onChange={(event) => {
const results = go(event.target.value, searchableTanks, {
keys: [
'name',
'nameFull',
'id',
] satisfies (keyof TankDefinition)[],
all: true,
}).map(({ obj }) => obj);
setSearchedList(results);
}}
/>
</TextField.Root> </TextField.Root>
<DropdownMenu.Root> <DropdownMenu.Root>
@@ -699,14 +726,24 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
Camouflage shooting Camouflage shooting
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.size'} checked={sort.by === 'survivability.volume'}
onClick={() => { onClick={() => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.size'; draft.sort.by = 'survivability.volume';
}); });
}} }}
> >
Size Volume
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.length'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.length';
});
}}
>
Length
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>
@@ -771,117 +808,123 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
<Flex wrap="wrap" gap="2" justify={compact ? 'between' : 'center'}> <Flex wrap="wrap" gap="2" justify={compact ? 'between' : 'center'}>
{!compact && {!compact &&
searchResultsPageSlice.map((tank) => ( searchResultsPageSlice.map((tank) => (
<Card <Link
key={tank.id} href={`/tools/tankopedia/${tank.id}`}
onClick={() => onSelect(tank)}
className={styles.listing}
style={{ style={{
flex: 1, flex: 1,
cursor: 'pointer', minWidth: 256,
height: 64,
}} }}
> >
<Inset <Card
key={tank.id} key={tank.id}
className={styles.listing}
style={{ style={{
minWidth: 256, cursor: 'pointer',
minHeight: 64, height: 64,
position: 'relative', width: '100%',
display: 'flex',
}} }}
> >
<img <Inset
className={styles.flag} key={tank.id}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{ style={{
width: '100%', minHeight: 64,
height: '100%', position: 'relative',
objectFit: 'contain', display: 'flex',
position: 'absolute',
}} }}
/>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<div
style={{
width: 256,
height: 128,
}}
className={styles.listingImage}
> >
<img <img
src={tankIcon(tank.id)} className={styles.flag}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{ style={{
width: '100%',
height: '100%',
objectFit: 'contain', objectFit: 'contain',
objectPosition: 'left center', position: 'absolute',
}} }}
/> />
</div>
<Flex <div
align="center" className={styles.listingShadow}
justify="between" style={{
className={styles.listingLabel} width: '100%',
style={{ height: '100%',
position: 'absolute', position: 'absolute',
bottom: 8, transition: `box-shadow ${theme.durations.regular}`,
padding: '0 12px', }}
width: '100%', />
boxSizing: 'border-box',
}} <div
> style={{
<Flex align="center" justify="center" gap="1"> width: 256,
height: 128,
}}
className={styles.listingImage}
>
<img <img
src={ src={tankIcon(tank.id)}
(tank.treeType === 'collector'
? TANK_ICONS_COLLECTOR
: tank.treeType === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.class]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{ style={{
color: objectFit: 'contain',
tank.treeType === 'researchable' objectPosition: 'left center',
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}} }}
> />
{tank.name} </div>
<Flex
align="center"
justify="between"
className={styles.listingLabel}
style={{
position: 'absolute',
bottom: 8,
padding: '0 12px',
width: '100%',
boxSizing: 'border-box',
}}
>
<Flex align="center" justify="center" gap="1">
<img
src={
(tank.treeType === 'collector'
? TANK_ICONS_COLLECTOR
: tank.treeType === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.class]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{
color:
tank.treeType === 'researchable'
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{tank.name}
</Text>
</Flex>
<Text size="4" color="gray" highContrast={false}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text> </Text>
</Flex> </Flex>
</Inset>
<Text size="4" color="gray" highContrast={false}> </Card>
{TIER_ROMAN_NUMERALS[tank.tier]} </Link>
</Text>
</Flex>
</Inset>
</Card>
))} ))}
{compact && ( {compact && (
-6
View File
@@ -74,12 +74,6 @@ export const characteristics = style({
}, },
}); });
export const configurationChild = style({
display: 'flex',
gap: 8,
flexDirection: 'column',
});
export const configuration = style({ export const configuration = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
+1 -1
View File
@@ -8,7 +8,7 @@ export default function Page() {
const router = useRouter(); const router = useRouter();
return ( return (
<PageWrapper size="double" color="purple"> <PageWrapper size={1200} color="purple">
<TankSearch <TankSearch
onSelect={(tank) => { onSelect={(tank) => {
router.push(`./tankopedia/${tank.id}`); router.push(`./tankopedia/${tank.id}`);
+3 -3
View File
@@ -6,7 +6,7 @@ import { Glow } from '../components/AllStatsOverview/components/HeroStat/compone
import CommandWrapper from '../components/CommandWrapper'; import CommandWrapper from '../components/CommandWrapper';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitz'; import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitzLink';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand'; import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
@@ -29,11 +29,11 @@ export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
async handler(interaction) { async handler(interaction) {
const { t, translate } = translator(interaction.locale); const { t, translate } = translator(interaction.locale);
const { id, region } = await resolvePlayerFromCommand(interaction); const { id, region } = await resolvePlayerFromCommand(interaction);
const discordId = parseInt(interaction.user.id); const discordId = BigInt(interaction.user.id);
const accountInfo = await getAccountInfo(region, id); const accountInfo = await getAccountInfo(region, id);
const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']); const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
await linkBlitzAndDiscord(discordId, region, id); await linkBlitzAndDiscord(discordId, id);
if (interaction.guildId === discord.sklld_guild_id) { if (interaction.guildId === discord.sklld_guild_id) {
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) { if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
+152 -145
View File
@@ -10,7 +10,7 @@ import getRatingInfo from '../core/blitz/getRatingInfo';
import { getRatingNeighbors } from '../core/blitz/getRatingNeighbors'; import { getRatingNeighbors } from '../core/blitz/getRatingNeighbors';
import { normalizeLeagueIcon } from '../core/blitz/normalizeLeagueIcon'; import { normalizeLeagueIcon } from '../core/blitz/normalizeLeagueIcon';
import { emblemURL } from '../core/blitzkrieg/emblemURL'; import { emblemURL } from '../core/blitzkrieg/emblemURL';
import { getArchivedRatingLeaderboard } from '../core/blitzkrieg/getArchivedRatingLeaderboard'; import getArchivedRatingMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingMidnightLeaderboard';
import { webpToPng } from '../core/blitzkrieg/iconPng'; import { webpToPng } from '../core/blitzkrieg/iconPng';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand'; import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
@@ -42,9 +42,12 @@ export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
const { id, region } = await resolvePlayerFromCommand(interaction); const { id, region } = await resolvePlayerFromCommand(interaction);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined; const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
const leaderboard = await getArchivedRatingLeaderboard(region, 53); const leaderboard = await getArchivedRatingMidnightLeaderboard(
region,
53,
);
if (leaderboard.format === BkrlFormat.Base) { if (leaderboard?.format === BkrlFormat.Base) {
throw new Error( throw new Error(
'Encountered bkrl minimal format in latest season. Wait till next cron?', 'Encountered bkrl minimal format in latest season. Wait till next cron?',
); );
@@ -54,11 +57,11 @@ export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
const accountInfo = await getAccountInfo(region, id, [ const accountInfo = await getAccountInfo(region, id, [
'statistics.rating', 'statistics.rating',
]); ]);
const statsIndex = leaderboard.entries.findIndex( const statsIndex = leaderboard?.entries.findIndex(
(entry) => entry.id === id, (entry) => entry.id === id,
); );
const statsA = leaderboard.entries[statsIndex]; const statsA = leaderboard ? leaderboard.entries[statsIndex!] : null;
const numberA = statsIndex + 1; const positionA = leaderboard ? statsIndex! + 1 : undefined;
const statsB1 = accountInfo.statistics.rating!; const statsB1 = accountInfo.statistics.rating!;
const [statsB2] = (await getRatingNeighbors(region, id, 0)).neighbors; const [statsB2] = (await getRatingNeighbors(region, id, 0)).neighbors;
const league = getLeagueFromScore(statsB2.score); const league = getLeagueFromScore(statsB2.score);
@@ -80,7 +83,9 @@ export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
statsB2.number >= reward.from_position && statsB2.number >= reward.from_position &&
statsB2.number <= reward.to_position, statsB2.number <= reward.to_position,
); );
const positionDelta = statsB2.number - numberA; const positionDelta = leaderboard
? statsB2.number - positionA!
: undefined;
const delta = deltaBkrlBlitzStats( const delta = deltaBkrlBlitzStats(
statsA, statsA,
accountInfo.statistics.rating!, accountInfo.statistics.rating!,
@@ -204,7 +209,7 @@ export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
> >
#{statsB2.number.toLocaleString(interaction.locale)} #{statsB2.number.toLocaleString(interaction.locale)}
</span> </span>
{positionDelta !== 0 && ( {positionDelta !== undefined && positionDelta !== 0 && (
<> <>
<DeltaCaret delta={positionDelta} /> <DeltaCaret delta={positionDelta} />
<span <span
@@ -336,150 +341,152 @@ export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
)} )}
</div> </div>
<div style={{ padding: '8px 0', display: 'flex' }}> {delta.battles > 0 && (
<div <div style={{ padding: '8px 0', display: 'flex' }}>
style={{ <div
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{ style={{
fontSize: 24, display: 'flex',
color: theme.colors.textHighContrast, alignItems: 'center',
fontWeight: 'bold', justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}} }}
> >
{delta.battles.toLocaleString(interaction.locale)} <span
</span> style={{
<span fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{delta.battles.toLocaleString(interaction.locale)}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Battles
</span>
</div>
<div
style={{ style={{
fontSize: 16, display: 'flex',
color: theme.colors.textLowContrast, alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}} }}
> >
Battles <span
</span> style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{(100 * (delta.wins / delta.battles)).toFixed(0)}%
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Winrate
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{Math.round(delta.damage / delta.battles).toLocaleString(
interaction.locale,
)}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Damage
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{(100 * (delta.survived / delta.battles)).toFixed(0)}%
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Survival
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{(delta.kills / delta.battles).toFixed(2)}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Kills
</span>
</div>
</div> </div>
<div )}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{(100 * (delta.wins / delta.battles)).toFixed(0)}%
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Winrate
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{Math.round(delta.damage / delta.battles).toLocaleString(
interaction.locale,
)}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Damage
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{(100 * (delta.survived / delta.battles)).toFixed(0)}%
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Survival
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{(delta.kills / delta.battles).toFixed(2)}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Kills
</span>
</div>
</div>
</CommandWrapper> </CommandWrapper>
); );
}, },
@@ -20,6 +20,7 @@ export const CoreArmorScene = memo(() => {
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
const initialTankopediaState = useTankopediaPersistent.getState(); const initialTankopediaState = useTankopediaPersistent.getState();
const protagonist = useDuel((draft) => draft.protagonist!);
useEffect(() => { useEffect(() => {
if (!modelDefinitions) return; if (!modelDefinitions) return;
@@ -88,7 +89,7 @@ export const CoreArmorScene = memo(() => {
turretContainer.current?.rotation.copy(turretRotation); turretContainer.current?.rotation.copy(turretRotation);
} }
handleModelTransform(useDuel.getState().protagonist!); handleModelTransform(protagonist);
modelTransformEvent.on(handleModelTransform); modelTransformEvent.on(handleModelTransform);
return () => { return () => {
@@ -50,6 +50,7 @@ export function CoreArmorSceneComponent({
opaque: { value: null }, opaque: { value: null },
useSpacedArmor: { value: null }, useSpacedArmor: { value: null },
inverseProjectionMatrix: { value: null },
resolution: { value: new Vector2() }, resolution: { value: new Vector2() },
spacedArmorBuffer: { value: null }, spacedArmorBuffer: { value: null },
spacedArmorDepth: { value: null }, spacedArmorDepth: { value: null },
@@ -57,10 +58,7 @@ export function CoreArmorSceneComponent({
}); });
useEffect(() => { useEffect(() => {
function handleShellChange(shell: ShellDefinition) { async function handleShellChange(shell: ShellDefinition) {
material.uniforms.penetration.value = resolveNearPenetration(
shell.penetration,
);
material.uniforms.caliber.value = shell.caliber; material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90); material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
material.uniforms.normalization.value = degToRad( material.uniforms.normalization.value = degToRad(
@@ -70,6 +68,10 @@ export function CoreArmorSceneComponent({
material.uniforms.canSplash.value = canSplash(shell.type); material.uniforms.canSplash.value = canSplash(shell.type);
material.uniforms.damage.value = shell.damage.armor; material.uniforms.damage.value = shell.damage.armor;
material.uniforms.explosionRadius.value = shell.explosionRadius; material.uniforms.explosionRadius.value = shell.explosionRadius;
const duel = useDuel.getState();
await handleProtagonistEquipmentChange(duel.protagonist!.equipment);
await handleAntagonistEquipmentChange(duel.antagonist!.equipment);
} }
function handleVisualChange( function handleVisualChange(
visual: TankopediaPersistent['model']['visual'], visual: TankopediaPersistent['model']['visual'],
@@ -91,6 +93,7 @@ export function CoreArmorSceneComponent({
const shell = useDuel.getState().antagonist!.shell; const shell = useDuel.getState().antagonist!.shell;
const penetration = resolveNearPenetration(shell.penetration); const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(103, true, equipment); const hasCalibratedShells = await hasEquipment(103, true, equipment);
material.uniforms.penetration.value = hasCalibratedShells material.uniforms.penetration.value = hasCalibratedShells
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05) ? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
: penetration; : penetration;
@@ -120,15 +123,17 @@ export function CoreArmorSceneComponent({
return () => { return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe()); unsubscribes.forEach((unsubscribe) => unsubscribe());
}; };
}, []); });
useFrame(({ gl }) => { useFrame(({ gl, camera }) => {
gl.getSize(material.uniforms.resolution.value).multiplyScalar( gl.getSize(material.uniforms.resolution.value).multiplyScalar(
gl.getPixelRatio(), gl.getPixelRatio(),
); );
material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture; material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
material.uniforms.spacedArmorDepth.value = material.uniforms.spacedArmorDepth.value =
spacedArmorRenderTarget.depthTexture; spacedArmorRenderTarget.depthTexture;
material.uniforms.inverseProjectionMatrix.value =
camera.projectionMatrixInverse;
}); });
return ( return (
@@ -1,6 +1,5 @@
varying vec3 vNormal; varying vec3 vNormal;
varying vec3 vViewPosition; varying vec3 vViewPosition;
varying mat4 vProjectionMatrix;
uniform float thickness; uniform float thickness;
uniform float penetration; uniform float penetration;
@@ -15,13 +14,14 @@ uniform float explosionRadius;
uniform bool greenPenetration; uniform bool greenPenetration;
uniform bool opaque; uniform bool opaque;
uniform bool useSpacedArmor; uniform bool useSpacedArmor;
uniform mat4 inverseProjectionMatrix;
uniform highp sampler2D spacedArmorBuffer; uniform highp sampler2D spacedArmorBuffer;
uniform highp sampler2D spacedArmorDepth; uniform highp sampler2D spacedArmorDepth;
float depthToDistance(float depth) { float depthToDistance(float depth) {
vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
vec4 eyePosition = inverse(vProjectionMatrix) * clipPosition; vec4 eyePosition = inverseProjectionMatrix * clipPosition;
return length(eyePosition.xyz / eyePosition.w); return length(eyePosition.xyz / eyePosition.w);
} }
@@ -42,36 +42,42 @@ void main() {
bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0; bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0;
float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization; float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization;
float finalThickness = thickness / cos(max(0.0, angle - finalNormalization)); float finalThickness = thickness / cos(max(0.0, angle - finalNormalization));
float remainingPenetration = penetration;
// world space is in meters // world space in meters
vec4 spacedArmorDepthFragment = texture2D(spacedArmorDepth, screenCoordinates); vec4 spacedArmorDepthFragment = texture2D(spacedArmorDepth, screenCoordinates);
float spacedArmorDistance = depthToDistance(spacedArmorDepthFragment.r); float spacedArmorDistance = depthToDistance(spacedArmorDepthFragment.r);
float coreArmorDistance = depthToDistance(gl_FragCoord.z); float coreArmorDistance = depthToDistance(gl_FragCoord.z);
float distanceFromSpacedArmor = coreArmorDistance - spacedArmorDistance; float distanceFromSpacedArmor = coreArmorDistance - spacedArmorDistance;
if (useSpacedArmor && isUnderSpacedArmor) { if (canSplash && useSpacedArmor && isUnderSpacedArmor) {
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration; float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
remainingPenetration -= spacedArmorThickness; float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * (finalThickness + spacedArmorThickness);
if (isExplosive && remainingPenetration > 0.0) { penetrationChance = 0.0;
// there is a 50% penetration loss per meter for HEAT shells splashChance = finalDamage > 0.0 ? 1.0 : 0.0;
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
}
}
remainingPenetration = max(0.0, remainingPenetration);
float delta = finalThickness - remainingPenetration;
float randomization = remainingPenetration * 0.05;
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
if (canSplash) {
// only allow splashing if the damage equation deals more than 0 damage
float nominalArmorThickness = finalThickness - remainingPenetration;
float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * nominalArmorThickness;
splashChance = finalDamage > 0.0 ? 1.0 : 0.0; // TODO: add randomization here?
} else { } else {
splashChance = 0.0; float remainingPenetration = penetration;
if (useSpacedArmor && isUnderSpacedArmor) {
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
remainingPenetration -= spacedArmorThickness;
if (isExplosive && remainingPenetration > 0.0) {
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
}
}
remainingPenetration = max(0.0, remainingPenetration);
float delta = finalThickness - remainingPenetration;
float randomization = remainingPenetration * 0.05;
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
if (canSplash) {
float splashDamage = 0.5 * damage - 1.1 * finalThickness;
splashChance = splashDamage > 0.0 ? 1.0 : 0.0;
} else {
splashChance = 0.0;
}
} }
} }
@@ -1,12 +1,10 @@
varying vec3 vNormal; varying vec3 vNormal;
varying vec3 vViewPosition; varying vec3 vViewPosition;
varying mat4 vProjectionMatrix;
void main() { void main() {
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0); vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
vViewPosition = vViewPosition4.xyz; vViewPosition = vViewPosition4.xyz;
vProjectionMatrix = projectionMatrix;
vNormal = normalMatrix * normal; vNormal = normalMatrix * normal;
gl_Position = projectionMatrix * vViewPosition4; gl_Position = projectionMatrix * vViewPosition4;
@@ -4,6 +4,7 @@ import { useFrame } from '@react-three/fiber';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { Mesh } from 'three'; import { Mesh } from 'three';
import { radToDeg } from 'three/src/math/MathUtils'; import { radToDeg } from 'three/src/math/MathUtils';
import { canSplash } from '../../../core/blitz/canSplash';
import { isExplosive } from '../../../core/blitz/isExplosive'; import { isExplosive } from '../../../core/blitz/isExplosive';
import { NaNFallback } from '../../../core/math/NaNFallback'; import { NaNFallback } from '../../../core/math/NaNFallback';
import { normalToEuler } from '../../../core/math/normalToEuler'; import { normalToEuler } from '../../../core/math/normalToEuler';
@@ -42,6 +43,7 @@ export function ShotDisplay() {
); );
const { shell } = useDuel.getState().antagonist!; const { shell } = useDuel.getState().antagonist!;
const explosive = isExplosive(shell.type); const explosive = isExplosive(shell.type);
const splashing = canSplash(shell.type);
const midPointLayer = (shot?.layers.findLast( const midPointLayer = (shot?.layers.findLast(
(layer) => layer.type !== null && layer.status === 'ricochet', (layer) => layer.type !== null && layer.status === 'ricochet',
) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase; ) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase;
@@ -64,6 +66,18 @@ export function ShotDisplay() {
return accumulator; return accumulator;
}, 0) + LENGTH_INFINITY }, 0) + LENGTH_INFINITY
: undefined; : undefined;
const preMidPointThickness = shot
? preMidPointLayers!.reduce(
(accumulator, layer) =>
accumulator +
(layer.type === null
? 0
: layer.type === ArmorType.External
? layer.thickness
: layer.thicknessAngled),
0,
)
: undefined;
const postMidPointGap = shot const postMidPointGap = shot
? postMidPointLayers!.reduce((accumulator, layer) => { ? postMidPointLayers!.reduce((accumulator, layer) => {
if (layer.type === null) return accumulator + layer.distance; if (layer.type === null) return accumulator + layer.distance;
@@ -246,8 +260,9 @@ export function ShotDisplay() {
(1 - (1 -
distanceFromSpacedArmor! / shell.explosionRadius!) - distanceFromSpacedArmor! / shell.explosionRadius!) -
1.1 * 1.1 *
(midPointLayer as ShotLayerNonExternal) ((midPointLayer as ShotLayerNonExternal)
.thicknessAngled, .thicknessAngled +
preMidPointThickness!),
), ),
), ),
0, 0,
@@ -255,7 +270,7 @@ export function ShotDisplay() {
</Text> </Text>
{shot.layers.map((layer, index) => { {shot.layers.map((layer, index) => {
if (layer.type === null && !explosive) return null; if (layer.type === null && (!explosive || splashing)) return null;
return ( return (
<Flex key={index} align="center" gap="2"> <Flex key={index} align="center" gap="2">
@@ -31,6 +31,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
const initialTankopediaState = useTankopediaPersistent.getState(); const initialTankopediaState = useTankopediaPersistent.getState();
const protagonist = useDuel((draft) => draft.protagonist!);
useEffect(() => { useEffect(() => {
if (!modelDefinitions) return; if (!modelDefinitions) return;
@@ -99,7 +100,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
turretContainer.current?.rotation.copy(turretRotation); turretContainer.current?.rotation.copy(turretRotation);
} }
handleModelTransform(useDuel.getState().protagonist!); handleModelTransform(protagonist);
modelTransformEvent.on(handleModelTransform); modelTransformEvent.on(handleModelTransform);
return () => { return () => {
@@ -83,7 +83,7 @@ export function SpacedArmorSubExternal({
return () => { return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe()); unsubscribes.forEach((unsubscribe) => unsubscribe());
}; };
}, []); });
return ( return (
<> <>
@@ -95,7 +95,7 @@ export function SpacedArmorSubSpaced({
return () => { return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe()); unsubscribes.forEach((unsubscribe) => unsubscribe());
}; };
}, []); });
return ( return (
<> <>
@@ -179,7 +179,7 @@ export function SpacedArmorSceneComponent({
const distance = ( const distance = (
shot.layers.at(-1) as ShotLayerBase shot.layers.at(-1) as ShotLayerBase
).point.distanceTo(intersections[0].point); ).point.distanceTo(intersections[0].point);
if (explosive) if (explosive && !splashing)
remainingPenetration -= 0.5 * remainingPenetration * distance; remainingPenetration -= 0.5 * remainingPenetration * distance;
const blocked = remainingPenetration < 0; const blocked = remainingPenetration < 0;
@@ -205,7 +205,7 @@ export function SpacedArmorSceneComponent({
const previousIntersection = intersections[layerIndex - 1]; const previousIntersection = intersections[layerIndex - 1];
const distance = const distance =
intersection.distance - previousIntersection.distance; intersection.distance - previousIntersection.distance;
if (explosive) if (explosive && !splashing)
remainingPenetration -= remainingPenetration -=
0.5 * remainingPenetration * distance; 0.5 * remainingPenetration * distance;
const wasted = remainingPenetration < 0; const wasted = remainingPenetration < 0;
@@ -216,7 +216,7 @@ export function SpacedArmorSceneComponent({
distance, distance,
}); });
if (wasted) return; if (wasted && !splashing) return;
} }
if (intersection.object.userData.type === ArmorType.External) { if (intersection.object.userData.type === ArmorType.External) {
@@ -233,7 +233,11 @@ export function SpacedArmorSceneComponent({
status: blocked ? 'blocked' : 'penetration', status: blocked ? 'blocked' : 'penetration',
}); });
if (blocked) break; if (splashing) {
remainingPenetration = Math.max(0, remainingPenetration);
} else if (blocked) {
break;
}
} else { } else {
const type = intersection.object.userData.type; const type = intersection.object.userData.type;
const angle = surfaceNormal.angleTo(shellNormal); const angle = surfaceNormal.angleTo(shellNormal);
@@ -11,23 +11,28 @@ export interface TankComponentButtonProps
discriminator?: ReactNode; discriminator?: ReactNode;
disabled?: boolean; disabled?: boolean;
banner?: string; banner?: string;
special?: boolean;
} }
export function TankComponentButton({ export function TankComponentButton({
variant,
selected, selected,
first = false, first = false,
last = false, last = false,
rowChild, rowChild,
discriminator, discriminator,
special,
children, children,
banner, banner,
disabled,
style,
...props ...props
}: TankComponentButtonProps) { }: TankComponentButtonProps) {
return ( return (
<Button <Button
radius="medium" radius="medium"
color={selected ? undefined : 'gray'} color={selected ? (special ? 'amber' : undefined) : 'gray'}
variant={selected ? 'surface' : 'soft'} variant={variant ?? (selected ? 'surface' : 'soft')}
style={{ style={{
padding: 0, padding: 0,
minWidth: 48, minWidth: 48,
@@ -39,7 +44,11 @@ export function TankComponentButton({
borderBottomRightRadius: last ? undefined : 0, borderBottomRightRadius: last ? undefined : 0,
margin: rowChild ? -0.5 : 'unset', margin: rowChild ? -0.5 : 'unset',
overflow: 'hidden', overflow: 'hidden',
cursor: 'unset',
pointerEvents: variant === 'ghost' ? 'none' : undefined,
...style,
}} }}
disabled={variant === 'ghost' || disabled}
{...props} {...props}
> >
{banner !== undefined && ( {banner !== undefined && (
@@ -56,23 +65,23 @@ export function TankComponentButton({
/> />
)} )}
{children}
{discriminator !== undefined && ( {discriminator !== undefined && (
<Text <Text
size="1" size="1"
style={{ style={{
textShadow: '0 0 4px black', textShadow: '0 0 4px black',
zIndex: 1,
position: 'absolute', position: 'absolute',
top: '50%', top: '50%',
right: 8, right: 8,
textAlign: 'right', textAlign: 'right',
}} }}
color={variant === 'ghost' ? 'gray' : undefined}
> >
{discriminator} {discriminator}
</Text> </Text>
)} )}
{children}
</Button> </Button>
); );
} }
+42 -2
View File
@@ -1,10 +1,16 @@
import { DiscordLogoIcon } from '@radix-ui/react-icons'; import { DiscordLogoIcon } from '@radix-ui/react-icons';
import { Flex, Text, Theme } from '@radix-ui/themes'; import { Flex, Text, Theme } from '@radix-ui/themes';
import Link from 'next/link'; import Link from 'next/link';
import { useFullScreen } from '../../hooks/useFullScreen';
import { BlitzkriegWormWide } from '../../icons/BlitzkriegWormWide'; import { BlitzkriegWormWide } from '../../icons/BlitzkriegWormWide';
import { PatreonIcon } from '../../icons/Patreon';
import { theme } from '../../stitches.config'; import { theme } from '../../stitches.config';
export default function Navbar() { export default function Navbar() {
const isFullScreen = useFullScreen();
if (isFullScreen) return null;
return ( return (
<Theme radius="none"> <Theme radius="none">
<div <div
@@ -51,9 +57,10 @@ export default function Navbar() {
</Text> </Text>
</Flex> </Flex>
<Flex justify="center" align="center" gap="2"> <Flex justify="center" align="center" gap="4">
<Link <Link
href="https://discord.gg/nDt7AjGJQH" href="https://discord.gg/nDt7AjGJQH"
target="_blank"
style={{ style={{
color: 'inherit', color: 'inherit',
display: 'flex', display: 'flex',
@@ -61,7 +68,40 @@ export default function Navbar() {
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
<DiscordLogoIcon /> <DiscordLogoIcon width={16} height={16} />
</Link>
<Link
href="https://ko-fi.com/tresabhi"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
src="https://i.imgur.com/QdqgdP2.png"
style={{
width: 20,
height: 20,
objectFit: 'contain',
}}
/>
</Link>
<Link
href="https://www.patreon.com/tresabhi"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PatreonIcon width={14} height={14} />
</Link> </Link>
</Flex> </Flex>
</Flex> </Flex>
+6 -6
View File
@@ -1,15 +1,15 @@
import { Flex, Theme } from '@radix-ui/themes'; import { Flex, FlexProps, Theme } from '@radix-ui/themes';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
interface PageWrapperProps extends ComponentProps<typeof Flex> { type PageWrapperProps = FlexProps & {
color?: ComponentProps<typeof Theme>['accentColor']; color?: ComponentProps<typeof Theme>['accentColor'];
size?: 'regular' | 'wide' | 'double'; size?: number;
} };
export default function PageWrapper({ export default function PageWrapper({
style, style,
color, color,
size = 'regular', size = 800,
...props ...props
}: PageWrapperProps) { }: PageWrapperProps) {
return ( return (
@@ -19,7 +19,7 @@ export default function PageWrapper({
gap="4" gap="4"
style={{ style={{
width: '100%', width: '100%',
maxWidth: size === 'regular' ? 800 : size === 'double' ? 1200 : 960, maxWidth: size,
margin: 'auto', margin: 'auto',
padding: 16, padding: 16,
boxSizing: 'border-box', boxSizing: 'border-box',
+19
View File
@@ -0,0 +1,19 @@
import { Region } from '../../constants/regions';
export function idToRange(id: number): Region {
if (id >= 42e8) {
throw new Error('bot id');
} else if (id >= 31e8) {
throw new Error('china id');
} else if (id >= 20e8) {
return 'asia';
} else if (id >= 10e8) {
return 'com';
} else if (id >= 5e8) {
return 'eu';
} else if (id >= 0) {
throw new Error('ru id');
} else {
throw new Error(`id ${id} is out of range`);
}
}
-21
View File
@@ -1,21 +0,0 @@
import { Region } from '../../constants/regions';
import { discordBlitzDB } from './discordBlitzDB';
export function linkBlitzAndDiscord(
discord: number,
region: Region,
blitz: number,
) {
return discordBlitzDB.discord_blitz.upsert({
where: { discord },
update: { region, blitz },
create: { discord, region, blitz },
});
}
export function getBlitzFromDiscord(discord: number) {
return discordBlitzDB.discord_blitz.findUnique({
where: { discord },
select: { region: true, blitz: true },
}) as Promise<null | { region: Region; blitz: number }>;
}
-3
View File
@@ -1,3 +0,0 @@
import { PrismaClient } from '@prisma/client';
export const discordBlitzDB = new PrismaClient();
+30
View File
@@ -0,0 +1,30 @@
import { usersDatabase } from '../../databases/users';
import { idToRange } from '../blitz/idToRegion';
export async function flagUserActivity(blitz: number) {
await usersDatabase.users.upsert({
where: { blitz },
update: {},
create: { blitz },
});
}
export async function linkBlitzAndDiscord(discord: bigint, blitz: number) {
await usersDatabase.discordUsers.upsert({
where: { discord },
update: { blitz },
create: { discord, blitz },
});
await flagUserActivity(blitz);
}
export async function getBlitzFromDiscord(discord: bigint) {
const unique = await usersDatabase.discordUsers.findUnique({
where: { discord },
});
if (unique === null) return null;
await flagUserActivity(unique.blitz);
return { id: unique.blitz, region: idToRange(unique.blitz) };
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { usersDatabase } from '../../databases/users';
import { client } from '../discord/client'; import { client } from '../discord/client';
import { discordBlitzDB } from './discordBlitzDB';
const EXIT_EVENTS = [ const EXIT_EVENTS = [
'beforeExit', 'beforeExit',
@@ -13,7 +13,7 @@ const EXIT_EVENTS = [
async function cleanup() { async function cleanup() {
console.log('Cleaning up...'); console.log('Cleaning up...');
await Promise.all([discordBlitzDB.$disconnect(), client.destroy()]); await Promise.all([usersDatabase.$disconnect(), client.destroy()]);
console.log('Gracefully exiting...'); console.log('Gracefully exiting...');
+1 -1
View File
@@ -5,7 +5,7 @@ config();
const SECRET_KEYS = [ const SECRET_KEYS = [
'DISCORD_TOKEN', 'DISCORD_TOKEN',
'GH_TOKEN', 'GH_TOKEN',
'DISCORD_BLITZ_DB', 'DATABASE_URL',
'WOTB_DLC_CDN', 'WOTB_DLC_CDN',
] as const; ] as const;
+11
View File
@@ -0,0 +1,11 @@
import { TankClass } from '../../components/Tanks';
import { asset } from './asset';
import { fetchCdonLz4 } from './fetchCdonLz4';
export interface SkillDefinitions {
classes: Record<TankClass, string[]>;
}
export const skillDefinitions = fetchCdonLz4<SkillDefinitions>(
asset('definitions/skills.cdon.lz4'),
);
@@ -55,6 +55,7 @@ export interface TankDefinition {
export interface TrackDefinition { export interface TrackDefinition {
id: number; id: number;
tier: Tier; tier: Tier;
name: string;
weight: number; weight: number;
traverseSpeed: number; traverseSpeed: number;
xp?: number; xp?: number;
@@ -3,7 +3,7 @@ import markdownEscape from 'markdown-escape';
import { Region } from '../../../constants/regions'; import { Region } from '../../../constants/regions';
import { UserError } from '../../../hooks/userError'; import { UserError } from '../../../hooks/userError';
import searchPlayersAcrossRegions from '../../blitz/searchPlayersAcrossRegions'; import searchPlayersAcrossRegions from '../../blitz/searchPlayersAcrossRegions';
import { getBlitzFromDiscord } from '../../blitzkrieg/discordBlitz'; import { getBlitzFromDiscord } from '../../blitzkrieg/discordBlitzLink';
import { translator } from '../../localization/translator'; import { translator } from '../../localization/translator';
import { serverAndIdPattern } from './constants'; import { serverAndIdPattern } from './constants';
@@ -43,12 +43,10 @@ export default async function resolvePlayerFromCommand(
} }
} }
} else { } else {
const account = await getBlitzFromDiscord(parseInt(interaction.user.id)); const account = await getBlitzFromDiscord(BigInt(interaction.user.id));
if (account) { if (account) return account;
return { region: account.region, id: account.blitz };
} else { throw new UserError(t`bot.common.errors.player_not_linked`);
throw new UserError(t`bot.common.errors.player_not_linked`);
}
} }
} }
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const usersDatabase = new PrismaClient();
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
export function useFullScreen() {
const [isFullScreen, setIsFullScreen] = useState(
typeof document !== 'undefined' && document.fullscreenElement !== null,
);
useEffect(() => {
function handleFullScreenChange() {
setIsFullScreen(document.fullscreenElement !== null);
}
document.addEventListener('fullscreenchange', handleFullScreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullScreenChange);
};
}, []);
return isFullScreen;
}
+14
View File
@@ -0,0 +1,14 @@
import { ComponentProps } from 'react';
export function PatreonIcon(props: ComponentProps<'svg'>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 436 476" {...props}>
<title>Patreon logo</title>
<path
fill="currentColor"
data-fill="1"
d="M436 143c-.084-60.778-47.57-110.591-103.285-128.565C263.528-7.884 172.279-4.649 106.214 26.424 26.142 64.089.988 146.596.051 228.883c-.77 67.653 6.004 245.841 106.83 247.11 74.917.948 86.072-95.279 120.737-141.623 24.662-32.972 56.417-42.285 95.507-51.929C390.309 265.865 436.097 213.011 436 143Z"
></path>
</svg>
);
}
+6 -2
View File
@@ -43,6 +43,7 @@ export interface TankopediaPersistent {
interface TankopediaTemporary { interface TankopediaTemporary {
shot?: Shot; shot?: Shot;
skills: Record<string, number>;
} }
export interface ShotLayerBase { export interface ShotLayerBase {
@@ -98,7 +99,8 @@ export const SORT_NAMES = {
'survivability.camouflageStill': 'camouflage still', 'survivability.camouflageStill': 'camouflage still',
'survivability.camouflageMoving': 'camouflage moving', 'survivability.camouflageMoving': 'camouflage moving',
'survivability.camouflageShooting': 'camouflage shooting', 'survivability.camouflageShooting': 'camouflage shooting',
'survivability.size': 'size', 'survivability.volume': 'volume',
'survivability.length': 'length',
'fire.dpm': 'DPM', 'fire.dpm': 'DPM',
'fire.reload': 'reload', 'fire.reload': 'reload',
'fire.caliber': 'caliber', 'fire.caliber': 'caliber',
@@ -153,7 +155,9 @@ export const useTankopediaPersistent = create<TankopediaPersistent>()(
); );
export const useTankopediaTemporary = create<TankopediaTemporary>()( export const useTankopediaTemporary = create<TankopediaTemporary>()(
subscribeWithSelector<TankopediaTemporary>(() => ({})), subscribeWithSelector<TankopediaTemporary>(() => ({
skills: {},
})),
); );
export default function mutateTankopediaPersistent( export default function mutateTankopediaPersistent(
+80 -18
View File
@@ -1259,7 +1259,7 @@
"@radix-ui/react-primitive" "1.0.3" "@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-slot" "1.0.2" "@radix-ui/react-slot" "1.0.2"
"@radix-ui/[email protected]": "@radix-ui/[email protected]", "@radix-ui/react-compose-refs@^1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989" resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
integrity sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw== integrity sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==
@@ -1279,7 +1279,7 @@
"@radix-ui/react-use-callback-ref" "1.0.1" "@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-controllable-state" "1.0.1" "@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/[email protected]": "@radix-ui/[email protected]", "@radix-ui/react-context@^1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c" resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c"
integrity sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg== integrity sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==
@@ -1432,6 +1432,27 @@
aria-hidden "^1.1.1" aria-hidden "^1.1.1"
react-remove-scroll "2.5.5" react-remove-scroll "2.5.5"
"@radix-ui/react-navigation-menu@^1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.1.4.tgz#654151310c3f9a29afd19fb60ddc7977e54b8a3d"
integrity sha512-Cc+seCS3PmWmjI51ufGG7zp1cAAIRqHVw7C9LOA2TZ+R4hG6rDvHcTqIsEEFLmZO3zNVH72jOOE7kKNy8W+RtA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-collection" "1.0.3"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-direction" "1.0.1"
"@radix-ui/react-dismissable-layer" "1.0.5"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-use-previous" "1.0.1"
"@radix-ui/react-visually-hidden" "1.0.3"
"@radix-ui/react-popover@^1.0.7": "@radix-ui/react-popover@^1.0.7":
version "1.0.7" version "1.0.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.7.tgz#23eb7e3327330cb75ec7b4092d685398c1654e3c" resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.7.tgz#23eb7e3327330cb75ec7b4092d685398c1654e3c"
@@ -1488,7 +1509,7 @@
"@radix-ui/react-compose-refs" "1.0.1" "@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-use-layout-effect" "1.0.1" "@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/[email protected]": "@radix-ui/[email protected]", "@radix-ui/react-primitive@^1.0.3":
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0" resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0"
integrity sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g== integrity sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==
@@ -1496,6 +1517,15 @@
"@babel/runtime" "^7.13.10" "@babel/runtime" "^7.13.10"
"@radix-ui/react-slot" "1.0.2" "@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-progress@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-progress/-/react-progress-1.0.3.tgz#8380272fdc64f15cbf263a294dea70a7d5d9b4fa"
integrity sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-radio-group@^1.1.3": "@radix-ui/react-radio-group@^1.1.3":
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-radio-group/-/react-radio-group-1.1.3.tgz#3197f5dcce143bcbf961471bf89320735c0212d3" resolved "https://registry.yarnpkg.com/@radix-ui/react-radio-group/-/react-radio-group-1.1.3.tgz#3197f5dcce143bcbf961471bf89320735c0212d3"
@@ -1513,7 +1543,7 @@
"@radix-ui/react-use-previous" "1.0.1" "@radix-ui/react-use-previous" "1.0.1"
"@radix-ui/react-use-size" "1.0.1" "@radix-ui/react-use-size" "1.0.1"
"@radix-ui/[email protected]": "@radix-ui/[email protected]", "@radix-ui/react-roving-focus@^1.0.4":
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974" resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974"
integrity sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ== integrity sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==
@@ -1573,14 +1603,6 @@
aria-hidden "^1.1.1" aria-hidden "^1.1.1"
react-remove-scroll "2.5.5" react-remove-scroll "2.5.5"
"@radix-ui/react-separator@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.0.3.tgz#be5a931a543d5726336b112f465f58585c04c8aa"
integrity sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-slider@^1.1.2": "@radix-ui/react-slider@^1.1.2":
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slider/-/react-slider-1.1.2.tgz#330ff2a0e1f6c19aace76590004f229a7e8fbe6c" resolved "https://registry.yarnpkg.com/@radix-ui/react-slider/-/react-slider-1.1.2.tgz#330ff2a0e1f6c19aace76590004f229a7e8fbe6c"
@@ -1636,6 +1658,30 @@
"@radix-ui/react-roving-focus" "1.0.4" "@radix-ui/react-roving-focus" "1.0.4"
"@radix-ui/react-use-controllable-state" "1.0.1" "@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-toggle-group@^1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle-group/-/react-toggle-group-1.0.4.tgz#f5b5c8c477831b013bec3580c55e20a68179d6ec"
integrity sha512-Uaj/M/cMyiyT9Bx6fOZO0SAG4Cls0GptBWiBmBxofmDbNVnYYoyRWj/2M/6VCi/7qcXFWnHhRUfdfZFvvkuu8A==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-direction" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-roving-focus" "1.0.4"
"@radix-ui/react-toggle" "1.0.3"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/[email protected]":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle/-/react-toggle-1.0.3.tgz#aecb2945630d1dc5c512997556c57aba894e539e"
integrity sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-tooltip@^1.0.7": "@radix-ui/react-tooltip@^1.0.7":
version "1.0.7" version "1.0.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz#8f55070f852e7e7450cc1d9210b793d2e5a7686e" resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz#8f55070f852e7e7450cc1d9210b793d2e5a7686e"
@@ -1662,7 +1708,7 @@
dependencies: dependencies:
"@babel/runtime" "^7.13.10" "@babel/runtime" "^7.13.10"
"@radix-ui/[email protected]": "@radix-ui/[email protected]", "@radix-ui/react-use-controllable-state@^1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz#ecd2ced34e6330caf89a82854aa2f77e07440286" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz#ecd2ced34e6330caf89a82854aa2f77e07440286"
integrity sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA== integrity sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==
@@ -1723,10 +1769,10 @@
dependencies: dependencies:
"@babel/runtime" "^7.13.10" "@babel/runtime" "^7.13.10"
"@radix-ui/themes@^2.0.1": "@radix-ui/themes@^3.0.1":
version "2.0.3" version "3.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/themes/-/themes-2.0.3.tgz#c9e8f4029d73769f356dbe29c97ce3cbade601a8" resolved "https://registry.yarnpkg.com/@radix-ui/themes/-/themes-3.0.1.tgz#24b685166109f93bd2212ea6bc5114b9c0d943df"
integrity sha512-yaXQ8aWT2P1CQ0Xe6YCRD9HXsfMTvKkrIYkrc4aitCzhGTLS0sjtTqKmrxIWMVA+3DIbEuG9K/8aAMRJBhep8g== integrity sha512-w0+2XqaifspZlds2ealxgVU2xtZlJ0gSTgHuLd56ectYxnC4hkg/PEOP5kPHOpCQ+vtV9+LI+Cy4RWipGgg3aQ==
dependencies: dependencies:
"@radix-ui/colors" "^3.0.0" "@radix-ui/colors" "^3.0.0"
"@radix-ui/primitive" "^1.0.1" "@radix-ui/primitive" "^1.0.1"
@@ -1735,26 +1781,34 @@
"@radix-ui/react-aspect-ratio" "^1.0.3" "@radix-ui/react-aspect-ratio" "^1.0.3"
"@radix-ui/react-avatar" "^1.0.4" "@radix-ui/react-avatar" "^1.0.4"
"@radix-ui/react-checkbox" "^1.0.4" "@radix-ui/react-checkbox" "^1.0.4"
"@radix-ui/react-compose-refs" "^1.0.1"
"@radix-ui/react-context" "^1.0.1"
"@radix-ui/react-context-menu" "^2.1.5" "@radix-ui/react-context-menu" "^2.1.5"
"@radix-ui/react-dialog" "^1.0.5" "@radix-ui/react-dialog" "^1.0.5"
"@radix-ui/react-direction" "^1.0.1" "@radix-ui/react-direction" "^1.0.1"
"@radix-ui/react-dropdown-menu" "^2.0.6" "@radix-ui/react-dropdown-menu" "^2.0.6"
"@radix-ui/react-form" "^0.0.3" "@radix-ui/react-form" "^0.0.3"
"@radix-ui/react-hover-card" "^1.0.7" "@radix-ui/react-hover-card" "^1.0.7"
"@radix-ui/react-navigation-menu" "^1.1.4"
"@radix-ui/react-popover" "^1.0.7" "@radix-ui/react-popover" "^1.0.7"
"@radix-ui/react-portal" "^1.0.4" "@radix-ui/react-portal" "^1.0.4"
"@radix-ui/react-primitive" "^1.0.3"
"@radix-ui/react-progress" "^1.0.3"
"@radix-ui/react-radio-group" "^1.1.3" "@radix-ui/react-radio-group" "^1.1.3"
"@radix-ui/react-roving-focus" "^1.0.4"
"@radix-ui/react-scroll-area" "^1.0.5" "@radix-ui/react-scroll-area" "^1.0.5"
"@radix-ui/react-select" "^2.0.0" "@radix-ui/react-select" "^2.0.0"
"@radix-ui/react-separator" "^1.0.3"
"@radix-ui/react-slider" "^1.1.2" "@radix-ui/react-slider" "^1.1.2"
"@radix-ui/react-slot" "^1.0.2" "@radix-ui/react-slot" "^1.0.2"
"@radix-ui/react-switch" "^1.0.3" "@radix-ui/react-switch" "^1.0.3"
"@radix-ui/react-tabs" "^1.0.4" "@radix-ui/react-tabs" "^1.0.4"
"@radix-ui/react-toggle-group" "^1.0.4"
"@radix-ui/react-tooltip" "^1.0.7" "@radix-ui/react-tooltip" "^1.0.7"
"@radix-ui/react-use-callback-ref" "^1.0.1" "@radix-ui/react-use-callback-ref" "^1.0.1"
"@radix-ui/react-use-controllable-state" "^1.0.1"
"@radix-ui/react-visually-hidden" "^1.0.3" "@radix-ui/react-visually-hidden" "^1.0.3"
classnames "^2.3.2" classnames "^2.3.2"
react-remove-scroll-bar "2.3.4"
"@react-spring/animated@~9.6.1": "@react-spring/animated@~9.6.1":
version "9.6.1" version "9.6.1"
@@ -4813,6 +4867,14 @@ react-reconciler@^0.27.0:
loose-envify "^1.1.0" loose-envify "^1.1.0"
scheduler "^0.21.0" scheduler "^0.21.0"
[email protected]:
version "2.3.4"
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz#53e272d7a5cb8242990c7f144c44d8bd8ab5afd9"
integrity sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==
dependencies:
react-style-singleton "^2.2.1"
tslib "^2.0.0"
react-remove-scroll-bar@^2.3.3: react-remove-scroll-bar@^2.3.3:
version "2.3.5" version "2.3.5"
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.5.tgz#cd2543b3ed7716c7c5b446342d21b0e0b303f47c" resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.5.tgz#cd2543b3ed7716c7c5b446342d21b0e0b303f47c"