mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 10:47:04 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c54ceb944e | ||
|
|
37a2ba3566 | ||
|
|
caff7bb6d0 | ||
|
|
01c491aee0 | ||
|
|
fd408d6777 | ||
|
|
6d1ed1e56e | ||
|
|
46f9d605f7 | ||
|
|
a65ea42b81 | ||
|
|
40f8c6a915 | ||
|
|
4678359f36 | ||
|
|
f91bd63646 | ||
|
|
c1872262f5 | ||
|
|
082e112758 | ||
|
|
4cb7d50eb5 | ||
|
|
8c050add23 | ||
|
|
2610697d86 | ||
|
|
64f1de37ad | ||
|
|
58f6862279 | ||
|
|
b47f6fdb32 | ||
|
|
ce5a53d0ff | ||
|
|
6ee1fa13d3 | ||
|
|
196f780bb3 | ||
|
|
088d5a2941 | ||
|
|
47abdaed6f | ||
|
|
30f6055a69 | ||
|
|
9d4b750dfd | ||
|
|
d1308bc625 | ||
|
|
db40256bb4 | ||
|
|
70f1ebadfc | ||
|
|
43adaf85f5 | ||
|
|
8d5e78a510 | ||
|
|
9d6d893373 | ||
|
|
9840982aa2 | ||
|
|
8e216ebe46 | ||
|
|
889ae39e3b | ||
|
|
265eff98bf | ||
|
|
ef7d1dcfb3 | ||
|
|
607ebc0025 | ||
|
|
0f0bc9569d | ||
|
|
169e892678 | ||
|
|
fe249e2704 | ||
|
|
b8be5d5e38 | ||
|
|
77d6c93b90 | ||
|
|
4fedaf1cea | ||
|
|
b2220f885e | ||
|
|
3181f9c4e7 | ||
|
|
153d0d326d | ||
|
|
7f9b349de6 | ||
|
|
b3a50f2b27 | ||
|
|
fb49628375 | ||
|
|
394a74c3a0 | ||
|
|
370bf826c0 | ||
|
|
38ca1499e2 | ||
|
|
2deced699b | ||
|
|
6405af7ec7 | ||
|
|
fa2bdfca12 | ||
|
|
872e7ce704 | ||
|
|
110d3c1007 | ||
|
|
7a27b70c29 | ||
|
|
2ffe5a137c | ||
|
|
bf0246ec26 | ||
|
|
b71feca802 | ||
|
|
da1fbeeca4 | ||
|
|
53174db351 | ||
|
|
19d2ad4cb2 | ||
|
|
3325536bbc | ||
|
|
4220177b02 | ||
|
|
796aca21cf | ||
|
|
3510768786 | ||
|
|
6197642d5e | ||
|
|
b60f8bfd57 | ||
|
|
9437d16908 | ||
|
|
de9077e0d2 | ||
|
|
4a6e946809 | ||
|
|
935c578ef6 | ||
|
|
0e06ff52ba | ||
|
|
fa4590d8f6 | ||
|
|
5620b118f4 | ||
|
|
d53b31ca40 | ||
|
|
a4a5d53f66 | ||
|
|
9f4c8c9088 | ||
|
|
472172c504 | ||
|
|
bc08656806 | ||
|
|
bd6f1f3570 | ||
|
|
716b446272 | ||
|
|
96a9e5b938 | ||
|
|
83e31e6352 | ||
|
|
49d0dbe86d | ||
|
|
e141a7ca3f | ||
|
|
adad668157 | ||
|
|
267f23cad8 | ||
|
|
f605801555 | ||
|
|
19e81ff78a | ||
|
|
9232a95e00 | ||
|
|
6dcef6b199 | ||
|
|
e355bd10b1 | ||
|
|
ceb4ed52c0 | ||
|
|
227750efad | ||
|
|
8a76558f01 | ||
|
|
db7c865c76 | ||
|
|
8e35e0d6d9 | ||
|
|
c9d50f39b5 | ||
|
|
77ac893a7c | ||
|
|
ac60a436f1 | ||
|
|
2ba9f0f4c8 | ||
|
|
47c3aa4842 | ||
|
|
d2edf4b89d | ||
|
|
11a0c246dc | ||
|
|
cba93b3d55 | ||
|
|
fa9b6c6d97 | ||
|
|
bef48eca62 | ||
|
|
e42ff03c4b | ||
|
|
ec628e4a0d | ||
|
|
8865a48936 | ||
|
|
d3a9c3bd95 | ||
|
|
90bff59af3 | ||
|
|
9efe9860ae | ||
|
|
f6a418499b | ||
|
|
66740bcb8d | ||
|
|
035e257926 | ||
|
|
e63549158b | ||
|
|
7fb64e690f | ||
|
|
54798c30b1 | ||
|
|
4c0f7b61e4 | ||
|
|
206ac373d2 | ||
|
|
c626e84f7d | ||
|
|
90ec5fa80b | ||
|
|
d496b46b6f | ||
|
|
7af1fdcf6b | ||
|
|
199e156e53 | ||
|
|
f5578ee788 | ||
|
|
c0a3628b3b | ||
|
|
fa8922e0a6 | ||
|
|
bf6991ce06 | ||
|
|
7f49245f15 | ||
|
|
f55313e0cd | ||
|
|
69c388f243 | ||
|
|
35788657a8 | ||
|
|
d531698867 | ||
|
|
cc4177bf14 | ||
|
|
d377067441 | ||
|
|
b2f026c212 | ||
|
|
07cdb0788c | ||
|
|
01f919ddd3 | ||
|
|
4b1fda9680 | ||
|
|
79d03233ae | ||
|
|
522b40aff1 | ||
|
|
06c878dd2c | ||
|
|
f5c589373d | ||
|
|
2689568650 | ||
|
|
fabf649608 | ||
|
|
080af86e1b | ||
|
|
e5b58cd5ff | ||
|
|
3500389628 | ||
|
|
1129f0d60b | ||
|
|
b3a6a47a6a | ||
|
|
bdb3844bcc | ||
|
|
f0bb25efc1 |
No files matched your search
@@ -2,10 +2,10 @@ name: CD Build Assets
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: dev
|
||||
branches: [dev, main]
|
||||
workflow_dispatch:
|
||||
# schedule:
|
||||
# - cron: '0 0 * * *'
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -50,4 +50,5 @@ jobs:
|
||||
run: |
|
||||
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
|
||||
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
|
||||
$env:DISCORD_BLITZ_DB = "${{ secrets.DISCORD_BLITZ_DB }}"
|
||||
yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }}
|
||||
@@ -35,4 +35,4 @@ jobs:
|
||||
run: yarn
|
||||
|
||||
- name: Generate leaderboard
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} yarn publish:ratingsleaderboard --region=asia --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingsleaderboard --region=asia --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
||||
@@ -35,4 +35,4 @@ jobs:
|
||||
run: yarn
|
||||
|
||||
- name: Generate leaderboard
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} yarn publish:ratingsleaderboard --region=eu --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingsleaderboard --region=eu --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
||||
@@ -35,4 +35,4 @@ jobs:
|
||||
run: yarn
|
||||
|
||||
- name: Generate leaderboard
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} yarn publish:ratingsleaderboard --region=na --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingsleaderboard --region=na --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
||||
@@ -1,32 +0,0 @@
|
||||
name: CI Version
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
host:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Use node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Yarn
|
||||
run: npm install --location=global yarn
|
||||
|
||||
- name: Use node_modules cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
|
||||
- name: Generate leaderboard
|
||||
run: yarn lint:version
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "submodules/closedkrieg"]
|
||||
path = submodules/closedkrieg
|
||||
url = https://github.com/tresabhi/closedkrieg
|
||||
Vendored
+9
-1
@@ -5,6 +5,7 @@
|
||||
"accountinfo",
|
||||
"aeson",
|
||||
"alphatest",
|
||||
"APAC",
|
||||
"apcr",
|
||||
"artefacts",
|
||||
"atgm",
|
||||
@@ -40,8 +41,10 @@
|
||||
"donotremove",
|
||||
"drei",
|
||||
"Droodles",
|
||||
"dvpd",
|
||||
"dvpl",
|
||||
"DVPLCRC",
|
||||
"dvpm",
|
||||
"dxgi",
|
||||
"Everytime",
|
||||
"FASTNAME",
|
||||
@@ -54,6 +57,7 @@
|
||||
"glsl",
|
||||
"GLTF",
|
||||
"haha",
|
||||
"highp",
|
||||
"iife",
|
||||
"immer",
|
||||
"Inclusivity",
|
||||
@@ -69,6 +73,7 @@
|
||||
"lods",
|
||||
"metallicness",
|
||||
"minitelrose",
|
||||
"Minotauro",
|
||||
"MIPMAPCOUNT",
|
||||
"modeltransform",
|
||||
"MURICA",
|
||||
@@ -128,6 +133,7 @@
|
||||
"TYPELESS",
|
||||
"ubjson",
|
||||
"Unicum",
|
||||
"UNLOCALIZED",
|
||||
"UNORM",
|
||||
"Unregistering",
|
||||
"vickers",
|
||||
@@ -137,6 +143,7 @@
|
||||
"webp",
|
||||
"winrate",
|
||||
"wireframe",
|
||||
"WOTB",
|
||||
"wotblitz",
|
||||
"xffn",
|
||||
"xkdkw",
|
||||
@@ -145,5 +152,6 @@
|
||||
"zustand"
|
||||
],
|
||||
"deno.enable": false,
|
||||
"deno.unstable": false
|
||||
"deno.unstable": false,
|
||||
"cSpell.enableFiletypes": ["glsl"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
files:
|
||||
- source: /src/lang/en-US.json
|
||||
translation: /src/lang/%locale%.json
|
||||
@@ -0,0 +1,28 @@
|
||||
# Blitzkrieg 1.16.1
|
||||
|
||||
Armor sandbox gains 25% rendering speed and 40% memory efficiency with added accuracy and features.
|
||||
|
||||
## Features
|
||||
|
||||
- Multiple layers of spaced armors are now calculated properly by the visual armor highlighting
|
||||
- Added opaque mode to view armor highlighting without any transparency
|
||||
- Added breakdown on layers of armor penetrated
|
||||
- Penetration of each layer is shown represented by a color and an index
|
||||
- Green: penetration
|
||||
- Red: blocked
|
||||
- Yellow: ricochet
|
||||
- This also shows layers encountered after a ricochet
|
||||
|
||||
## Fixes
|
||||
|
||||
- Accuracy now doesn't drop the further the shot is placed from the origin
|
||||
- HE shells no longer loose penetration like how HEAT shells do when traveling between layers of armor
|
||||
- Persistent settings are now merged in the correct order making them truly persistent now
|
||||
- Enhanced armor and calibrated shells are now properly synced from the get go
|
||||
|
||||
## Technical Changes
|
||||
|
||||
- Spaced armor and external module scenes have been merged
|
||||
- They now use additive blending
|
||||
- Colors are now normalized by the penetration value instead of maximum armor plate thickness
|
||||
- Shell normalization now doesn't violate arccosine's domain
|
||||
@@ -0,0 +1,36 @@
|
||||
# Blitzkrieg 1.16.2
|
||||
|
||||
¡Hola desde el mundo del español!
|
||||
|
||||
## Features
|
||||
|
||||
- Added translations for Spanish to the bot
|
||||
- Your language will be inferred automatically from your Discord settings
|
||||
- Any untranslated languages and words we forgot to translate will be left in English
|
||||
- If you have your language set to Spanish and see English in any places, please let us know on [the official Discord server](https://discord.gg/nDt7AjGJQH)
|
||||
- All `/about` commands except for `commands` remain in English
|
||||
- Tank names are still in English
|
||||
- The website remains unchanged
|
||||
- The title bar (featured as the big text on top of most commands) has now been made more compact
|
||||
|
||||
## Changes
|
||||
|
||||
- "Tank type" is now called "tank class"
|
||||
- Removed `/player-achievements` and `search-tanks`
|
||||
- They will be reintroduced in the future in a far better way
|
||||
- Also removed a `SKLLD` server exclusive `/eligible` command
|
||||
- This will be generalized for all clans in the future
|
||||
|
||||
## Fixes
|
||||
|
||||
- Fixed the red glowing floor underneath the tank sandbox
|
||||
- This has been replaced with a static image
|
||||
- The refresh button is now properly removed when lacking permissions
|
||||
- Tank names that start with a number are now properly inferred from command inputs
|
||||
|
||||
## Technical Changes
|
||||
|
||||
- Command registration is now error tolerant meaning that if a single command fails to initiate, it will be discarded until the next restart
|
||||
- We are currently looking into how we can allow the command to retry registration after a fixed amount of time
|
||||
- Due to the implications of the code added which can be very easily exploited by hackers, a single file has been added to the repository as a closed source submodule and will remain closed unless a third party open-source implementation is made public
|
||||
- Removed preview commands
|
||||
@@ -0,0 +1,36 @@
|
||||
# Blitzkrieg 1.16.2
|
||||
|
||||
¡Hola desde el mundo del español!
|
||||
|
||||
## Features
|
||||
|
||||
- Added translations for Spanish to the bot
|
||||
- Your language will be inferred automatically from your Discord settings
|
||||
- Any untranslated languages and words we forgot to translate will be left in English
|
||||
- If you have your language set to Spanish and see English in any places, please let us know on [the official Discord server](https://discord.gg/nDt7AjGJQH)
|
||||
- All `/about` commands except for `commands` remain in English
|
||||
- Tank names are still in English
|
||||
- The website remains unchanged
|
||||
- The title bar (featured as the big text on top of most commands) has now been made more compact
|
||||
|
||||
## Changes
|
||||
|
||||
- "Tank type" is now called "tank class"
|
||||
- Removed `/player-achievements` and `search-tanks`
|
||||
- They will be reintroduced in the future in a far better way
|
||||
- Also removed a `SKLLD` server exclusive `/eligible` command
|
||||
- This will be generalized for all clans in the future
|
||||
|
||||
## Fixes
|
||||
|
||||
- Fixed the red glowing floor underneath the tank sandbox
|
||||
- This has been replaced with a static image
|
||||
- The refresh button is now properly removed when lacking permissions
|
||||
- Tank names that start with a number are now properly inferred from command inputs
|
||||
|
||||
## Technical Changes
|
||||
|
||||
- Command registration is now error tolerant meaning that if a single command fails to initiate, it will be discarded until the next restart
|
||||
- We are currently looking into how we can allow the command to retry registration after a fixed amount of time
|
||||
- Due to the implications of the code added which can be very easily exploited by hackers, a single file has been added to the repository as a closed source submodule and will remain closed unless a third party open-source implementation is made public
|
||||
- Removed preview commands
|
||||
@@ -0,0 +1,21 @@
|
||||
# Blitzkrieg 1.16.3
|
||||
|
||||
Further polishing and corrections for the Tankopedia.
|
||||
|
||||
## Features
|
||||
|
||||
- Added a few more stats to view for the Tankopedia
|
||||
- Added the count of shells in the magazine for auto-reloaders
|
||||
- The optimal shell index was moved below this stats
|
||||
- Shell reloads for auto-reloaders now abide crew stats
|
||||
- Intra-clip is now also shown for auto-reloaders
|
||||
- Added 1 more decimal place worth of precision when viewing and changing gun pitch and yaw
|
||||
|
||||
## Fixes
|
||||
|
||||
- "Inter-clip" is now correctly spelled as "intra-clip"
|
||||
- Tanks with initial gun pitches (like the Minotauro) are now handled correctly
|
||||
- The stats now reflect the fact that the gun is naturally pitched downwards
|
||||
- The view model now compensates for the natural pitch
|
||||
- Tanks with the improved vertical stabilizer equipment are now handled correctly
|
||||
- The view model now allows you to pitch the gun further as intended when the equipment is on
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "blitzkrieg",
|
||||
"version": "1.16.0",
|
||||
"version": "1.16.2",
|
||||
"description": "🎉 Tools for everything World of Tanks Blitz",
|
||||
"main": "src/server.ts",
|
||||
"type": "module",
|
||||
@@ -25,7 +25,8 @@
|
||||
"lint:version": "vite-node --script scripts/checkVersionMissMatch",
|
||||
"knip": "knip",
|
||||
"update": "yarn upgrade --latest",
|
||||
"setup:depotdownloader": "vite-node --script scripts/setupDepotDownloader"
|
||||
"setup:depotdownloader": "vite-node --script scripts/setupDepotDownloader",
|
||||
"tunnel": "cloudflared tunnel --url http://localhost:3000"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -2,10 +2,11 @@ import { readdir } from 'fs/promises';
|
||||
import { argv } from 'process';
|
||||
|
||||
const isDepot = argv.includes('--depot');
|
||||
export const WOTB_WIN32_DEPOT = 444202;
|
||||
export const DATA = isDepot
|
||||
? await (async () => {
|
||||
const [installationVersion] = await readdir('depots/444202');
|
||||
return `depots/444202/${installationVersion}/Data`;
|
||||
const [installationVersion] = await readdir(`depots/${WOTB_WIN32_DEPOT}`);
|
||||
return `depots/${WOTB_WIN32_DEPOT}/${installationVersion}/Data`;
|
||||
})()
|
||||
: 'C:/Program Files (x86)/Steam/steamapps/common/World of Tanks Blitz/Data';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readdir } from 'fs/promises';
|
||||
import { parse as parsePath } from 'path';
|
||||
import { Vector3Tuple } from 'three';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { TankType } from '../../src/components/Tanks';
|
||||
import { TankClass } from '../../src/components/Tanks';
|
||||
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
|
||||
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
|
||||
import { toUniqueId } from '../../src/core/blitz/toUniqueId';
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
import { DATA, POI } from './constants';
|
||||
import { TankParameters } from './tankIcons';
|
||||
|
||||
type BlitzTankType = 'AT-SPG' | 'lightTank' | 'mediumTank' | 'heavyTank';
|
||||
interface Strings {
|
||||
[key: string]: string;
|
||||
}
|
||||
@@ -306,19 +305,6 @@ type ConsumablesVehicleFilter =
|
||||
| { name: string }
|
||||
| { extendedTags: string };
|
||||
|
||||
const consumableProvisionEffectSuffixes = [
|
||||
'Increase',
|
||||
'Factor',
|
||||
'Bias',
|
||||
'Coef',
|
||||
'Percent',
|
||||
];
|
||||
const blitzTankTypeToBlitzkrieg: Record<BlitzTankType, TankType> = {
|
||||
'AT-SPG': 'tankDestroyer',
|
||||
lightTank: 'light',
|
||||
mediumTank: 'medium',
|
||||
heavyTank: 'heavy',
|
||||
};
|
||||
const blitzShellKindToBLitzkrieg: Record<ShellKind, ShellType> = {
|
||||
ARMOR_PIERCING: 'ap',
|
||||
ARMOR_PIERCING_CR: 'ap_cr',
|
||||
@@ -502,7 +488,7 @@ export async function definitions(production: boolean) {
|
||||
? 'premium'
|
||||
: 'researchable',
|
||||
tier: tank.level as Tier,
|
||||
type: blitzTankTypeToBlitzkrieg[tankTags[0] as BlitzTankType],
|
||||
class: tankTags[0] as TankClass,
|
||||
testing: tankTags.includes('testTank') ? true : undefined,
|
||||
price: tankPrice,
|
||||
camouflage: {
|
||||
@@ -688,7 +674,7 @@ export async function definitions(production: boolean) {
|
||||
: turretGunEntry.reloadTime;
|
||||
const gunClipCount =
|
||||
gunType === 'regular' ? undefined : turretGunEntry.clip!.count;
|
||||
const gunInterClip =
|
||||
const gunIntraClip =
|
||||
gunType === 'regular'
|
||||
? undefined
|
||||
: 60 / turretGunEntry.clip!.rate;
|
||||
@@ -736,7 +722,7 @@ export async function definitions(production: boolean) {
|
||||
type: gunType,
|
||||
reload: gunReload,
|
||||
count: gunClipCount,
|
||||
interClip: gunInterClip,
|
||||
intraClip: gunIntraClip,
|
||||
camouflageLoss:
|
||||
typeof turretGunEntry.invisibilityFactorAtShot === 'number'
|
||||
? turretGunEntry.invisibilityFactorAtShot
|
||||
|
||||
@@ -311,7 +311,7 @@ export default function SessionPage({ naked = true }: SessionPageProps) {
|
||||
tankDefinitions?.name ?? `Unknown tank ${stats.tank_id}`
|
||||
}
|
||||
type="tank"
|
||||
tankType={tankDefinitions?.type}
|
||||
tankType={tankDefinitions?.class}
|
||||
treeType={tankDefinitions?.treeType}
|
||||
stats={session.customColumns.map((customColumn) => {
|
||||
const rowStat = rowStats[customColumn.display];
|
||||
|
||||
+49
-46
@@ -57,6 +57,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingTop: isEmbed ? 0 : '3.25rem',
|
||||
backgroundColor: isEmbed ? 'transparent' : undefined,
|
||||
}}
|
||||
>
|
||||
<Analytics />
|
||||
@@ -100,59 +101,61 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
|
||||
<Flex direction="column">{children}</Flex>
|
||||
|
||||
<PageWrapper>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://ko-fi.com/tresabhi"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: mintTheme.colors.componentInteractive,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
{!isEmbed && (
|
||||
<PageWrapper>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://ko-fi.com/tresabhi"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="1"
|
||||
align="center"
|
||||
style={{
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: mintTheme.colors.componentInteractive,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Text size="4" color="mint">
|
||||
Blitzkrieg. Free forever. For everyone.
|
||||
</Text>
|
||||
<Text color="mint">
|
||||
<b>Consider supporting today 🍀</b>
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Text
|
||||
color="mint"
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Flex>
|
||||
<CaretRightIcon width={32} height={32} />
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="1"
|
||||
style={{
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Text size="4" color="mint">
|
||||
Blitzkrieg. Free forever. For everyone.
|
||||
</Text>
|
||||
<Text color="mint">
|
||||
<b>Consider supporting today 🍀</b>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Text>
|
||||
|
||||
<SkyLine
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 'min(8vw, 15%)',
|
||||
top: '5%',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</a>
|
||||
</PageWrapper>
|
||||
<Text
|
||||
color="mint"
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Flex>
|
||||
<CaretRightIcon width={32} height={32} />
|
||||
</Flex>
|
||||
</Text>
|
||||
|
||||
<SkyLine
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 'min(8vw, 15%)',
|
||||
top: '5%',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</a>
|
||||
</PageWrapper>
|
||||
)}
|
||||
</Theme>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -20,7 +20,11 @@ import PageWrapper from '../../../components/PageWrapper';
|
||||
import { Skeleton } from '../../../components/Skeleton';
|
||||
import { LEAGUES } from '../../../constants/leagues';
|
||||
import { FIRST_ARCHIVED_RATINGS_SEASON } from '../../../constants/ratings';
|
||||
import { REGIONS, REGION_NAMES, Region } from '../../../constants/regions';
|
||||
import {
|
||||
REGIONS,
|
||||
Region,
|
||||
UNLOCALIZED_REGION_NAMES,
|
||||
} from '../../../constants/regions';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
|
||||
import fetchBlitz from '../../../core/blitz/fetchBlitz';
|
||||
import { getClanAccountInfo } from '../../../core/blitz/getClanAccountInfo';
|
||||
@@ -556,7 +560,7 @@ export default function Page() {
|
||||
<Select.Content>
|
||||
{REGIONS.map((region) => (
|
||||
<Select.Item key={region} value={region}>
|
||||
{REGION_NAMES[region]}
|
||||
{UNLOCALIZED_REGION_NAMES[region]}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { debounce } from 'lodash';
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import PageWrapper from '../../../components/PageWrapper';
|
||||
import { REGION_NAMES } from '../../../constants/regions';
|
||||
import { UNLOCALIZED_REGION_NAMES } from '../../../constants/regions';
|
||||
import searchPlayersAcrossRegions, {
|
||||
AccountListWithServer,
|
||||
} from '../../../core/blitz/searchPlayersAcrossRegions';
|
||||
@@ -107,7 +107,7 @@ export default function Page() {
|
||||
setShowSearchResults(false);
|
||||
setSession(region, id, nickname);
|
||||
}}
|
||||
shortcut={REGION_NAMES[region]}
|
||||
shortcut={UNLOCALIZED_REGION_NAMES[region]}
|
||||
>
|
||||
{nickname}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { debounce } from 'lodash';
|
||||
import { use, useEffect, useRef, useState } from 'react';
|
||||
import { lerp } from 'three/src/math/MathUtils';
|
||||
import { ShellButton } from '../../../../../../components/ModuleButtons/ShellButton';
|
||||
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
|
||||
import { coefficient } from '../../../../../../core/blitzkrieg/coefficient';
|
||||
@@ -56,6 +57,7 @@ export function Characteristics() {
|
||||
turretModelDefinition.boundingBox,
|
||||
),
|
||||
);
|
||||
const gunDefaultPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
|
||||
const weight =
|
||||
tank.weight + engine.weight + track.weight + turret.weight + gun.weight;
|
||||
const weightTons = weight / 1000;
|
||||
@@ -113,7 +115,7 @@ export function Characteristics() {
|
||||
// TODO: add max rolls
|
||||
[hasTungsten, 0.15],
|
||||
);
|
||||
const interClipCoefficient = coefficient([hasShellReloadBoost, -0.3]);
|
||||
const intraClipCoefficient = coefficient([hasShellReloadBoost, -0.3]);
|
||||
const shellVelocityCoefficient = coefficient(
|
||||
[hasSupercharger, 0.3],
|
||||
[hasImprovedGunPowder, 0.3],
|
||||
@@ -136,12 +138,13 @@ export function Characteristics() {
|
||||
]);
|
||||
const enginePowerCoefficient = coefficient(
|
||||
[
|
||||
hasEngineAccelerator && (tank.type === 'light' || tank.type === 'medium'),
|
||||
hasEngineAccelerator &&
|
||||
(tank.class === 'lightTank' || tank.class === 'mediumTank'),
|
||||
0.05,
|
||||
],
|
||||
[
|
||||
hasEngineAccelerator &&
|
||||
(tank.type === 'heavy' || tank.type === 'tankDestroyer'),
|
||||
(tank.class === 'heavyTank' || tank.class === 'AT-SPG'),
|
||||
0.07,
|
||||
],
|
||||
[hasEnginePowerBoost, 0.2],
|
||||
@@ -163,7 +166,7 @@ export function Characteristics() {
|
||||
const viewRangeCoefficient =
|
||||
coefficient([
|
||||
hasImprovedOptics,
|
||||
tank.type === 'tankDestroyer' ? 0.05 : tank.type === 'heavy' ? 0.07 : 0.1,
|
||||
tank.class === 'AT-SPG' ? 0.05 : tank.class === 'heavyTank' ? 0.07 : 0.1,
|
||||
]) * coefficient([true, progressiveStat(commanderMastery)]);
|
||||
const fireChanceCoefficient = coefficient([hasProtectiveKit, -0.2]);
|
||||
|
||||
@@ -180,38 +183,26 @@ export function Characteristics() {
|
||||
const camouflageSumMoving = sum(
|
||||
[
|
||||
hasCamouflageNet,
|
||||
tank.type === 'heavy'
|
||||
? 0.03
|
||||
: tank.type === 'tankDestroyer'
|
||||
? 0.07
|
||||
: 0.05,
|
||||
tank.class === 'heavyTank' ? 0.03 : tank.class === 'AT-SPG' ? 0.07 : 0.05,
|
||||
],
|
||||
[
|
||||
camouflage,
|
||||
tank.type === 'tankDestroyer'
|
||||
? 0.04
|
||||
: tank.type === 'heavy'
|
||||
? 0.03
|
||||
: 0.02,
|
||||
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
|
||||
],
|
||||
);
|
||||
const camouflageSumStill = sum(
|
||||
[
|
||||
hasCamouflageNet,
|
||||
2 *
|
||||
(tank.type === 'heavy'
|
||||
(tank.class === 'heavyTank'
|
||||
? 0.03
|
||||
: tank.type === 'tankDestroyer'
|
||||
: tank.class === 'AT-SPG'
|
||||
? 0.07
|
||||
: 0.05),
|
||||
],
|
||||
[
|
||||
camouflage,
|
||||
tank.type === 'tankDestroyer'
|
||||
? 0.04
|
||||
: tank.type === 'heavy'
|
||||
? 0.03
|
||||
: 0.02,
|
||||
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -221,7 +212,7 @@ export function Characteristics() {
|
||||
shell,
|
||||
damageCoefficient,
|
||||
reloadCoefficient,
|
||||
interClipCoefficient,
|
||||
intraClipCoefficient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -229,6 +220,17 @@ export function Characteristics() {
|
||||
penetrationDistanceInput.current.value = `${penetrationDistance}`;
|
||||
}
|
||||
}, [penetrationDistance]);
|
||||
useEffect(() => {
|
||||
mutateDuel((draft) => {
|
||||
[draft.protagonist!.pitch, draft.protagonist!.yaw] = applyPitchYawLimits(
|
||||
draft.protagonist!.pitch,
|
||||
draft.protagonist!.yaw,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
});
|
||||
}, [hasImprovedVerticalStabilizer]);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="8" style={{ width: '100%' }}>
|
||||
@@ -266,7 +268,7 @@ export function Characteristics() {
|
||||
<>
|
||||
<InfoWithDelta decimals={0} indent name="Maximum" unit="hp / min">
|
||||
{gun.reload.at(-1)! < gun.reload.at(-2)!
|
||||
? (shell.damage.armor / (gun.reload.at(-1)! + gun.interClip)) *
|
||||
? (shell.damage.armor / (gun.reload.at(-1)! + gun.intraClip)) *
|
||||
60
|
||||
: (shell.damage.armor / gun.reload[0]) * 60}
|
||||
</InfoWithDelta>
|
||||
@@ -277,12 +279,12 @@ export function Characteristics() {
|
||||
unit="hp / min"
|
||||
>
|
||||
{gun.reload.at(-1)! < gun.reload.at(-2)!
|
||||
? (shell.damage.armor / (gun.reload.at(-1)! + gun.interClip)) *
|
||||
? (shell.damage.armor / (gun.reload.at(-1)! + gun.intraClip)) *
|
||||
(60 -
|
||||
(gun.reload.slice(0, -1).length - 1) * gun.interClip) +
|
||||
(gun.reload.slice(0, -1).length - 1) * gun.intraClip) +
|
||||
shell.damage.armor * gun.reload.slice(0, -1).length
|
||||
: (shell.damage.armor / (gun.reload[0] + gun.interClip)) *
|
||||
(60 - (gun.reload.slice(1).length - 1) * gun.interClip) +
|
||||
: (shell.damage.armor / (gun.reload[0] + gun.intraClip)) *
|
||||
(60 - (gun.reload.slice(1).length - 1) * gun.intraClip) +
|
||||
shell.damage.armor * gun.reload.slice(1).length}
|
||||
</InfoWithDelta>
|
||||
<Info
|
||||
@@ -296,14 +298,15 @@ export function Characteristics() {
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<Info indent name="Optimal shell index">
|
||||
{gun.reload.at(-1)! < gun.reload.at(-2)! ? gun.reload.length : 1}
|
||||
</Info>
|
||||
</>
|
||||
)}
|
||||
{gun.type === 'autoReloader' ? (
|
||||
<>
|
||||
<Info name="Reload" unit="s" />
|
||||
<InfoWithDelta name="Shells">{gun.reload.length}</InfoWithDelta>
|
||||
<Info indent name="Most optimal">
|
||||
{gun.reload.at(-1)! < gun.reload.at(-2)! ? gun.reload.length : 1}
|
||||
</Info>
|
||||
<Info name="Shell reloads" unit="s" />
|
||||
{gun.reload.map((reload, index) => (
|
||||
<InfoWithDelta
|
||||
key={index}
|
||||
@@ -312,7 +315,7 @@ export function Characteristics() {
|
||||
decimals={2}
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{reload}
|
||||
{reload * reloadCoefficient}
|
||||
</InfoWithDelta>
|
||||
))}
|
||||
</>
|
||||
@@ -326,17 +329,18 @@ export function Characteristics() {
|
||||
{gun.reload * reloadCoefficient}
|
||||
</InfoWithDelta>
|
||||
)}
|
||||
{gun.type === 'autoLoader' && (
|
||||
<InfoWithDelta
|
||||
indent
|
||||
decimals={2}
|
||||
name="Inter-clip reload"
|
||||
unit="s"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.interClip * interClipCoefficient}
|
||||
</InfoWithDelta>
|
||||
)}
|
||||
{gun.type === 'autoLoader' ||
|
||||
(gun.type === 'autoReloader' && (
|
||||
<InfoWithDelta
|
||||
indent
|
||||
decimals={2}
|
||||
name="Intra-clip"
|
||||
unit="s"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.intraClip * intraClipCoefficient}
|
||||
</InfoWithDelta>
|
||||
))}
|
||||
<InfoWithDelta name="Caliber" decimals={0} unit="mm">
|
||||
{shell.caliber}
|
||||
</InfoWithDelta>
|
||||
@@ -468,10 +472,12 @@ export function Characteristics() {
|
||||
<Info name="Gun flexibility" unit="°" />
|
||||
<InfoWithDelta decimals={1} indent name="Depression">
|
||||
{gunModelDefinition.pitch.max +
|
||||
gunDefaultPitch +
|
||||
(hasImprovedVerticalStabilizer ? 3 : 0)}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="Elevation">
|
||||
{-gunModelDefinition.pitch.min +
|
||||
{-gunModelDefinition.pitch.min -
|
||||
gunDefaultPitch +
|
||||
(hasImprovedVerticalStabilizer ? 3 : 0)}
|
||||
</InfoWithDelta>
|
||||
{gunModelDefinition.pitch.front && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react';
|
||||
import { Vector3 } from 'three';
|
||||
import { OrbitControls as OrbitControlsClass } from 'three-stdlib';
|
||||
import { applyPitchYawLimits } from '../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { hasEquipment } from '../../../../../core/blitzkrieg/hasEquipment';
|
||||
import { modelDefinitions } from '../../../../../core/blitzkrieg/modelDefinitions';
|
||||
import { Pose, poseEvent } from '../../../../../core/blitzkrieg/pose';
|
||||
import { useAwait } from '../../../../../hooks/useAwait';
|
||||
@@ -67,7 +68,9 @@ export function Controls() {
|
||||
},
|
||||
);
|
||||
|
||||
function handlePoseEvent(event: Pose) {
|
||||
async function handlePoseEvent(event: Pose) {
|
||||
const hasImprovedVerticalStabilizer = await hasEquipment(122);
|
||||
|
||||
switch (event) {
|
||||
case Pose.HullDown: {
|
||||
const [pitch] = applyPitchYawLimits(
|
||||
@@ -75,6 +78,7 @@ export function Controls() {
|
||||
0,
|
||||
protagonistGunModelDefinition.pitch,
|
||||
protagonistTurretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
camera.position
|
||||
@@ -106,6 +110,7 @@ export function Controls() {
|
||||
0,
|
||||
protagonistGunModelDefinition.pitch,
|
||||
protagonistTurretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
camera.position
|
||||
|
||||
@@ -4,12 +4,14 @@ import { PerspectiveCamera } from '@react-three/drei';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { Suspense, use, useEffect, useRef, useState } from 'react';
|
||||
import { isMobile } from 'react-device-detect';
|
||||
import { ShotDisplay } from '../../../../../../components/ShotDisplay';
|
||||
import { Armor } from '../../../../../../components/Armor';
|
||||
import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay';
|
||||
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
|
||||
import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTransform';
|
||||
import { Pose, poseEvent } from '../../../../../../core/blitzkrieg/pose';
|
||||
import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon';
|
||||
import { useEquipment } from '../../../../../../hooks/useEquipment';
|
||||
import { useWideFormat } from '../../../../../../hooks/useWideFormat';
|
||||
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
|
||||
import mutateTankopediaPersistent, {
|
||||
@@ -24,13 +26,11 @@ import { RotationInputs } from '../QuickInputs';
|
||||
import { SceneProps } from '../SceneProps';
|
||||
import { ModelLoader } from './components/ModelLoader';
|
||||
import { Options } from './components/Options';
|
||||
import { TankArmor } from './components/TankArmor';
|
||||
import { ExternalModuleMask } from './components/TankArmor/components/ExternalModuleMask';
|
||||
import { SpacedArmorDepth } from './components/TankArmor/components/SpacedArmorDepth';
|
||||
import { TankModel } from './components/TankModel';
|
||||
|
||||
export function TankSandbox() {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const hasImprovedVerticalStabilizer = useEquipment(122);
|
||||
const awaitedModelDefinitions = use(modelDefinitions);
|
||||
const canvasWrapper = useRef<HTMLDivElement>(null);
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
@@ -72,6 +72,7 @@ export function TankSandbox() {
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
@@ -89,6 +90,7 @@ export function TankSandbox() {
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
@@ -106,6 +108,7 @@ export function TankSandbox() {
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
@@ -133,6 +136,7 @@ export function TankSandbox() {
|
||||
draft.protagonist!.yaw,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
});
|
||||
}, [protagonist.gun, protagonist.turret]);
|
||||
@@ -198,12 +202,11 @@ export function TankSandbox() {
|
||||
<SceneProps />
|
||||
|
||||
<Suspense fallback={<ModelLoader />}>
|
||||
<ShotDisplay />
|
||||
{/* TODO: add rotation cube to the top right */}
|
||||
<Lighting />
|
||||
<TankModel />
|
||||
<TankArmor />
|
||||
<ExternalModuleMask ornamental />
|
||||
<SpacedArmorDepth ornamental />
|
||||
<Armor />
|
||||
<ShotDisplay />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
||||
const wireframe = useTankopediaPersistent(
|
||||
(state) => state.model.visual.wireframe,
|
||||
);
|
||||
const opaque = useTankopediaPersistent((state) => state.model.visual.opaque);
|
||||
const fullScreenAvailable =
|
||||
typeof document !== 'undefined' && document.fullscreenEnabled;
|
||||
const environment = useTankopediaPersistent(
|
||||
@@ -78,6 +79,8 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Label>Armor</DropdownMenu.Label>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={greenPenetration}
|
||||
onCheckedChange={(checked) => {
|
||||
@@ -89,6 +92,30 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
||||
Green penetration
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={wireframe}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.wireframe = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Wireframe
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={opaque}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.opaque = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Opaque
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.Label>Environment</DropdownMenu.Label>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={showGrid}
|
||||
onCheckedChange={(checked) => {
|
||||
@@ -132,17 +159,6 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={wireframe}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.wireframe = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Wireframe
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
|
||||
-286
@@ -1,286 +0,0 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { Euler, Group, Vector3 } from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { ArmorMeshExternalModuleMask } from '../../../../../../../../../components/ArmorMesh';
|
||||
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../../../constants/axis';
|
||||
import {
|
||||
ModelTransformEventData,
|
||||
modelTransformEvent,
|
||||
} from '../../../../../../../../../core/blitzkrieg/modelTransform';
|
||||
import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
|
||||
import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
|
||||
import { useArmor } from '../../../../../../../../../hooks/useArmor';
|
||||
import { useModel } from '../../../../../../../../../hooks/useModel';
|
||||
import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions';
|
||||
import { useDuel } from '../../../../../../../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia';
|
||||
|
||||
interface ExternalModuleMaskProps {
|
||||
ornamental?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* When rendered, generates a mask and thickness buffer for external modules.
|
||||
* - R: 1 means external module
|
||||
* - G: normalized thickness; multiply with max thickness to get nominal thickness
|
||||
* - B: no data
|
||||
* - A: 1 means is armor; 0 is background
|
||||
*/
|
||||
export const ExternalModuleMask = memo<ExternalModuleMaskProps>(
|
||||
({ ornamental = false }) => {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const wrapper = useRef<Group>(null);
|
||||
const modelDefinitions = useModelDefinitions();
|
||||
const turretContainer = useRef<Group>(null);
|
||||
const gunContainer = useRef<Group>(null);
|
||||
const initialTankopediaState = useTankopediaPersistent.getState();
|
||||
|
||||
useEffect(() => {
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretPosition = new Vector3();
|
||||
const turretRotation = new Euler();
|
||||
const gunPosition = new Vector3();
|
||||
const gunRotation = new Euler();
|
||||
|
||||
function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
|
||||
gunPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
.sub(turretOrigin)
|
||||
.sub(gunOrigin)
|
||||
.applyAxisAngle(I_HAT, pitch)
|
||||
.add(gunOrigin)
|
||||
.add(turretOrigin)
|
||||
.add(hullOrigin);
|
||||
gunRotation.set(pitch, 0, 0);
|
||||
gunContainer.current?.position.copy(gunPosition);
|
||||
gunContainer.current?.rotation.copy(gunRotation);
|
||||
|
||||
if (yaw === undefined) return;
|
||||
|
||||
turretPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(turretOrigin)
|
||||
.sub(hullOrigin)
|
||||
.applyAxisAngle(new Vector3(0, 0, 1), yaw);
|
||||
turretRotation.set(0, 0, yaw);
|
||||
|
||||
if (tankModelDefinition.turretRotation) {
|
||||
const initialPitch = -degToRad(
|
||||
tankModelDefinition.turretRotation.pitch,
|
||||
);
|
||||
const initialYaw = -degToRad(tankModelDefinition.turretRotation.yaw);
|
||||
const initialRoll = -degToRad(
|
||||
tankModelDefinition.turretRotation.roll,
|
||||
);
|
||||
|
||||
turretPosition
|
||||
.applyAxisAngle(I_HAT, initialPitch)
|
||||
.applyAxisAngle(J_HAT, initialRoll)
|
||||
.applyAxisAngle(K_HAT, initialYaw);
|
||||
turretRotation.x += initialPitch;
|
||||
turretRotation.y += initialRoll;
|
||||
turretRotation.z += initialYaw;
|
||||
}
|
||||
|
||||
turretPosition.add(turretOrigin).add(hullOrigin);
|
||||
turretContainer.current?.position.copy(turretPosition);
|
||||
turretContainer.current?.rotation.copy(turretRotation);
|
||||
}
|
||||
|
||||
handleModelTransform(protagonist);
|
||||
modelTransformEvent.on(handleModelTransform);
|
||||
|
||||
return () => {
|
||||
modelTransformEvent.off(handleModelTransform);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = useTankopediaPersistent.subscribe(
|
||||
(state) => state.mode,
|
||||
(mode) => {
|
||||
if (wrapper.current) wrapper.current.visible = mode === 'armor';
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const armorGltf = useArmor(protagonist.tank.id);
|
||||
const { gltf: modelGltf } = useModel(protagonist.tank.id);
|
||||
|
||||
const armorNodes = Object.values(armorGltf.nodes);
|
||||
const modelNodes = Object.values(modelGltf.nodes);
|
||||
const tankModelDefinition = modelDefinitions[protagonist.tank.id];
|
||||
const trackModelDefinition =
|
||||
tankModelDefinition.tracks[protagonist.track.id];
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
const maxThickness = Math.max(
|
||||
trackModelDefinition.thickness,
|
||||
gunModelDefinition.thickness,
|
||||
...armorNodes
|
||||
.map((node) => {
|
||||
const armorId = nameToArmorId(node.name);
|
||||
return resolveArmor(tankModelDefinition.armor, armorId).thickness;
|
||||
})
|
||||
.filter(Boolean),
|
||||
);
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={wrapper}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
visible={initialTankopediaState.mode === 'armor'}
|
||||
>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isHull = node.name.startsWith('hull_');
|
||||
const isVisible = isHull;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { thickness, spaced } = resolveArmor(
|
||||
tankModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined || spaced) return null;
|
||||
|
||||
return (
|
||||
<ArmorMeshExternalModuleMask
|
||||
ornamental={ornamental}
|
||||
exclude
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
|
||||
{modelNodes.map((node) => {
|
||||
const isWheel = node.name.startsWith('chassis_wheel_');
|
||||
const isTrack = node.name.startsWith('chassis_track_');
|
||||
const isVisible = isWheel || isTrack;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<ArmorMeshExternalModuleMask
|
||||
ornamental={ornamental}
|
||||
maxThickness={maxThickness}
|
||||
thickness={trackModelDefinition.thickness}
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<group ref={turretContainer}>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentTurret = node.name.startsWith(
|
||||
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentTurret;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { thickness, spaced } = resolveArmor(
|
||||
turretModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined || spaced) return null;
|
||||
|
||||
return (
|
||||
<group key={node.uuid} position={turretOrigin}>
|
||||
<ArmorMeshExternalModuleMask
|
||||
ornamental={ornamental}
|
||||
exclude
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
|
||||
<group ref={gunContainer}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentGun = node.name.startsWith(
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentGun;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { thickness, spaced } = resolveArmor(
|
||||
gunModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined || spaced) return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
key={node.uuid}
|
||||
position={turretOrigin.clone().add(gunOrigin)}
|
||||
>
|
||||
<ArmorMeshExternalModuleMask
|
||||
ornamental={ornamental}
|
||||
exclude
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
|
||||
{modelNodes.map((node) => {
|
||||
const isCurrentGun =
|
||||
node.name ===
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
|
||||
const isVisible = isCurrentGun;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<ArmorMeshExternalModuleMask
|
||||
ornamental={ornamental}
|
||||
maxThickness={maxThickness}
|
||||
thickness={gunModelDefinition.thickness}
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
},
|
||||
);
|
||||
-299
@@ -1,299 +0,0 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { Euler, Group, Vector3 } from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { ArmorMeshSpacedArmorDepth } from '../../../../../../../../../components/ArmorMesh/components/SpacedArmorDepth';
|
||||
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../../../constants/axis';
|
||||
import {
|
||||
ModelTransformEventData,
|
||||
modelTransformEvent,
|
||||
} from '../../../../../../../../../core/blitzkrieg/modelTransform';
|
||||
import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
|
||||
import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
|
||||
import { useArmor } from '../../../../../../../../../hooks/useArmor';
|
||||
import { useModel } from '../../../../../../../../../hooks/useModel';
|
||||
import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions';
|
||||
import { useDuel } from '../../../../../../../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia';
|
||||
|
||||
interface SpacedArmorDepthProps {
|
||||
ornamental?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* When rendered, this component provides the depth buffer for the armor which
|
||||
* when compared to the depth of the core armor will give you the distance
|
||||
* between the first point of contact and the core armor. The color channels
|
||||
* hold information about the thickness of the spaced armor (external modules
|
||||
* not included).
|
||||
* - R: angle of the armor where 0 is 0 and 1 is 90
|
||||
* - G: normalized thickness; multiply with max thickness to get nominal thickness
|
||||
* - B: no data
|
||||
* - A: 1 means is armor; 0 is background
|
||||
*/
|
||||
export const SpacedArmorDepth = memo<SpacedArmorDepthProps>(
|
||||
({ ornamental = false }) => {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const wrapper = useRef<Group>(null);
|
||||
const modelDefinitions = useModelDefinitions();
|
||||
const turretContainer = useRef<Group>(null);
|
||||
const gunContainer = useRef<Group>(null);
|
||||
const initialTankopediaState = useTankopediaPersistent.getState();
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelDefinitions) return;
|
||||
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretPosition = new Vector3();
|
||||
const turretRotation = new Euler();
|
||||
const gunPosition = new Vector3();
|
||||
const gunRotation = new Euler();
|
||||
|
||||
function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
|
||||
gunPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
.sub(turretOrigin)
|
||||
.sub(gunOrigin)
|
||||
.applyAxisAngle(I_HAT, pitch)
|
||||
.add(gunOrigin)
|
||||
.add(turretOrigin)
|
||||
.add(hullOrigin);
|
||||
gunRotation.set(pitch, 0, 0);
|
||||
gunContainer.current?.position.copy(gunPosition);
|
||||
gunContainer.current?.rotation.copy(gunRotation);
|
||||
|
||||
if (yaw === undefined) return;
|
||||
|
||||
turretPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
.sub(turretOrigin)
|
||||
.applyAxisAngle(new Vector3(0, 0, 1), yaw);
|
||||
turretRotation.set(0, 0, yaw);
|
||||
|
||||
if (tankModelDefinition.turretRotation) {
|
||||
const initialPitch = -degToRad(
|
||||
tankModelDefinition.turretRotation.pitch,
|
||||
);
|
||||
const initialYaw = -degToRad(tankModelDefinition.turretRotation.yaw);
|
||||
const initialRoll = -degToRad(
|
||||
tankModelDefinition.turretRotation.roll,
|
||||
);
|
||||
|
||||
turretPosition
|
||||
.applyAxisAngle(I_HAT, initialPitch)
|
||||
.applyAxisAngle(J_HAT, initialRoll)
|
||||
.applyAxisAngle(K_HAT, initialYaw);
|
||||
turretRotation.x += initialPitch;
|
||||
turretRotation.y += initialRoll;
|
||||
turretRotation.z += initialYaw;
|
||||
}
|
||||
|
||||
turretPosition.add(turretOrigin).add(hullOrigin);
|
||||
turretContainer.current?.position.copy(turretPosition);
|
||||
turretContainer.current?.rotation.copy(turretRotation);
|
||||
}
|
||||
|
||||
handleModelTransform(protagonist);
|
||||
modelTransformEvent.on(handleModelTransform);
|
||||
|
||||
return () => {
|
||||
modelTransformEvent.off(handleModelTransform);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = useTankopediaPersistent.subscribe(
|
||||
(state) => state.mode,
|
||||
(mode) => {
|
||||
if (wrapper.current) wrapper.current.visible = mode === 'armor';
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const armorGltf = useArmor(protagonist.tank.id);
|
||||
const { gltf: modelGltf } = useModel(protagonist.tank.id);
|
||||
|
||||
const armorNodes = Object.values(armorGltf.nodes);
|
||||
const modelNodes = Object.values(modelGltf.nodes);
|
||||
const tankModelDefinition = modelDefinitions[protagonist.tank.id];
|
||||
const trackModelDefinition =
|
||||
tankModelDefinition.tracks[protagonist.track.id];
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
const maxThickness = Math.max(
|
||||
trackModelDefinition.thickness,
|
||||
gunModelDefinition.thickness,
|
||||
...armorNodes
|
||||
.map((node) => {
|
||||
const armorId = nameToArmorId(node.name);
|
||||
return resolveArmor(tankModelDefinition.armor, armorId).thickness;
|
||||
})
|
||||
.filter(Boolean),
|
||||
);
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={wrapper}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
visible={initialTankopediaState.mode === 'armor'}
|
||||
>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isHull = node.name.startsWith('hull_');
|
||||
const isVisible = isHull;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
tankModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<ArmorMeshSpacedArmorDepth
|
||||
ornamental={ornamental}
|
||||
include={spaced}
|
||||
isExternalModule={false}
|
||||
thickness={thickness}
|
||||
maxThickness={maxThickness}
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
{modelNodes.map((node) => {
|
||||
const isWheel = node.name.startsWith('chassis_wheel_');
|
||||
const isTrack = node.name.startsWith('chassis_track_');
|
||||
const isVisible = isWheel || isTrack;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<ArmorMeshSpacedArmorDepth
|
||||
ornamental={ornamental}
|
||||
isExternalModule
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<group ref={turretContainer}>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentTurret = node.name.startsWith(
|
||||
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentTurret;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
turretModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<group key={node.uuid} position={turretOrigin}>
|
||||
<ArmorMeshSpacedArmorDepth
|
||||
ornamental={ornamental}
|
||||
include={spaced}
|
||||
isExternalModule={false}
|
||||
thickness={thickness}
|
||||
maxThickness={maxThickness}
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
<group ref={gunContainer}>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentGun = node.name.startsWith(
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentGun;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
gunModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
key={node.uuid}
|
||||
position={turretOrigin.clone().add(gunOrigin)}
|
||||
>
|
||||
<ArmorMeshSpacedArmorDepth
|
||||
ornamental={ornamental}
|
||||
include={spaced}
|
||||
isExternalModule={false}
|
||||
thickness={thickness}
|
||||
maxThickness={maxThickness}
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
|
||||
{modelNodes.map((node) => {
|
||||
const isCurrentGun =
|
||||
node.name ===
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
|
||||
const isVisible = isCurrentGun;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<ArmorMeshSpacedArmorDepth
|
||||
ornamental={ornamental}
|
||||
isExternalModule
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,67 +0,0 @@
|
||||
import { createPortal, useFrame } from '@react-three/fiber';
|
||||
import { memo } from 'react';
|
||||
import { DepthTexture, Scene, Vector2 } from 'three';
|
||||
import { externalModuleMaskRenderTarget } from '../../../../../../../../components/ArmorMesh';
|
||||
import { spacedArmorDepthRenderTarget } from '../../../../../../../../components/ArmorMesh/components/SpacedArmorDepth';
|
||||
import { ArmorHighlighting } from './components/ArmorHighlighting';
|
||||
import { ExternalModuleMask } from './components/ExternalModuleMask';
|
||||
import { SpacedArmorDepth } from './components/SpacedArmorDepth';
|
||||
|
||||
export const TankArmor = memo(() => {
|
||||
const externalModuleMaskScene = new Scene();
|
||||
const spacedArmorDepthScene = new Scene();
|
||||
const armorHighlightingScene = new Scene();
|
||||
const externalModuleMaskPortal = createPortal(
|
||||
<ExternalModuleMask />,
|
||||
externalModuleMaskScene,
|
||||
);
|
||||
const spacedArmorDepthPortal = createPortal(
|
||||
<SpacedArmorDepth />,
|
||||
spacedArmorDepthScene,
|
||||
);
|
||||
const armorHighlightingPortal = createPortal(
|
||||
<ArmorHighlighting />,
|
||||
armorHighlightingScene,
|
||||
);
|
||||
const renderSize = new Vector2();
|
||||
const newRenderSize = new Vector2();
|
||||
|
||||
useFrame(({ gl, scene, camera }) => {
|
||||
const pixelRatio = gl.getPixelRatio();
|
||||
gl.autoClear = true;
|
||||
|
||||
gl.getSize(newRenderSize).multiplyScalar(pixelRatio);
|
||||
if (!newRenderSize.equals(renderSize)) {
|
||||
renderSize.copy(newRenderSize);
|
||||
spacedArmorDepthRenderTarget.depthTexture = new DepthTexture(
|
||||
renderSize.x,
|
||||
renderSize.y,
|
||||
);
|
||||
}
|
||||
|
||||
externalModuleMaskRenderTarget.setSize(renderSize.x, renderSize.y);
|
||||
gl.setRenderTarget(externalModuleMaskRenderTarget);
|
||||
gl.render(externalModuleMaskScene, camera);
|
||||
|
||||
gl.clearDepth();
|
||||
spacedArmorDepthRenderTarget.setSize(renderSize.x, renderSize.y * 0.5);
|
||||
gl.setRenderTarget(spacedArmorDepthRenderTarget);
|
||||
gl.render(spacedArmorDepthScene, camera);
|
||||
|
||||
gl.autoClear = false;
|
||||
|
||||
gl.setRenderTarget(null);
|
||||
gl.render(scene, camera);
|
||||
|
||||
gl.clearDepth();
|
||||
gl.render(armorHighlightingScene, camera);
|
||||
}, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
{externalModuleMaskPortal}
|
||||
{spacedArmorDepthPortal}
|
||||
{armorHighlightingPortal}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -4,13 +4,14 @@ import { Euler, Group, Vector3 } from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../constants/axis';
|
||||
import { applyPitchYawLimits } from '../../../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { hasEquipment } from '../../../../../../../core/blitzkrieg/hasEquipment';
|
||||
import { jsxTree } from '../../../../../../../core/blitzkrieg/jsxTree';
|
||||
import { modelDefinitions } from '../../../../../../../core/blitzkrieg/modelDefinitions';
|
||||
import {
|
||||
ModelTransformEventData,
|
||||
modelTransformEvent,
|
||||
} from '../../../../../../../core/blitzkrieg/modelTransform';
|
||||
import { normalizeAnglePI } from '../../../../../../../core/math/normalizeAngle180';
|
||||
import { normalizeAngleRad } from '../../../../../../../core/math/normalizeAngleRad';
|
||||
import { useAwait } from '../../../../../../../hooks/useAwait';
|
||||
import { useModel } from '../../../../../../../hooks/useModel';
|
||||
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
||||
@@ -106,7 +107,6 @@ export const TankModel = memo(() => {
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
|
||||
const { gltf } = useModel(protagonist.tank.id);
|
||||
const nodes = Object.values(gltf.nodes);
|
||||
|
||||
@@ -161,19 +161,21 @@ export const TankModel = memo(() => {
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
async function handlePointerMove(event: PointerEvent) {
|
||||
const hasImprovedVerticalStabilizer = await hasEquipment(122);
|
||||
[pitch, yaw] = applyPitchYawLimits(
|
||||
pitch,
|
||||
yaw + event.movementX * (Math.PI / canvas.width),
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
}
|
||||
function handlePointerUp() {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.pitch = normalizeAnglePI(pitch);
|
||||
draft.protagonist!.yaw = normalizeAnglePI(yaw);
|
||||
draft.protagonist!.pitch = normalizeAngleRad(pitch);
|
||||
draft.protagonist!.yaw = normalizeAngleRad(yaw);
|
||||
});
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.controlsEnabled = true;
|
||||
@@ -220,12 +222,14 @@ export const TankModel = memo(() => {
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
async function handlePointerMove(event: PointerEvent) {
|
||||
const hasImprovedVerticalStabilizer = await hasEquipment(122);
|
||||
[pitch, yaw] = applyPitchYawLimits(
|
||||
pitch - event.movementY * (Math.PI / canvas.height),
|
||||
yaw + event.movementX * (Math.PI / canvas.width),
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
}
|
||||
@@ -234,8 +238,8 @@ export const TankModel = memo(() => {
|
||||
draft.model.visual.controlsEnabled = true;
|
||||
});
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.pitch = normalizeAnglePI(pitch);
|
||||
draft.protagonist!.yaw = normalizeAnglePI(yaw);
|
||||
draft.protagonist!.pitch = normalizeAngleRad(pitch);
|
||||
draft.protagonist!.yaw = normalizeAngleRad(yaw);
|
||||
});
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Flex, TextField } from '@radix-ui/themes';
|
||||
import { use, useEffect, useRef } from 'react';
|
||||
import { degToRad, radToDeg } from 'three/src/math/MathUtils';
|
||||
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
|
||||
import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTransform';
|
||||
@@ -20,22 +21,21 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
||||
const mode = useTankopediaPersistent((state) => state.mode);
|
||||
const hasCalibratedShells = useEquipment(103, true);
|
||||
const hasEnhancedArmor = useEquipment(110);
|
||||
|
||||
useEffect(() => {
|
||||
turretYawInput.current!.value = `${-Math.round(
|
||||
protagonist.yaw * (180 / Math.PI),
|
||||
)}`;
|
||||
}, [protagonist.yaw]);
|
||||
useEffect(() => {
|
||||
gunPitchInput.current!.value = `${-Math.round(
|
||||
protagonist.pitch * (180 / Math.PI),
|
||||
)}`;
|
||||
}, [protagonist.pitch]);
|
||||
|
||||
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
const initialGunPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
|
||||
const hasImprovedVerticalStabilizer = useEquipment(122);
|
||||
|
||||
useEffect(() => {
|
||||
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
|
||||
}, [protagonist.yaw]);
|
||||
useEffect(() => {
|
||||
gunPitchInput.current!.value = (
|
||||
-radToDeg(protagonist.pitch) + initialGunPitch
|
||||
).toFixed(1);
|
||||
}, [protagonist.pitch]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -68,22 +68,22 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
||||
Yaw
|
||||
</TextField.Slot>
|
||||
<TextField.Input
|
||||
defaultValue={Math.round(protagonist.yaw * (180 / Math.PI))}
|
||||
onBlur={() => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
protagonist.pitch,
|
||||
-Number(turretYawInput.current?.value) * (Math.PI / 180),
|
||||
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 = `${Math.round(
|
||||
yaw * (180 / Math.PI),
|
||||
)}`;
|
||||
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
|
||||
1,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
@@ -116,22 +116,24 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
||||
Pitch
|
||||
</TextField.Slot>
|
||||
<TextField.Input
|
||||
defaultValue={-Math.round(protagonist.pitch * (180 / Math.PI))}
|
||||
onBlur={() => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
-Number(gunPitchInput.current?.value) * (Math.PI / 180),
|
||||
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 = `${Math.round(
|
||||
pitch * (180 / Math.PI),
|
||||
)}`;
|
||||
gunPitchInput.current!.value = (
|
||||
-radToDeg(protagonist.pitch) + initialGunPitch
|
||||
).toFixed(1);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
import { Color } from 'three';
|
||||
import InfiniteGridHelper from '../../../../../components/InfiniteGridHelper';
|
||||
import { useFrame, useLoader } from '@react-three/fiber';
|
||||
import { clamp } from 'lodash';
|
||||
import { useRef } from 'react';
|
||||
import { MeshStandardMaterial, TextureLoader } from 'three';
|
||||
import { useTankopediaPersistent } from '../../../../../stores/tankopedia';
|
||||
|
||||
export function SceneProps() {
|
||||
const showGrid = useTankopediaPersistent(
|
||||
(state) => state.model.visual.showGrid,
|
||||
);
|
||||
const showEnvironment = useTankopediaPersistent(
|
||||
(state) => state.model.visual.showEnvironment,
|
||||
const show = useTankopediaPersistent(
|
||||
(state) =>
|
||||
state.model.visual.showGrid && !state.model.visual.showEnvironment,
|
||||
);
|
||||
const texture = useLoader(TextureLoader, 'https://i.imgur.com/C28Z8nU.png');
|
||||
const material = useRef<MeshStandardMaterial>(null);
|
||||
|
||||
if (!showGrid || showEnvironment) return null;
|
||||
useFrame(({ camera }) => {
|
||||
if (!material.current) return;
|
||||
material.current.opacity = clamp(0.1 * camera.position.y, 0, 1);
|
||||
});
|
||||
|
||||
texture.anisotropy = 2;
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<InfiniteGridHelper
|
||||
size1={1 / 5}
|
||||
size2={1}
|
||||
distance={20}
|
||||
color={new Color('#ffffff')}
|
||||
/>
|
||||
<InfiniteGridHelper
|
||||
position={[0, 1e-4, 0]}
|
||||
size1={0}
|
||||
size2={100}
|
||||
distance={25}
|
||||
color={new Color('red')}
|
||||
/>
|
||||
</>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[10, 10]} />
|
||||
<meshStandardMaterial ref={material} map={texture} transparent />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function Title() {
|
||||
<img
|
||||
src={
|
||||
TREE_TYPE_ICONS[protagonist.tank.treeType][
|
||||
protagonist.tank.type
|
||||
protagonist.tank.class
|
||||
]
|
||||
}
|
||||
style={{
|
||||
|
||||
@@ -63,6 +63,8 @@ export default function Page({ params }: { params: { id: string } }) {
|
||||
};
|
||||
}, [initialId, assigned]);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<PageWrapper color="purple" size="double">
|
||||
{assigned && (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button, DropdownMenu, Flex } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { Flag } from '../../../../../../../components/Flag';
|
||||
import {
|
||||
TANK_TYPES,
|
||||
TANK_CLASSES,
|
||||
TREE_TYPES,
|
||||
TREE_TYPE_ICONS,
|
||||
TREE_TYPE_IMAGES,
|
||||
@@ -121,7 +121,7 @@ export function Options() {
|
||||
))}
|
||||
</Flex>
|
||||
<Flex>
|
||||
{[...TANK_TYPES].map((type, index) => (
|
||||
{[...TANK_CLASSES].map((type, index) => (
|
||||
<Button
|
||||
key={type}
|
||||
color={filters.types.includes(type) ? undefined : 'gray'}
|
||||
@@ -132,9 +132,9 @@ export function Options() {
|
||||
borderTopLeftRadius: index === 0 ? undefined : 0,
|
||||
borderBottomLeftRadius: index === 0 ? undefined : 0,
|
||||
borderTopRightRadius:
|
||||
index === TANK_TYPES.length - 1 ? undefined : 0,
|
||||
index === TANK_CLASSES.length - 1 ? undefined : 0,
|
||||
borderBottomRightRadius:
|
||||
index === TANK_TYPES.length - 1 ? undefined : 0,
|
||||
index === TANK_CLASSES.length - 1 ? undefined : 0,
|
||||
}}
|
||||
onClick={() =>
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '@radix-ui/themes';
|
||||
import { go } from 'fuzzysort';
|
||||
import { use, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TankType, TreeType } from '../../../../../components/Tanks';
|
||||
import { TANK_CLASSES, TreeType } from '../../../../../components/Tanks';
|
||||
import { resolveNearPenetration } from '../../../../../core/blitz/resolveNearPenetration';
|
||||
import { asset } from '../../../../../core/blitzkrieg/asset';
|
||||
import { modelDefinitions } from '../../../../../core/blitzkrieg/modelDefinitions';
|
||||
@@ -49,7 +49,6 @@ interface TankSearchProps {
|
||||
onSelect?: (tank: TankDefinition) => void;
|
||||
}
|
||||
|
||||
const tankTypeOrder: TankType[] = ['light', 'medium', 'heavy', 'tankDestroyer'];
|
||||
const treeTypeOrder: TreeType[] = ['researchable', 'premium', 'collector'];
|
||||
|
||||
export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
||||
@@ -70,7 +69,7 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(tankTypeOrder.indexOf(a.type) - tankTypeOrder.indexOf(b.type)) *
|
||||
(TANK_CLASSES.indexOf(a.class) - TANK_CLASSES.indexOf(b.class)) *
|
||||
(sort.direction === 'descending' ? -1 : 1),
|
||||
)
|
||||
.sort(
|
||||
@@ -89,7 +88,7 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
||||
: filters.tiers.includes(tank.tier)) &&
|
||||
(filters.types.length === 0
|
||||
? true
|
||||
: filters.types.includes(tank.type)) &&
|
||||
: filters.types.includes(tank.class)) &&
|
||||
(filters.treeTypes.length === 0
|
||||
? true
|
||||
: filters.treeTypes.includes(tank.treeType)) &&
|
||||
@@ -844,7 +843,7 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
||||
? TANK_ICONS_COLLECTOR
|
||||
: tank.treeType === 'premium'
|
||||
? TANK_ICONS_PREMIUM
|
||||
: TANK_ICONS)[tank.type]
|
||||
: TANK_ICONS)[tank.class]
|
||||
}
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
/>
|
||||
|
||||
+54
-52
@@ -1,67 +1,69 @@
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
SlashCommandSubcommandsOnlyBuilder,
|
||||
} from 'discord.js';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { COMMANDS_RAW, CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
const RAW_PATH = `https://raw.githubusercontent.com/tresabhi/blitzkrieg/main/docs/`;
|
||||
const DOCS = {
|
||||
const subcommands = [
|
||||
'embeds',
|
||||
'introduction',
|
||||
'invite',
|
||||
'timezones',
|
||||
'creators',
|
||||
] as const;
|
||||
type Subcommand = (typeof subcommands)[number];
|
||||
|
||||
const DOCS: Record<Subcommand, string> = {
|
||||
embeds: 'guide/embeds',
|
||||
introduction: 'guide/introduction',
|
||||
invite: 'guide/invite',
|
||||
timezones: 'guide/timezones',
|
||||
creators: 'guide/creators',
|
||||
};
|
||||
const DOC_DESCRIPTIONS: Record<keyof typeof DOCS, string> = {
|
||||
embeds: 'How embeds work',
|
||||
introduction: 'About the bot',
|
||||
invite: 'How to invite the bot',
|
||||
timezones: 'How Blitzkrieg infers and uses your timezone',
|
||||
creators: 'Who created the bot',
|
||||
};
|
||||
|
||||
function addDocsSubcommands(option: SlashCommandSubcommandsOnlyBuilder) {
|
||||
Object.entries(DOC_DESCRIPTIONS).forEach(([key, value]) => {
|
||||
option.addSubcommand((sub) => sub.setName(key).setDescription(value));
|
||||
});
|
||||
export const aboutCommand = new Promise<CommandRegistry>(async (resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
return option;
|
||||
}
|
||||
command: createLocalizedCommand('about', [
|
||||
{ subcommand: 'commands' },
|
||||
...subcommands.map((subcommand) => ({ subcommand })),
|
||||
]),
|
||||
|
||||
export const aboutCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
async handler(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const { t } = translator(interaction.locale);
|
||||
|
||||
command: addDocsSubcommands(
|
||||
new SlashCommandBuilder()
|
||||
.setName('about')
|
||||
.setDescription('All the info you need about the bot')
|
||||
.addSubcommand((option) =>
|
||||
option
|
||||
.setName('commands')
|
||||
.setDescription('List of all commands and their descriptions'),
|
||||
),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === 'commands') {
|
||||
return `# Commands\n\nBlitzkrieg offers the following commands:\n\n${(
|
||||
await Promise.all(COMMANDS_RAW)
|
||||
)
|
||||
.filter((registry) => registry.inPublic && registry.inProduction)
|
||||
.sort((a, b) => (a.command.name < b.command.name ? -1 : 1))
|
||||
.map(
|
||||
(registry) =>
|
||||
`- \`/${registry.command.name}\`: ${registry.command.description}`,
|
||||
if (subcommand === 'commands') {
|
||||
return t`bot.commands.about.subcommands.commands.body${(
|
||||
await Promise.all(COMMANDS_RAW)
|
||||
)
|
||||
.join('\n')}`;
|
||||
}
|
||||
.filter((registry) => registry.inPublic && registry.inProduction)
|
||||
.sort((a, b) =>
|
||||
(a.command.name_localizations?.[interaction.locale] ??
|
||||
a.command.name) <
|
||||
(b.command.name_localizations?.[interaction.locale] ??
|
||||
b.command.name)
|
||||
? -1
|
||||
: 1,
|
||||
)
|
||||
.map(
|
||||
(registry) =>
|
||||
`- \`/${
|
||||
registry.command.name_localizations?.[interaction.locale] ??
|
||||
registry.command.name
|
||||
}\`: ${
|
||||
registry.command.description_localizations?.[
|
||||
interaction.locale
|
||||
] ?? registry.command.description
|
||||
}`,
|
||||
)
|
||||
.join('\n')}`;
|
||||
}
|
||||
|
||||
const url = `${RAW_PATH}${DOCS[subcommand as keyof typeof DOCS]}.md`;
|
||||
const content = fetch(url).then((response) => response.text());
|
||||
const url = `https://raw.githubusercontent.com/tresabhi/blitzkrieg/main/docs/${DOCS[subcommand as Subcommand]}.md`;
|
||||
const content = fetch(url).then((response) => response.text());
|
||||
|
||||
return content;
|
||||
},
|
||||
};
|
||||
return content;
|
||||
},
|
||||
});
|
||||
});
|
||||
+51
-38
@@ -1,9 +1,9 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { Locale } from 'discord.js';
|
||||
import { chunk } from 'lodash';
|
||||
import * as Breakdown from '../components/Breakdown';
|
||||
import NoData, { NoDataType } from '../components/NoData';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import NoData from '../components/NoData';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { AllStats, getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import getTankStats from '../core/blitz/getTankStats';
|
||||
@@ -19,8 +19,9 @@ import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteTanks from '../core/discord/autocompleteTanks';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import buttonPrimary from '../core/discord/buttonPrimary';
|
||||
import { buttonRefresh } from '../core/discord/buttonRefresh';
|
||||
import commandToURL from '../core/discord/commandToURL';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
|
||||
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
|
||||
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
|
||||
@@ -32,10 +33,11 @@ import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
|
||||
import resolvePlayerFromCommand, {
|
||||
ResolvedPlayer,
|
||||
} from '../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import calculateWN8 from '../core/statistics/calculateWN8';
|
||||
import { StatFilters, filterStats } from '../core/statistics/filterStats';
|
||||
import getWN8Percentile from '../core/statistics/getWN8Percentile';
|
||||
import { CommandRegistryPromisable } from '../events/interactionCreate';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
const ROWS_PER_PAGE = 8;
|
||||
const MAX_PAGES = 9;
|
||||
@@ -44,15 +46,17 @@ export async function renderBreakdown(
|
||||
{ region, id }: ResolvedPlayer,
|
||||
{ start, end, name }: ResolvedPeriod,
|
||||
filters: StatFilters,
|
||||
locale: Locale,
|
||||
) {
|
||||
const { t, translate } = translator(locale);
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const awaitedTankAverages = await tankAverages;
|
||||
const statsInPeriod = await getStatsInPeriod(region, id, start, end);
|
||||
const statsInPeriod = await getStatsInPeriod(region, id, start, end, locale);
|
||||
const { filteredOrder } = await filterStats(statsInPeriod, filters);
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clanData = await getClanAccountInfo(region, id, ['clan']);
|
||||
const tankStats = await getTankStats(region, id);
|
||||
const filterDescriptions = await filtersToDescription(filters);
|
||||
const tankStats = await getTankStats(region, id, locale);
|
||||
const filterDescriptions = await filtersToDescription(filters, locale);
|
||||
const orderedCurrentStats: AllStats[] = [];
|
||||
const orderedCareerStats: AllStats[] = [];
|
||||
const orderedCurrentWN8: (number | undefined)[] = [];
|
||||
@@ -144,21 +148,21 @@ export async function renderBreakdown(
|
||||
<Breakdown.Row
|
||||
key={id}
|
||||
type="summary"
|
||||
title="Total"
|
||||
title={t`bot.commands.breakdown.body.total`}
|
||||
stats={[
|
||||
{
|
||||
title: 'Battles',
|
||||
title: t`bot.commands.breakdown.body.battles`,
|
||||
current: currentBattles,
|
||||
career: careerBattles,
|
||||
},
|
||||
{
|
||||
title: 'Winrate',
|
||||
title: t`bot.commands.breakdown.body.winrate`,
|
||||
current: `${(100 * currentWinrate).toFixed(2)}%`,
|
||||
career: `${(100 * careerWinrate).toFixed(2)}%`,
|
||||
delta: currentWinrate - careerWinrate,
|
||||
},
|
||||
{
|
||||
title: 'WN8',
|
||||
title: t`bot.commands.breakdown.body.wn8`,
|
||||
current:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
@@ -173,7 +177,7 @@ export async function renderBreakdown(
|
||||
: getWN8Percentile(currentWN8),
|
||||
},
|
||||
{
|
||||
title: 'Damage',
|
||||
title: t`bot.commands.breakdown.body.damage`,
|
||||
current: Math.round(currentDamage).toLocaleString(),
|
||||
career: Math.round(careerDamage).toLocaleString(),
|
||||
delta: currentDamage - careerDamage,
|
||||
@@ -195,17 +199,20 @@ export async function renderBreakdown(
|
||||
<Breakdown.Row
|
||||
key={id}
|
||||
type="tank"
|
||||
tankType={tankDefinition?.type}
|
||||
tankType={tankDefinition?.class}
|
||||
treeType={tankDefinition?.treeType}
|
||||
title={tankDefinition?.name ?? `Unknown tank ${id}`}
|
||||
title={
|
||||
tankDefinition?.name ??
|
||||
translate('bot.commands.breakdown.body.unknown_tank', [`${id}`])
|
||||
}
|
||||
stats={[
|
||||
{
|
||||
title: 'Battles',
|
||||
title: t`bot.commands.breakdown.body.battles`,
|
||||
current: current.battles.toLocaleString(),
|
||||
career: career.battles.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: 'Winrate',
|
||||
title: t`bot.commands.breakdown.body.winrate`,
|
||||
current: `${(100 * (current.wins / current.battles)).toFixed(
|
||||
2,
|
||||
)}%`,
|
||||
@@ -214,7 +221,7 @@ export async function renderBreakdown(
|
||||
current.wins / current.battles - career.wins / career.battles,
|
||||
},
|
||||
{
|
||||
title: 'WN8',
|
||||
title: t`bot.commands.breakdown.body.wn8`,
|
||||
current:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
@@ -229,7 +236,7 @@ export async function renderBreakdown(
|
||||
: getWN8Percentile(currentWN8),
|
||||
},
|
||||
{
|
||||
title: 'Damage',
|
||||
title: t`bot.commands.breakdown.body.damage`,
|
||||
current: Math.round(
|
||||
current.damage_dealt / current.battles,
|
||||
).toLocaleString(),
|
||||
@@ -254,10 +261,10 @@ export async function renderBreakdown(
|
||||
if (filteredOrder.length > 0) {
|
||||
return pages
|
||||
.map((page, index) => (
|
||||
<Wrapper>
|
||||
<CommandWrapper>
|
||||
{index === 0 && (
|
||||
<TitleBar
|
||||
name={accountInfo.nickname}
|
||||
title={accountInfo.nickname}
|
||||
image={
|
||||
clanData?.clan
|
||||
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData?.clan?.emblem_set_id}.png`
|
||||
@@ -268,14 +275,14 @@ export async function renderBreakdown(
|
||||
)}
|
||||
|
||||
<Breakdown.Root>{page}</Breakdown.Root>
|
||||
</Wrapper>
|
||||
</CommandWrapper>
|
||||
))
|
||||
.reverse();
|
||||
} else {
|
||||
return [
|
||||
<Wrapper>
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
name={accountInfo.nickname}
|
||||
title={accountInfo.nickname}
|
||||
image={
|
||||
clanData?.clan
|
||||
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData?.clan?.emblem_set_id}.png`
|
||||
@@ -284,26 +291,22 @@ export async function renderBreakdown(
|
||||
description={`${name} • ${filterDescriptions}`}
|
||||
/>
|
||||
|
||||
<NoData type={NoDataType.BattlesInPeriod} />
|
||||
</Wrapper>,
|
||||
<NoData type="battles_in_period" locale={locale} />
|
||||
</CommandWrapper>,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export const breakdownCommand = new Promise<CommandRegistryPromisable>(
|
||||
export const breakdownCommand = new Promise<CommandRegistry>(
|
||||
async (resolve) => {
|
||||
const command = await addPeriodicFilterOptions(
|
||||
new SlashCommandBuilder()
|
||||
.setName('breakdown')
|
||||
.setDescription("A period's breakdown by tanks played"),
|
||||
(option) => option.addStringOption(addUsernameChoices),
|
||||
);
|
||||
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command,
|
||||
command: await addPeriodicFilterOptions(
|
||||
createLocalizedCommand('breakdown'),
|
||||
(option) => option.addStringOption(addUsernameChoices),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const player = await resolvePlayerFromCommand(interaction);
|
||||
@@ -316,8 +319,13 @@ export const breakdownCommand = new Promise<CommandRegistryPromisable>(
|
||||
});
|
||||
|
||||
return [
|
||||
...(await renderBreakdown(player, period, filters)),
|
||||
buttonPrimary(path, 'Refresh'),
|
||||
...(await renderBreakdown(
|
||||
player,
|
||||
period,
|
||||
filters,
|
||||
interaction.locale,
|
||||
)),
|
||||
buttonRefresh(interaction, path),
|
||||
await getBlitzStarsLinkButton(player.region, player.id),
|
||||
];
|
||||
},
|
||||
@@ -332,7 +340,12 @@ export const breakdownCommand = new Promise<CommandRegistryPromisable>(
|
||||
const period = resolvePeriodFromButton(player.region, interaction);
|
||||
const filters = getFiltersFromButton(interaction);
|
||||
|
||||
return await renderBreakdown(player, period, filters);
|
||||
return await renderBreakdown(
|
||||
player,
|
||||
period,
|
||||
filters,
|
||||
interaction.locale,
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
+32
-28
@@ -1,36 +1,40 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import packageJSON from '../../package.json' assert { type: 'json' };
|
||||
import { client } from '../core/discord/client';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
const executionStart = new Date().getTime();
|
||||
|
||||
export const debugCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
export const debugCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('debug')
|
||||
.setDescription('Debug information about the bot'),
|
||||
command: createLocalizedCommand('debug'),
|
||||
|
||||
async handler() {
|
||||
const currentTime = new Date().getTime();
|
||||
const uptime = currentTime - executionStart;
|
||||
const list = [
|
||||
['Version', packageJSON.version],
|
||||
['Shard', client.shard?.ids[0] ?? 'default'],
|
||||
[
|
||||
'Uptime',
|
||||
`${Math.floor((uptime / 1000 / 60 / 60) % 24)}h ${Math.floor(
|
||||
(uptime / 1000 / 60) % 60,
|
||||
)}m ${Math.floor((uptime / 1000) % 60)}s ${Math.floor(
|
||||
uptime % 1000,
|
||||
)}ms`,
|
||||
],
|
||||
]
|
||||
.map(([key, value]) => `- ${key}: ${value}`)
|
||||
.join('\n');
|
||||
|
||||
return `# Debug\n\n${list}`;
|
||||
},
|
||||
};
|
||||
async handler(interaction) {
|
||||
const { t } = translator(interaction.locale);
|
||||
const currentTime = new Date().getTime();
|
||||
const uptime = currentTime - executionStart;
|
||||
const list = [
|
||||
[t`bot.commands.debug.body.version`, packageJSON.version],
|
||||
[
|
||||
t`bot.commands.debug.body.shard`,
|
||||
client.shard?.ids[0] ?? t`bot.commands.debug.body.shard.default`,
|
||||
],
|
||||
[
|
||||
t`bot.commands.debug.body.uptime`,
|
||||
`${Math.floor((uptime / 1000 / 60 / 60) % 24)}h ${Math.floor(
|
||||
(uptime / 1000 / 60) % 60,
|
||||
)}m ${Math.floor((uptime / 1000) % 60)}s ${Math.floor(
|
||||
uptime % 1000,
|
||||
)}ms`,
|
||||
],
|
||||
];
|
||||
return t`bot.commands.debug.body.title${list
|
||||
.map(([key, value]) => `- ${key}: ${value}`)
|
||||
.join('\n')}`;
|
||||
},
|
||||
});
|
||||
});
|
||||
+185
-178
@@ -1,198 +1,205 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import getPeriodNow from '../core/blitzkrieg/getPeriodNow';
|
||||
import getTimeDaysAgo from '../core/blitzkrieg/getTimeDaysAgo';
|
||||
import { tankDefinitions } from '../core/blitzkrieg/tankDefinitions';
|
||||
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
|
||||
import { tankAverages } from '../core/blitzstars/tankAverages';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import embedNegative from '../core/discord/embedNegative';
|
||||
import embedPositive from '../core/discord/embedPositive';
|
||||
import markdownTable, { TableInput } from '../core/discord/markdownTable';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import calculateWN8 from '../core/statistics/calculateWN8';
|
||||
import sumStats from '../core/statistics/sumStats';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { PossiblyPromise } from '../types/possiblyPromise';
|
||||
// _____ _____ _ _ _ _ _____ ____ __ __ ______ ____ _____ _ __
|
||||
// |_ _| / ____| | | | /\ | | | | / ____/ __ \| \/ | ____| | _ \ /\ / ____| |/ /
|
||||
// | | | (___ | |__| | / \ | | | | | | | | | | \ / | |__ | |_) | / \ | | | ' /
|
||||
// | | \___ \| __ | / /\ \ | | | | | | | | | | |\/| | __| | _ < / /\ \| | | <
|
||||
// _| |_ ____) | | | |/ ____ \| |____| |____ | |___| |__| | | | | |____ | |_) / ____ \ |____| . \
|
||||
// |_____| |_____/|_| |_/_/ \_\______|______| \_____\____/|_| |_|______| |____/_/ \_\_____|_|\_\
|
||||
|
||||
export type SkilledClan = 'SKLLD' | 'SMRI';
|
||||
// import { SlashCommandBuilder } from 'discord.js';
|
||||
// import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
// import getPeriodNow from '../core/blitzkrieg/getPeriodNow';
|
||||
// import getTimeDaysAgo from '../core/blitzkrieg/getTimeDaysAgo';
|
||||
// import { tankDefinitions } from '../core/blitzkrieg/tankDefinitions';
|
||||
// import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
|
||||
// import { tankAverages } from '../core/blitzstars/tankAverages';
|
||||
// import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
// import embedNegative from '../core/discord/embedNegative';
|
||||
// import embedPositive from '../core/discord/embedPositive';
|
||||
// import markdownTable, { TableInput } from '../core/discord/markdownTable';
|
||||
// import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
// import calculateWN8 from '../core/statistics/calculateWN8';
|
||||
// import sumStats from '../core/statistics/sumStats';
|
||||
// import { CommandRegistry } from '../events/interactionCreate';
|
||||
// import { PossiblyPromise } from '../types/possiblyPromise';
|
||||
|
||||
export const SKILLED_CLANS: Record<SkilledClan, string> = {
|
||||
SKLLD: 'Skilled',
|
||||
SMRI: 'Samurai',
|
||||
};
|
||||
// export type SkilledClan = 'SKLLD' | 'SMRI';
|
||||
|
||||
export const eligibleCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: false,
|
||||
// export const SKILLED_CLANS: Record<SkilledClan, string> = {
|
||||
// SKLLD: 'Skilled',
|
||||
// SMRI: 'Samurai',
|
||||
// };
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('eligible')
|
||||
.setDescription('Checks eligibility for any Skilled clan')
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('clan')
|
||||
.setDescription('The clan to check')
|
||||
.setChoices(
|
||||
{
|
||||
name: SKILLED_CLANS.SKLLD,
|
||||
value: 'SKLLD' satisfies SkilledClan,
|
||||
},
|
||||
{ name: SKILLED_CLANS.SMRI, value: 'SMRI' satisfies SkilledClan },
|
||||
)
|
||||
.setRequired(true),
|
||||
)
|
||||
.addStringOption(addUsernameChoices),
|
||||
// export const eligibleCommand: CommandRegistry = {
|
||||
// inProduction: true,
|
||||
// inPublic: false,
|
||||
|
||||
async handler(interaction) {
|
||||
const clan = interaction.options.getString('clan') as SkilledClan;
|
||||
const { id, region: region } = await resolvePlayerFromCommand(interaction);
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const problems: TableInput = [];
|
||||
const title = `${accountInfo.nickname}'s eligibility for ${SKILLED_CLANS[clan]}`;
|
||||
let isEligible = false;
|
||||
let body = '';
|
||||
// command: new SlashCommandBuilder()
|
||||
// .setName('eligible')
|
||||
// .setDescription('Checks eligibility for any Skilled clan')
|
||||
// .addStringOption((option) =>
|
||||
// option
|
||||
// .setName('clan')
|
||||
// .setDescription('The clan to check')
|
||||
// .setChoices(
|
||||
// {
|
||||
// name: SKILLED_CLANS.SKLLD,
|
||||
// value: 'SKLLD' satisfies SkilledClan,
|
||||
// },
|
||||
// { name: SKILLED_CLANS.SMRI, value: 'SMRI' satisfies SkilledClan },
|
||||
// )
|
||||
// .setRequired(true),
|
||||
// )
|
||||
// .addStringOption(addUsernameChoices),
|
||||
|
||||
if (clan === 'SKLLD') {
|
||||
const { diff: diffed } = await getStatsInPeriod(
|
||||
region,
|
||||
id,
|
||||
getTimeDaysAgo(region, 30),
|
||||
getPeriodNow(),
|
||||
);
|
||||
const entries = Object.entries(diffed);
|
||||
const stats = sumStats(entries.map(([, stats]) => stats));
|
||||
const averageTier =
|
||||
(await entries.reduce<PossiblyPromise<number>>(
|
||||
async (accumulator, [tankIdString, stats]) => {
|
||||
const tankId = parseInt(tankIdString);
|
||||
const tankDefinition = (await tankDefinitions)[tankId];
|
||||
// async handler(interaction) {
|
||||
// const clan = interaction.options.getString('clan') as SkilledClan;
|
||||
// const { id, region: region } = await resolvePlayerFromCommand(interaction);
|
||||
// const accountInfo = await getAccountInfo(region, id);
|
||||
// const problems: TableInput = [];
|
||||
// const title = `${accountInfo.nickname}'s eligibility for ${SKILLED_CLANS[clan]}`;
|
||||
// let isEligible = false;
|
||||
// let body = '';
|
||||
|
||||
if (!tankDefinition) return accumulator;
|
||||
// if (clan === 'SKLLD') {
|
||||
// const { diff: diffed } = await getStatsInPeriod(
|
||||
// region,
|
||||
// id,
|
||||
// getTimeDaysAgo(region, 30),
|
||||
// getPeriodNow(),
|
||||
// );
|
||||
// const entries = Object.entries(diffed);
|
||||
// const stats = sumStats(entries.map(([, stats]) => stats));
|
||||
// const averageTier =
|
||||
// (await entries.reduce<PossiblyPromise<number>>(
|
||||
// async (accumulator, [tankIdString, stats]) => {
|
||||
// const tankId = parseInt(tankIdString);
|
||||
// const tankDefinition = (await tankDefinitions)[tankId];
|
||||
|
||||
const tankTier = tankDefinition.tier;
|
||||
// if (!tankDefinition) return accumulator;
|
||||
|
||||
return (await accumulator) + tankTier * stats.battles;
|
||||
},
|
||||
0,
|
||||
)) /
|
||||
(await entries.reduce<PossiblyPromise<number>>(
|
||||
async (accumulator, [tankIdString, stats]) => {
|
||||
const tankId = parseInt(tankIdString);
|
||||
const tankDefinition = (await tankDefinitions)[tankId];
|
||||
// const tankTier = tankDefinition.tier;
|
||||
|
||||
if (!tankDefinition) return accumulator;
|
||||
// return (await accumulator) + tankTier * stats.battles;
|
||||
// },
|
||||
// 0,
|
||||
// )) /
|
||||
// (await entries.reduce<PossiblyPromise<number>>(
|
||||
// async (accumulator, [tankIdString, stats]) => {
|
||||
// const tankId = parseInt(tankIdString);
|
||||
// const tankDefinition = (await tankDefinitions)[tankId];
|
||||
|
||||
return stats.battles;
|
||||
},
|
||||
0,
|
||||
));
|
||||
const WN8 =
|
||||
(await entries.reduce<PossiblyPromise<number>>(
|
||||
async (accumulator, [tankIdString, stats]) => {
|
||||
const tankId = parseInt(tankIdString);
|
||||
// if (!tankDefinition) return accumulator;
|
||||
|
||||
// edge case where new tanks don't have averages
|
||||
if ((await tankAverages)[tankId]) {
|
||||
const tankWN8 = calculateWN8(
|
||||
(await tankAverages)[tankId].all,
|
||||
stats,
|
||||
);
|
||||
// return stats.battles;
|
||||
// },
|
||||
// 0,
|
||||
// ));
|
||||
// const WN8 =
|
||||
// (await entries.reduce<PossiblyPromise<number>>(
|
||||
// async (accumulator, [tankIdString, stats]) => {
|
||||
// const tankId = parseInt(tankIdString);
|
||||
|
||||
if (isNaN(tankWN8)) return accumulator;
|
||||
return (await accumulator) + tankWN8 * stats.battles;
|
||||
} else return accumulator;
|
||||
},
|
||||
0,
|
||||
)) / stats.battles;
|
||||
const tier10Damage =
|
||||
(await entries.reduce<PossiblyPromise<number>>(
|
||||
async (accumulator, [tankIdString, stats]) => {
|
||||
const tankId = parseInt(tankIdString);
|
||||
// // edge case where new tanks don't have averages
|
||||
// if ((await tankAverages)[tankId]) {
|
||||
// const tankWN8 = calculateWN8(
|
||||
// (await tankAverages)[tankId].all,
|
||||
// stats,
|
||||
// );
|
||||
|
||||
if ((await tankDefinitions)[tankId]?.tier === 10) {
|
||||
const tankStats = diffed[tankId];
|
||||
return (await accumulator) + tankStats.damage_dealt;
|
||||
} else return accumulator;
|
||||
},
|
||||
0,
|
||||
)) /
|
||||
(await entries.reduce<PossiblyPromise<number>>(
|
||||
async (accumulator, [tankIdString, stats]) => {
|
||||
const tankId = parseInt(tankIdString);
|
||||
// if (isNaN(tankWN8)) return accumulator;
|
||||
// return (await accumulator) + tankWN8 * stats.battles;
|
||||
// } else return accumulator;
|
||||
// },
|
||||
// 0,
|
||||
// )) / stats.battles;
|
||||
// const tier10Damage =
|
||||
// (await entries.reduce<PossiblyPromise<number>>(
|
||||
// async (accumulator, [tankIdString, stats]) => {
|
||||
// const tankId = parseInt(tankIdString);
|
||||
|
||||
if ((await tankDefinitions)[tankId]?.tier === 10) {
|
||||
const tankStats = diffed[tankId];
|
||||
return (await accumulator) + tankStats.battles;
|
||||
} else return accumulator;
|
||||
},
|
||||
0,
|
||||
));
|
||||
// if ((await tankDefinitions)[tankId]?.tier === 10) {
|
||||
// const tankStats = diffed[tankId];
|
||||
// return (await accumulator) + tankStats.damage_dealt;
|
||||
// } else return accumulator;
|
||||
// },
|
||||
// 0,
|
||||
// )) /
|
||||
// (await entries.reduce<PossiblyPromise<number>>(
|
||||
// async (accumulator, [tankIdString, stats]) => {
|
||||
// const tankId = parseInt(tankIdString);
|
||||
|
||||
if (stats.battles < 126) {
|
||||
problems.push(['Battles', `${stats.battles} (${stats.battles - 126})`]);
|
||||
}
|
||||
if (averageTier < 8) {
|
||||
problems.push([
|
||||
'Average tier',
|
||||
`${averageTier.toFixed(2)} (${(averageTier - 8).toFixed(2)})`,
|
||||
]);
|
||||
}
|
||||
if (stats.wins / stats.battles < 0.6) {
|
||||
problems.push([
|
||||
'Winrate',
|
||||
`${(100 * (stats.wins / stats.battles)).toFixed(2)}% (${(
|
||||
100 *
|
||||
(stats.wins / stats.battles - 0.6)
|
||||
).toFixed(2)}%)`,
|
||||
]);
|
||||
}
|
||||
if (WN8 < 2450) {
|
||||
problems.push([
|
||||
'WN8',
|
||||
`${WN8.toFixed(0)} (${(WN8 - 2450).toFixed(0)})`,
|
||||
]);
|
||||
}
|
||||
if (tier10Damage < 1250) {
|
||||
problems.push([
|
||||
'Tier 10 damage',
|
||||
`${tier10Damage.toFixed(0)} (${tier10Damage - 1250})`,
|
||||
]);
|
||||
}
|
||||
// if ((await tankDefinitions)[tankId]?.tier === 10) {
|
||||
// const tankStats = diffed[tankId];
|
||||
// return (await accumulator) + tankStats.battles;
|
||||
// } else return accumulator;
|
||||
// },
|
||||
// 0,
|
||||
// ));
|
||||
|
||||
isEligible = problems.length === 0;
|
||||
body = isEligible
|
||||
? 'Player is fully eligible.'
|
||||
: `Player is not eligible. Shortcomings in 30-day statistics:\n\n${markdownTable(
|
||||
problems,
|
||||
)}`;
|
||||
} else if (clan === 'SMRI') {
|
||||
const stats = accountInfo.statistics.all;
|
||||
// if (stats.battles < 126) {
|
||||
// problems.push(['Battles', `${stats.battles} (${stats.battles - 126})`]);
|
||||
// }
|
||||
// if (averageTier < 8) {
|
||||
// problems.push([
|
||||
// 'Average tier',
|
||||
// `${averageTier.toFixed(2)} (${(averageTier - 8).toFixed(2)})`,
|
||||
// ]);
|
||||
// }
|
||||
// if (stats.wins / stats.battles < 0.6) {
|
||||
// problems.push([
|
||||
// 'Winrate',
|
||||
// `${(100 * (stats.wins / stats.battles)).toFixed(2)}% (${(
|
||||
// 100 *
|
||||
// (stats.wins / stats.battles - 0.6)
|
||||
// ).toFixed(2)}%)`,
|
||||
// ]);
|
||||
// }
|
||||
// if (WN8 < 2450) {
|
||||
// problems.push([
|
||||
// 'WN8',
|
||||
// `${WN8.toFixed(0)} (${(WN8 - 2450).toFixed(0)})`,
|
||||
// ]);
|
||||
// }
|
||||
// if (tier10Damage < 1250) {
|
||||
// problems.push([
|
||||
// 'Tier 10 damage',
|
||||
// `${tier10Damage.toFixed(0)} (${tier10Damage - 1250})`,
|
||||
// ]);
|
||||
// }
|
||||
|
||||
if (stats.battles < 1500) {
|
||||
problems.push([
|
||||
'Battles',
|
||||
`${stats.battles} (${stats.battles - 1500})`,
|
||||
]);
|
||||
}
|
||||
if (stats.wins / stats.battles <= 0.53) {
|
||||
problems.push([
|
||||
'Winrate',
|
||||
`${(100 * (stats.wins / stats.battles)).toFixed(2)}% (${(
|
||||
100 *
|
||||
(stats.wins / stats.battles - 0.53)
|
||||
).toFixed(2)}%)`,
|
||||
]);
|
||||
}
|
||||
// isEligible = problems.length === 0;
|
||||
// body = isEligible
|
||||
// ? 'Player is fully eligible.'
|
||||
// : `Player is not eligible. Shortcomings in 30-day statistics:\n\n${markdownTable(
|
||||
// problems,
|
||||
// )}`;
|
||||
// } else if (clan === 'SMRI') {
|
||||
// const stats = accountInfo.statistics.all;
|
||||
|
||||
isEligible = problems.length === 0;
|
||||
body = isEligible
|
||||
? 'Player is fully eligible.'
|
||||
: `Player is not eligible. Shortcomings in career statistics:\n\n${markdownTable(
|
||||
problems,
|
||||
)}`;
|
||||
}
|
||||
// if (stats.battles < 1500) {
|
||||
// problems.push([
|
||||
// 'Battles',
|
||||
// `${stats.battles} (${stats.battles - 1500})`,
|
||||
// ]);
|
||||
// }
|
||||
// if (stats.wins / stats.battles <= 0.53) {
|
||||
// problems.push([
|
||||
// 'Winrate',
|
||||
// `${(100 * (stats.wins / stats.battles)).toFixed(2)}% (${(
|
||||
// 100 *
|
||||
// (stats.wins / stats.battles - 0.53)
|
||||
// ).toFixed(2)}%)`,
|
||||
// ]);
|
||||
// }
|
||||
|
||||
return (isEligible ? embedPositive : embedNegative)(title, body);
|
||||
},
|
||||
};
|
||||
// isEligible = problems.length === 0;
|
||||
// body = isEligible
|
||||
// ? 'Player is fully eligible.'
|
||||
// : `Player is not eligible. Shortcomings in career statistics:\n\n${markdownTable(
|
||||
// problems,
|
||||
// )}`;
|
||||
// }
|
||||
|
||||
// return (isEligible ? embedPositive : embedNegative)(title, body);
|
||||
// },
|
||||
// };
|
||||
+48
-52
@@ -1,11 +1,8 @@
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
SlashCommandSubcommandGroupBuilder,
|
||||
} from 'discord.js';
|
||||
import { Locale, SlashCommandSubcommandGroupBuilder } from 'discord.js';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import * as Graph from '../components/Graph';
|
||||
import NoData, { NoDataType } from '../components/NoData';
|
||||
import NoData from '../components/NoData';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import resolveTankId from '../core/blitz/resolveTankId';
|
||||
@@ -13,13 +10,14 @@ import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
|
||||
import getTankHistories from '../core/blitzkrieg/getTankHistories';
|
||||
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
||||
import getPlayerHistories from '../core/blitzstars/getPlayerHistories';
|
||||
import addPeriodSubCommands from '../core/discord/addPeriodSubCommands';
|
||||
import { addPeriodSubCommands } from '../core/discord/addPeriodSubCommands';
|
||||
import addTankChoices from '../core/discord/addTankChoices';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteTanks from '../core/discord/autocompleteTanks';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import buttonPrimary from '../core/discord/buttonPrimary';
|
||||
import { buttonRefresh } from '../core/discord/buttonRefresh';
|
||||
import commandToURL from '../core/discord/commandToURL';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
|
||||
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
|
||||
import resolvePeriodFromCommand, {
|
||||
@@ -29,7 +27,7 @@ import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
|
||||
import resolvePlayerFromCommand, {
|
||||
ResolvedPlayer,
|
||||
} from '../core/discord/resolvePlayerFromCommand';
|
||||
import { CommandRegistryPromisable } from '../events/interactionCreate';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
type EvolutionStatType = 'player' | 'tank';
|
||||
|
||||
@@ -38,6 +36,7 @@ async function render(
|
||||
{ start, end, name }: ResolvedPeriod,
|
||||
type: 'player' | 'tank',
|
||||
tankId: number | null,
|
||||
locale: Locale,
|
||||
) {
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||
@@ -49,7 +48,7 @@ async function render(
|
||||
includeLatestHistories: true,
|
||||
includePreviousHistories: true,
|
||||
})
|
||||
: getTankHistories(region, id, {
|
||||
: getTankHistories(region, id, locale, {
|
||||
tankId: tankId!,
|
||||
start,
|
||||
end,
|
||||
@@ -74,8 +73,8 @@ async function render(
|
||||
const minX = Math.min(...xs);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<TitleBar name={accountInfo.nickname} image={logo} description={name} />
|
||||
<CommandWrapper>
|
||||
<TitleBar title={accountInfo.nickname} image={logo} description={name} />
|
||||
|
||||
{plot.length > 0 && (
|
||||
<Graph.Root
|
||||
@@ -90,8 +89,8 @@ async function render(
|
||||
max: maxX,
|
||||
precision: 0,
|
||||
}}
|
||||
xMinLabel={new Date(minTime * 1000).toDateString()}
|
||||
xMaxLabel={new Date(maxTime * 1000).toDateString()}
|
||||
xMinLabel={new Date(minTime * 1000).toLocaleDateString(locale)}
|
||||
xMaxLabel={new Date(maxTime * 1000).toLocaleDateString(locale)}
|
||||
>
|
||||
{Graph.Line({
|
||||
color: Graph.LineColor.Red,
|
||||
@@ -102,47 +101,37 @@ async function render(
|
||||
</Graph.Root>
|
||||
)}
|
||||
|
||||
{plot.length === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
|
||||
</Wrapper>
|
||||
{plot.length === 0 && <NoData type="battles_in_period" locale={locale} />}
|
||||
</CommandWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const evolutionCommand = new Promise<CommandRegistryPromisable>(
|
||||
export const evolutionCommand = new Promise<CommandRegistry>(
|
||||
async (resolve) => {
|
||||
let playerGroup: SlashCommandSubcommandGroupBuilder;
|
||||
let tankGroup: SlashCommandSubcommandGroupBuilder;
|
||||
const command = new SlashCommandBuilder()
|
||||
.setName('evolution')
|
||||
.setDescription('Evolution of statistics')
|
||||
.addSubcommandGroup((option) => {
|
||||
playerGroup = option
|
||||
.setName('player')
|
||||
.setDescription('Player statistics');
|
||||
|
||||
return playerGroup;
|
||||
})
|
||||
.addSubcommandGroup((option) => {
|
||||
tankGroup = option.setName('tank').setDescription('Tank statistics');
|
||||
|
||||
return tankGroup;
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
addPeriodSubCommands(playerGroup!, (option) =>
|
||||
option.addStringOption(addUsernameChoices),
|
||||
),
|
||||
addPeriodSubCommands(tankGroup!, (option) =>
|
||||
option
|
||||
.addStringOption(addTankChoices)
|
||||
.addStringOption(addUsernameChoices),
|
||||
),
|
||||
]);
|
||||
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command,
|
||||
command: createLocalizedCommand('evolution', [
|
||||
{
|
||||
group: 'player',
|
||||
modify(option: SlashCommandSubcommandGroupBuilder) {
|
||||
addPeriodSubCommands(option, (option) =>
|
||||
option.addStringOption(addUsernameChoices),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'tank',
|
||||
modify(option: SlashCommandSubcommandGroupBuilder) {
|
||||
addPeriodSubCommands(option, (option) =>
|
||||
option
|
||||
.addStringOption(addTankChoices)
|
||||
.addStringOption(addUsernameChoices),
|
||||
);
|
||||
},
|
||||
},
|
||||
]),
|
||||
|
||||
async handler(interaction) {
|
||||
const commandGroup =
|
||||
@@ -151,18 +140,24 @@ export const evolutionCommand = new Promise<CommandRegistryPromisable>(
|
||||
const period = resolvePeriodFromCommand(player.region, interaction);
|
||||
const tankIdRaw = interaction.options.getString('tank')!;
|
||||
const tankId =
|
||||
commandGroup === 'tank' ? await resolveTankId(tankIdRaw) : null;
|
||||
commandGroup === 'tank'
|
||||
? await resolveTankId(tankIdRaw, interaction.locale)
|
||||
: null;
|
||||
const path = commandToURL(interaction, {
|
||||
...player,
|
||||
...getCustomPeriodParams(interaction),
|
||||
tankId,
|
||||
});
|
||||
|
||||
getBlitzStarsLinkButton;
|
||||
|
||||
return [
|
||||
await render(player, period, commandGroup, tankId),
|
||||
buttonPrimary(path, 'Refresh'),
|
||||
await render(
|
||||
player,
|
||||
period,
|
||||
commandGroup,
|
||||
tankId,
|
||||
interaction.locale,
|
||||
),
|
||||
buttonRefresh(interaction, path),
|
||||
await getBlitzStarsLinkButton(
|
||||
player.region,
|
||||
player.id,
|
||||
@@ -188,6 +183,7 @@ export const evolutionCommand = new Promise<CommandRegistryPromisable>(
|
||||
period,
|
||||
commandGroup,
|
||||
parseInt(url.searchParams.get('tankId') ?? '0') || null,
|
||||
interaction.locale,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
+32
-22
@@ -1,9 +1,9 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { Locale } from 'discord.js';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import GenericAllStats from '../components/GenericAllStats';
|
||||
import NoData, { NoDataType } from '../components/NoData';
|
||||
import NoData from '../components/NoData';
|
||||
import TierWeights from '../components/TierWeights';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
|
||||
@@ -16,6 +16,7 @@ import autocompleteTanks from '../core/discord/autocompleteTanks';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { buttonRefresh } from '../core/discord/buttonRefresh';
|
||||
import commandToURL from '../core/discord/commandToURL';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
|
||||
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
|
||||
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
|
||||
@@ -29,55 +30,64 @@ import resolvePlayerFromCommand, {
|
||||
} from '../core/discord/resolvePlayerFromCommand';
|
||||
import { StatFilters, filterStats } from '../core/statistics/filterStats';
|
||||
import { getTierWeights } from '../core/statistics/getTierWeights';
|
||||
import { CommandRegistryPromisable } from '../events/interactionCreate';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
async function render(
|
||||
{ region, id }: ResolvedPlayer,
|
||||
{ start, end, name }: ResolvedPeriod,
|
||||
filters: StatFilters,
|
||||
locale: Locale,
|
||||
) {
|
||||
const { nickname } = await getAccountInfo(region, id);
|
||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined;
|
||||
const diffedTankStats = await getStatsInPeriod(region, id, start, end);
|
||||
const diffedTankStats = await getStatsInPeriod(
|
||||
region,
|
||||
id,
|
||||
start,
|
||||
end,
|
||||
locale,
|
||||
);
|
||||
const { stats, supplementary, filteredOrder } = await filterStats(
|
||||
diffedTankStats,
|
||||
filters,
|
||||
);
|
||||
const filterDescriptions = await filtersToDescription(filters);
|
||||
const filterDescriptions = await filtersToDescription(filters, locale);
|
||||
const tierWeights = await getTierWeights(diffedTankStats.diff, filteredOrder);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
name={nickname}
|
||||
title={nickname}
|
||||
image={clanImage}
|
||||
description={`${name} • ${filterDescriptions}`}
|
||||
/>
|
||||
|
||||
{stats.battles === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
|
||||
{stats.battles === 0 && (
|
||||
<NoData type="battles_in_period" locale={locale} />
|
||||
)}
|
||||
{stats.battles > 0 && <TierWeights weights={tierWeights!} />}
|
||||
{stats.battles > 0 && (
|
||||
<GenericAllStats stats={stats} supplementaryStats={supplementary} />
|
||||
<GenericAllStats
|
||||
stats={stats}
|
||||
supplementaryStats={supplementary}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</Wrapper>
|
||||
</CommandWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const fullStatsCommand = new Promise<CommandRegistryPromisable>(
|
||||
export const fullStatsCommand = new Promise<CommandRegistry>(
|
||||
async (resolve) => {
|
||||
const command = await addPeriodicFilterOptions(
|
||||
new SlashCommandBuilder()
|
||||
.setName('full-stats')
|
||||
.setDescription('Full in-game statistics'),
|
||||
(option) => option.addStringOption(addUsernameChoices),
|
||||
);
|
||||
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command,
|
||||
command: await addPeriodicFilterOptions(
|
||||
createLocalizedCommand('full-stats'),
|
||||
(option) => option.addStringOption(addUsernameChoices),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const player = await resolvePlayerFromCommand(interaction);
|
||||
@@ -90,7 +100,7 @@ export const fullStatsCommand = new Promise<CommandRegistryPromisable>(
|
||||
});
|
||||
|
||||
return Promise.all([
|
||||
render(player, period, filters),
|
||||
render(player, period, filters, interaction.locale),
|
||||
buttonRefresh(interaction, path),
|
||||
getBlitzStarsLinkButton(player.region, player.id),
|
||||
]);
|
||||
@@ -106,7 +116,7 @@ export const fullStatsCommand = new Promise<CommandRegistryPromisable>(
|
||||
const period = resolvePeriodFromButton(player.region, interaction);
|
||||
const filters = getFiltersFromButton(interaction);
|
||||
|
||||
return await render(player, period, filters);
|
||||
return await render(player, period, filters, interaction.locale);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
+75
-53
@@ -1,69 +1,91 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { Locale } from 'discord.js';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import GenericStats from '../components/GenericStats';
|
||||
import NoData, { NoDataType } from '../components/NoData';
|
||||
import NoData from '../components/NoData';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanInfo } from '../core/blitz/getClanInfo';
|
||||
import addClanChoices from '../core/discord/addClanChoices';
|
||||
import autocompleteClan from '../core/discord/autocompleteClan';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { localizationObject } from '../core/discord/localizationObject';
|
||||
import resolveClanFromCommand from '../core/discord/resolveClanFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
const DEFAULT_THRESHOLD = 7;
|
||||
|
||||
export const inactiveCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
export const inactiveCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('inactive')
|
||||
.setDescription('Lists all inactive players')
|
||||
.addStringOption(addClanChoices)
|
||||
.addNumberOption((option) =>
|
||||
option
|
||||
.setName('threshold')
|
||||
.setDescription(
|
||||
`The number of days inactive (default: ${DEFAULT_THRESHOLD})`,
|
||||
command: createLocalizedCommand('inactive')
|
||||
.addStringOption(addClanChoices)
|
||||
.addNumberOption((option) => {
|
||||
const { translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName('threshold')
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.commands.inactive.options.threshold'),
|
||||
)
|
||||
.setDescription(
|
||||
translate('bot.commands.inactive.options.threshold.description'),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.inactive.options.threshold.description',
|
||||
),
|
||||
)
|
||||
.setMinValue(0);
|
||||
}),
|
||||
|
||||
async handler(interaction) {
|
||||
const { translate } = translator(interaction.locale);
|
||||
const { region, id } = await resolveClanFromCommand(interaction);
|
||||
const threshold =
|
||||
interaction.options.getNumber('threshold')! ?? DEFAULT_THRESHOLD;
|
||||
const time = new Date().getTime() / 1000;
|
||||
const clanInfo = await getClanInfo(region, id);
|
||||
const accountInfo = await getAccountInfo(region, clanInfo.members_ids);
|
||||
const inactive = accountInfo
|
||||
.map(
|
||||
(account) =>
|
||||
[
|
||||
account.nickname,
|
||||
(time - account.last_battle_time) / 60 / 60 / 24,
|
||||
] as [string, number],
|
||||
)
|
||||
.setMinValue(0),
|
||||
),
|
||||
.filter(([, inactiveDays]) => inactiveDays >= threshold)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(
|
||||
([name, days]) =>
|
||||
[
|
||||
name,
|
||||
translate('bot.commands.inactive.body.listing', [
|
||||
days.toFixed(0),
|
||||
]),
|
||||
] as [string, string],
|
||||
);
|
||||
const hasInactiveMembers = inactive.length > 0;
|
||||
|
||||
async handler(interaction) {
|
||||
const { region, id } = await resolveClanFromCommand(interaction);
|
||||
const threshold =
|
||||
interaction.options.getNumber('threshold')! ?? DEFAULT_THRESHOLD;
|
||||
const time = new Date().getTime() / 1000;
|
||||
const clanInfo = await getClanInfo(region, id);
|
||||
const accountInfo = await getAccountInfo(region, clanInfo.members_ids);
|
||||
const inactive = accountInfo
|
||||
.map(
|
||||
(account) =>
|
||||
[
|
||||
account.nickname,
|
||||
(time - account.last_battle_time) / 60 / 60 / 24,
|
||||
] as [string, number],
|
||||
)
|
||||
.filter(([, inactiveDays]) => inactiveDays >= threshold)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(
|
||||
([name, days]) => [name, `${days.toFixed(0)} days`] as [string, string],
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={clanInfo.name}
|
||||
image={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanInfo.emblem_set_id}.png`}
|
||||
description={`${translate('bot.commands.inactive.body.subtitle', [`${threshold}`])} • ${new Date().toLocaleDateString(interaction.locale)}`}
|
||||
/>
|
||||
|
||||
{!hasInactiveMembers && (
|
||||
<NoData type="players_in_period" locale={interaction.locale} />
|
||||
)}
|
||||
{hasInactiveMembers && <GenericStats stats={inactive} />}
|
||||
</CommandWrapper>
|
||||
);
|
||||
const hasInactiveMembers = inactive.length > 0;
|
||||
},
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<TitleBar
|
||||
name={clanInfo.name}
|
||||
image={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanInfo.emblem_set_id}.png`}
|
||||
description={`Inactive for ${threshold}+ Days • ${new Date().toDateString()}`}
|
||||
/>
|
||||
|
||||
{!hasInactiveMembers && <NoData type={NoDataType.PlayersInPeriod} />}
|
||||
{hasInactiveMembers && <GenericStats stats={inactive} />}
|
||||
</Wrapper>
|
||||
);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteClan,
|
||||
};
|
||||
autocomplete: autocompleteClan,
|
||||
});
|
||||
});
|
||||
+158
-147
@@ -1,209 +1,220 @@
|
||||
import { teal } from '@radix-ui/colors';
|
||||
import { GuildMemberRoleManager, SlashCommandBuilder } from 'discord.js';
|
||||
import { GuildMemberRoleManager } from 'discord.js';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import discord from '../../discord.json' assert { type: 'json' };
|
||||
import { Glow } from '../components/AllStatsOverview/components/WN8Display/components/Glow';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { REGION_NAMES } from '../constants/regions';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitz';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import embedInfo from '../core/discord/embedInfo';
|
||||
import embedNegative from '../core/discord/embedNegative';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
export const verifyCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('link')
|
||||
.setDescription('Links your Blitz and Discord account')
|
||||
.addStringOption((option) => addUsernameChoices(option).setRequired(true)),
|
||||
command: createLocalizedCommand('link').addStringOption((option) =>
|
||||
addUsernameChoices(option).setRequired(true),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const { id, region } = await resolvePlayerFromCommand(interaction);
|
||||
const discordId = parseInt(interaction.user.id);
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const { id, region } = await resolvePlayerFromCommand(interaction);
|
||||
const discordId = parseInt(interaction.user.id);
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
|
||||
|
||||
await linkBlitzAndDiscord(discordId, region, id);
|
||||
await linkBlitzAndDiscord(discordId, region, id);
|
||||
|
||||
if (interaction.guildId === discord.sklld_guild_id) {
|
||||
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
|
||||
return embedNegative(
|
||||
`${markdownEscape(interaction.user.username)} failed to verify`,
|
||||
"I don't have the permission to change your manage roles.",
|
||||
if (interaction.guildId === discord.sklld_guild_id) {
|
||||
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
|
||||
return embedNegative(
|
||||
translate(translate('bot.commands.link.body.no_role_permissions'), [
|
||||
markdownEscape(interaction.user.username),
|
||||
]),
|
||||
translate(
|
||||
translate(
|
||||
'bot.commands.link.body.no_role_permissions.description',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await (interaction.member!.roles as GuildMemberRoleManager).remove(
|
||||
discord.sklld_verify_role,
|
||||
);
|
||||
await (interaction.member!.roles as GuildMemberRoleManager).add(
|
||||
discord.sklld_peasant_role,
|
||||
);
|
||||
}
|
||||
|
||||
await (interaction.member!.roles as GuildMemberRoleManager).remove(
|
||||
discord.sklld_verify_role,
|
||||
);
|
||||
await (interaction.member!.roles as GuildMemberRoleManager).add(
|
||||
discord.sklld_peasant_role,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
<Wrapper fat>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Glow color={teal.teal9} rotation={90} />
|
||||
|
||||
return [
|
||||
<CommandWrapper fat>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Glow color={teal.teal9} rotation={90} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
interaction.user.avatarURL({ extension: 'png' }) ??
|
||||
'https://i.stack.imgur.com/l60Hf.png'
|
||||
}
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 900,
|
||||
maxWidth: 240,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{interaction.user.displayName}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
@{interaction.user.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
color: theme.colors.textLowContrast_teal,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="https://i.imgur.com/jIcRgog.png"
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 16 }}>Accounts linked</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{clanAccountInfo?.clan ? (
|
||||
<img
|
||||
src={
|
||||
interaction.user.avatarURL({ extension: 'png' }) ??
|
||||
'https://i.stack.imgur.com/l60Hf.png'
|
||||
}
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
src={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanAccountInfo?.clan?.emblem_set_id}.png`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 900,
|
||||
maxWidth: 240,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{interaction.user.displayName}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
@{interaction.user.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
color: theme.colors.textLowContrast_teal,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
<img
|
||||
src="https://i.imgur.com/jIcRgog.png"
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 900,
|
||||
maxWidth: 240,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
width: 16,
|
||||
height: 16,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{ fontSize: 16 }}
|
||||
>{t`bot.commands.link.body.accounts_linked`}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{clanAccountInfo?.clan ? (
|
||||
<img
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
src={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanAccountInfo?.clan?.emblem_set_id}.png`}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{accountInfo.nickname}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
{clanAccountInfo?.clan
|
||||
? `[${clanAccountInfo?.clan?.tag}] - `
|
||||
: ''}
|
||||
{REGION_NAMES[region]}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 900,
|
||||
maxWidth: 240,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{accountInfo.nickname}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
{clanAccountInfo?.clan
|
||||
? `[${clanAccountInfo?.clan?.tag}] • `
|
||||
: ''}
|
||||
{translate(`common.regions.normal.${region}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Glow color={teal.teal9} rotation={-90} />
|
||||
</div>
|
||||
</CommandWrapper>,
|
||||
|
||||
<Glow color={teal.teal9} rotation={-90} />
|
||||
</div>
|
||||
</Wrapper>,
|
||||
embedInfo(
|
||||
t`bot.commands.link.embed.title`,
|
||||
t`bot.commands.link.embed.description`,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
embedInfo(
|
||||
'Incorrect account?',
|
||||
'Use the command again and select a username from the search results.',
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
};
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
});
|
||||
+104
-99
@@ -1,9 +1,7 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import NoData, { NoDataType } from '../components/NoData';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import NoData from '../components/NoData';
|
||||
import * as Tanks from '../components/Tanks';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { encyclopediaInfo } from '../core/blitz/encyclopediaInfo';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import getTankStats from '../core/blitz/getTankStats';
|
||||
@@ -18,111 +16,118 @@ import { tankIcon } from '../core/blitzkrieg/tankIcon';
|
||||
import addTierChoices from '../core/discord/addTierChoices';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
export const ownedTanksCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
export const ownedTanksCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('owned-tanks')
|
||||
.setDescription("Shows a player's owned tanks")
|
||||
.addStringOption(addTierChoices)
|
||||
.addStringOption(addUsernameChoices),
|
||||
command: createLocalizedCommand('owned-tanks')
|
||||
.addStringOption(addTierChoices)
|
||||
.addStringOption(addUsernameChoices),
|
||||
|
||||
async handler(interaction) {
|
||||
const tier = Number(interaction.options.getString('tier'));
|
||||
const account = await resolvePlayerFromCommand(interaction);
|
||||
const { id, region: server } = account;
|
||||
const accountInfo = await getAccountInfo(server, id);
|
||||
const tankStats = await getTankStats(server, id);
|
||||
const filteredTanks = (
|
||||
await Promise.all(
|
||||
tankStats.map(async (tankData) => ({
|
||||
tankDefinitions: (await tankDefinitions)[tankData.tank_id]!,
|
||||
id: tankData.tank_id,
|
||||
})),
|
||||
)
|
||||
).filter((tank) => tank.tankDefinitions?.tier === tier);
|
||||
const clanAccountInfo = await getClanAccountInfo(server, id, ['clan']);
|
||||
const groupedTanks: Record<string, TankDefinition[]> = {};
|
||||
const nations: string[] = [];
|
||||
async handler(interaction) {
|
||||
const { translate } = translator(interaction.locale);
|
||||
const tier = Number(interaction.options.getString('tier'));
|
||||
const account = await resolvePlayerFromCommand(interaction);
|
||||
const { id, region: server } = account;
|
||||
const accountInfo = await getAccountInfo(server, id);
|
||||
const tankStats = await getTankStats(server, id, interaction.locale);
|
||||
const filteredTanks = (
|
||||
await Promise.all(
|
||||
tankStats.map(async (tankData) => ({
|
||||
tankDefinitions: (await tankDefinitions)[tankData.tank_id]!,
|
||||
id: tankData.tank_id,
|
||||
})),
|
||||
)
|
||||
).filter((tank) => tank.tankDefinitions?.tier === tier);
|
||||
const clanAccountInfo = await getClanAccountInfo(server, id, ['clan']);
|
||||
const groupedTanks: Record<string, TankDefinition[]> = {};
|
||||
const nations: string[] = [];
|
||||
|
||||
filteredTanks.forEach((tank) => {
|
||||
if (groupedTanks[tank.tankDefinitions.nation] === undefined) {
|
||||
groupedTanks[tank.tankDefinitions.nation] = [tank.tankDefinitions];
|
||||
nations.push(tank.tankDefinitions.nation);
|
||||
} else {
|
||||
groupedTanks[tank.tankDefinitions.nation].push(tank.tankDefinitions);
|
||||
}
|
||||
});
|
||||
filteredTanks.forEach((tank) => {
|
||||
if (groupedTanks[tank.tankDefinitions.nation] === undefined) {
|
||||
groupedTanks[tank.tankDefinitions.nation] = [tank.tankDefinitions];
|
||||
nations.push(tank.tankDefinitions.nation);
|
||||
} else {
|
||||
groupedTanks[tank.tankDefinitions.nation].push(tank.tankDefinitions);
|
||||
}
|
||||
});
|
||||
|
||||
nations.sort();
|
||||
nations.sort();
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<TitleBar
|
||||
name={accountInfo.nickname}
|
||||
image={
|
||||
clanAccountInfo?.clan
|
||||
? emblemIdToURL(clanAccountInfo.clan.emblem_set_id)
|
||||
: undefined
|
||||
}
|
||||
description={`Tier ${TIER_ROMAN_NUMERALS[tier as Tier]} tanks`}
|
||||
/>
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={accountInfo.nickname}
|
||||
image={
|
||||
clanAccountInfo?.clan
|
||||
? emblemIdToURL(clanAccountInfo.clan.emblem_set_id)
|
||||
: undefined
|
||||
}
|
||||
description={translate('bot.commands.owned_tanks.body.subtitle', [
|
||||
TIER_ROMAN_NUMERALS[tier as Tier],
|
||||
])}
|
||||
/>
|
||||
|
||||
{filteredTanks.length === 0 && <NoData type={NoDataType.TanksFound} />}
|
||||
{filteredTanks.length === 0 && (
|
||||
<NoData type="tanks_found" locale={interaction.locale} />
|
||||
)}
|
||||
|
||||
{filteredTanks.length > 0 &&
|
||||
(await Promise.all(
|
||||
nations.map(async (nation) => {
|
||||
const tanks = groupedTanks[nation];
|
||||
const leftColumnSize = Math.ceil(tanks.length / 2);
|
||||
const leftColumn = tanks.slice(0, leftColumnSize);
|
||||
const rightColumn = tanks.slice(leftColumnSize);
|
||||
{filteredTanks.length > 0 &&
|
||||
(await Promise.all(
|
||||
nations.map(async (nation) => {
|
||||
const tanks = groupedTanks[nation];
|
||||
const leftColumnSize = Math.ceil(tanks.length / 2);
|
||||
const leftColumn = tanks.slice(0, leftColumnSize);
|
||||
const rightColumn = tanks.slice(leftColumnSize);
|
||||
|
||||
return (
|
||||
<Tanks.Root>
|
||||
<Tanks.Title>
|
||||
{(await encyclopediaInfo).vehicle_nations[nation]}
|
||||
</Tanks.Title>
|
||||
return (
|
||||
<Tanks.Root key={nation}>
|
||||
<Tanks.Title>
|
||||
{translate(`common.nations.${nation}`)}
|
||||
</Tanks.Title>
|
||||
|
||||
<Tanks.Row>
|
||||
<Tanks.Column>
|
||||
{await Promise.all(
|
||||
leftColumn.map(async (tank) => (
|
||||
<Tanks.Item
|
||||
key={tank.id}
|
||||
name={tank.name}
|
||||
tankType={tank.type}
|
||||
image={tankIcon(tank.id)}
|
||||
treeType={tank.treeType}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</Tanks.Column>
|
||||
<Tanks.Column>
|
||||
{await Promise.all(
|
||||
rightColumn.map(async (tank) => (
|
||||
<Tanks.Item
|
||||
key={tank.id}
|
||||
name={tank.name}
|
||||
tankType={tank.type}
|
||||
image={tankIcon(tank.id)}
|
||||
treeType={tank.treeType}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</Tanks.Column>
|
||||
</Tanks.Row>
|
||||
</Tanks.Root>
|
||||
);
|
||||
}),
|
||||
))}
|
||||
</Wrapper>
|
||||
);
|
||||
},
|
||||
<Tanks.Row>
|
||||
<Tanks.Column>
|
||||
{await Promise.all(
|
||||
leftColumn.map(async (tank) => (
|
||||
<Tanks.Item
|
||||
key={tank.id}
|
||||
name={tank.name}
|
||||
tankType={tank.class}
|
||||
image={tankIcon(tank.id)}
|
||||
treeType={tank.treeType}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</Tanks.Column>
|
||||
<Tanks.Column>
|
||||
{await Promise.all(
|
||||
rightColumn.map(async (tank) => (
|
||||
<Tanks.Item
|
||||
key={tank.id}
|
||||
name={tank.name}
|
||||
tankType={tank.class}
|
||||
image={tankIcon(tank.id)}
|
||||
treeType={tank.treeType}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</Tanks.Column>
|
||||
</Tanks.Row>
|
||||
</Tanks.Root>
|
||||
);
|
||||
}),
|
||||
))}
|
||||
</CommandWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
};
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
});
|
||||
+39
-37
@@ -1,42 +1,44 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
export const permissionsCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
export const permissionsCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('permissions')
|
||||
.setDescription('Checks wether the bot has the required permissions'),
|
||||
command: createLocalizedCommand('permissions'),
|
||||
|
||||
async handler(interaction) {
|
||||
// friday the 13th permissions easter egg
|
||||
const isFridayThe13th =
|
||||
new Date().getDay() === 5 && new Date().getDate() === 13;
|
||||
const permissions = [
|
||||
[
|
||||
interaction.appPermissions?.has('ViewChannel'),
|
||||
'View channels: needed for the refresh button to work',
|
||||
],
|
||||
[
|
||||
interaction.appPermissions?.has('ReadMessageHistory'),
|
||||
'Read message history: also needed for the refresh button to work',
|
||||
],
|
||||
[
|
||||
interaction.appPermissions?.has('AttachFiles'),
|
||||
'Attach files: needed for image-based commands to work',
|
||||
],
|
||||
];
|
||||
const body = permissions
|
||||
.map(([hasPermission, description]) => {
|
||||
if (hasPermission) {
|
||||
return `${isFridayThe13th ? '😇' : '✅'} ${description}`;
|
||||
} else {
|
||||
return `${isFridayThe13th ? '💀' : '❌'} ${description}`;
|
||||
}
|
||||
})
|
||||
.join('\n');
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
// friday the 13th permissions easter egg
|
||||
const isFridayThe13th =
|
||||
new Date().getDay() === 5 && new Date().getDate() === 13;
|
||||
const permissions = [
|
||||
[
|
||||
interaction.appPermissions?.has('ViewChannel'),
|
||||
t`bot.commands.permissions.body.view_channels`,
|
||||
],
|
||||
[
|
||||
interaction.appPermissions?.has('ReadMessageHistory'),
|
||||
t`bot.commands.permissions.body.read_message_history`,
|
||||
],
|
||||
[
|
||||
interaction.appPermissions?.has('AttachFiles'),
|
||||
t`bot.commands.permissions.body.attach_files`,
|
||||
],
|
||||
];
|
||||
const body = permissions
|
||||
.map(([hasPermission, description]) => {
|
||||
if (hasPermission) {
|
||||
return `${isFridayThe13th ? '😇' : '✅'} ${description}`;
|
||||
} else {
|
||||
return `${isFridayThe13th ? '💀' : '❌'} ${description}`;
|
||||
}
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `# Permissions\n${body}`;
|
||||
},
|
||||
};
|
||||
return translate('bot.commands.permissions.body.title', [body]);
|
||||
},
|
||||
});
|
||||
});
|
||||
+29
-26
@@ -1,32 +1,35 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
export const pingCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
handlesInteraction: true,
|
||||
export const pingCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
handlesInteraction: true,
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('ping')
|
||||
.setDescription('Check if the bot is alive')
|
||||
.addSubcommand((option) =>
|
||||
option.setName('blitzkrieg').setDescription('Ping Blitzkrieg'),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
option.setName('blitz').setDescription('Ping Blitz'),
|
||||
),
|
||||
command: createLocalizedCommand('ping', [
|
||||
{ subcommand: 'blitzkrieg' },
|
||||
{ subcommand: 'wotb' },
|
||||
]),
|
||||
|
||||
async handler(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const executionStart = Date.now();
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const executionStart = Date.now();
|
||||
|
||||
if (subcommand === 'blitzkrieg') {
|
||||
await interaction.editReply('Pong 🏓');
|
||||
} else {
|
||||
await fetch('https://api.wotblitz.com/');
|
||||
}
|
||||
if (subcommand === 'blitzkrieg') {
|
||||
await interaction.editReply(t`bot.commands.ping.body.pong`);
|
||||
} else {
|
||||
await fetch('https://api.wotblitz.com/');
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - executionStart;
|
||||
interaction.editReply(`Pong 🏓 - ${executionTime}ms`);
|
||||
},
|
||||
};
|
||||
const executionTime = Date.now() - executionStart;
|
||||
interaction.editReply(
|
||||
translate('bot.commands.ping.body.pong_with_time', [
|
||||
`${Math.round(executionTime)}`,
|
||||
]),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,68 +1,80 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { startCase } from 'lodash';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { getAccountAchievements } from '../core/blitz/getAccountAchievements';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import embedInfo from '../core/discord/embedInfo';
|
||||
import markdownTable, { TableInputEntry } from '../core/discord/markdownTable';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
// _____ _____ _ _ _ _ _____ ____ __ __ ______ ____ _____ _ __
|
||||
// |_ _| / ____| | | | /\ | | | | / ____/ __ \| \/ | ____| | _ \ /\ / ____| |/ /
|
||||
// | | | (___ | |__| | / \ | | | | | | | | | | \ / | |__ | |_) | / \ | | | ' /
|
||||
// | | \___ \| __ | / /\ \ | | | | | | | | | | |\/| | __| | _ < / /\ \| | | <
|
||||
// _| |_ ____) | | | |/ ____ \| |____| |____ | |___| |__| | | | | |____ | |_) / ____ \ |____| . \
|
||||
// |_____| |_____/|_| |_/_/ \_\______|______| \_____\____/|_| |_|______| |____/_/ \_\_____|_|\_\
|
||||
|
||||
type SortBy = 'name' | 'count';
|
||||
// import { SlashCommandBuilder } from 'discord.js';
|
||||
// import { startCase } from 'lodash';
|
||||
// import markdownEscape from 'markdown-escape';
|
||||
// import { getAccountAchievements } from '../core/blitz/getAccountAchievements';
|
||||
// import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
// import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
// import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
// import embedInfo from '../core/discord/embedInfo';
|
||||
// import markdownTable, { TableInputEntry } from '../core/discord/markdownTable';
|
||||
// import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
// import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
export const playerAchievementsCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
// type SortBy = 'name' | 'count';
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('player-achievements')
|
||||
.setDescription("All the player's achievements")
|
||||
.addStringOption(addUsernameChoices)
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('sort')
|
||||
.setDescription('What to sort by (default: name)')
|
||||
.addChoices(
|
||||
{ name: 'By name', value: 'name' satisfies SortBy },
|
||||
{ name: 'By count', value: 'count' satisfies SortBy },
|
||||
),
|
||||
),
|
||||
// export const playerAchievementsCommand = new Promise<CommandRegistry>(
|
||||
// (resolve) => {
|
||||
// resolve({
|
||||
// inProduction: true,
|
||||
// inPublic: true,
|
||||
|
||||
async handler(interaction) {
|
||||
const sortBy = (interaction.options.getString('sort') ?? 'name') as SortBy;
|
||||
const account = await resolvePlayerFromCommand(interaction);
|
||||
const { id, region: region } = account;
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const accountsAchievements = await getAccountAchievements(region, id);
|
||||
const accountAchievements = accountsAchievements;
|
||||
const compound = {
|
||||
...accountAchievements.achievements,
|
||||
...accountAchievements.max_series,
|
||||
};
|
||||
// command: new SlashCommandBuilder()
|
||||
// .setName('player-achievements')
|
||||
// .setDescription("All the player's achievements")
|
||||
// .addStringOption(addUsernameChoices)
|
||||
// .addStringOption((option) =>
|
||||
// option
|
||||
// .setName('sort')
|
||||
// .setDescription('What to sort by (default: name)')
|
||||
// .addChoices(
|
||||
// { name: 'By name', value: 'name' satisfies SortBy },
|
||||
// { name: 'By count', value: 'count' satisfies SortBy },
|
||||
// ),
|
||||
// ),
|
||||
|
||||
return embedInfo(
|
||||
`${markdownEscape(accountInfo.nickname)}'s information`,
|
||||
// async handler(interaction) {
|
||||
// const sortBy = (interaction.options.getString('sort') ??
|
||||
// 'name') as SortBy;
|
||||
// const account = await resolvePlayerFromCommand(interaction);
|
||||
// const { id, region: region } = account;
|
||||
// const accountInfo = await getAccountInfo(region, id);
|
||||
// const accountsAchievements = await getAccountAchievements(region, id);
|
||||
// const accountAchievements = accountsAchievements;
|
||||
// const compound = {
|
||||
// ...accountAchievements.achievements,
|
||||
// ...accountAchievements.max_series,
|
||||
// };
|
||||
|
||||
markdownTable(
|
||||
Object.keys(compound)
|
||||
.filter((achievement) => compound[achievement] > 0)
|
||||
.sort(
|
||||
sortBy === 'count'
|
||||
? (a, b) => compound[b] - compound[a]
|
||||
: undefined,
|
||||
)
|
||||
.map(
|
||||
(achievement) =>
|
||||
[
|
||||
startCase(achievement),
|
||||
compound[achievement],
|
||||
] as TableInputEntry,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
// return embedInfo(
|
||||
// `${markdownEscape(accountInfo.nickname)}'s information`,
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
};
|
||||
// markdownTable(
|
||||
// Object.keys(compound)
|
||||
// .filter((achievement) => compound[achievement] > 0)
|
||||
// .sort(
|
||||
// sortBy === 'count'
|
||||
// ? (a, b) => compound[b] - compound[a]
|
||||
// : undefined,
|
||||
// )
|
||||
// .map(
|
||||
// (achievement) =>
|
||||
// [
|
||||
// startCase(achievement),
|
||||
// compound[achievement],
|
||||
// ] as TableInputEntry,
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
|
||||
// autocomplete: autocompleteUsername,
|
||||
// });
|
||||
// },
|
||||
// );
|
||||
+57
-40
@@ -1,54 +1,71 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import embedInfo from '../core/discord/embedInfo';
|
||||
import markdownTable from '../core/discord/markdownTable';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
export const playerInfoCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
export const playerInfoCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('player-info')
|
||||
.setDescription('Basic information about a player')
|
||||
.addStringOption(addUsernameChoices),
|
||||
command:
|
||||
createLocalizedCommand('player-info').addStringOption(addUsernameChoices),
|
||||
|
||||
async handler(interaction) {
|
||||
const account = await resolvePlayerFromCommand(interaction);
|
||||
const { id, region: region } = account;
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const account = await resolvePlayerFromCommand(interaction);
|
||||
const { id, region: region } = account;
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
|
||||
return embedInfo(
|
||||
`${markdownEscape(accountInfo.nickname)}'s information`,
|
||||
return embedInfo(
|
||||
translate('bot.commands.player_info.body.title', [
|
||||
markdownEscape(accountInfo.nickname),
|
||||
]),
|
||||
|
||||
markdownTable([
|
||||
['Nickname', `${accountInfo.nickname}`],
|
||||
['Battles', `${accountInfo.statistics.all.battles}`],
|
||||
[
|
||||
'Winrate',
|
||||
`${(
|
||||
100 *
|
||||
(accountInfo.statistics.all.wins /
|
||||
accountInfo.statistics.all.battles)
|
||||
).toFixed(2)}%`,
|
||||
],
|
||||
[],
|
||||
['Account ID', `${accountInfo.account_id}`],
|
||||
[
|
||||
'Created',
|
||||
`${new Date(accountInfo.created_at * 1000).toDateString()}`,
|
||||
],
|
||||
[
|
||||
'Last battle',
|
||||
`${new Date(accountInfo.last_battle_time * 1000).toDateString()}`,
|
||||
],
|
||||
]),
|
||||
);
|
||||
},
|
||||
markdownTable([
|
||||
[
|
||||
t`bot.commands.player_info.body.nickname`,
|
||||
`${accountInfo.nickname}`,
|
||||
],
|
||||
[
|
||||
t`bot.commands.player_info.body.battles`,
|
||||
`${accountInfo.statistics.all.battles}`,
|
||||
],
|
||||
[
|
||||
t`bot.commands.player_info.body.winrate`,
|
||||
`${(
|
||||
100 *
|
||||
(accountInfo.statistics.all.wins /
|
||||
accountInfo.statistics.all.battles)
|
||||
).toFixed(2)}%`,
|
||||
],
|
||||
[],
|
||||
[
|
||||
t`bot.commands.player_info.body.account_id`,
|
||||
`${accountInfo.account_id}`,
|
||||
],
|
||||
[
|
||||
t`bot.commands.player_info.body.created`,
|
||||
new Date(accountInfo.created_at * 1000).toLocaleDateString(
|
||||
interaction.locale,
|
||||
),
|
||||
],
|
||||
[
|
||||
t`bot.commands.player_info.body.last_battle`,
|
||||
new Date(accountInfo.last_battle_time * 1000).toLocaleDateString(
|
||||
interaction.locale,
|
||||
),
|
||||
],
|
||||
]),
|
||||
);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
};
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
});
|
||||
+353
-303
@@ -1,17 +1,17 @@
|
||||
import {
|
||||
APIApplicationCommandOptionChoice,
|
||||
SlashCommandBuilder,
|
||||
Locale,
|
||||
SlashCommandSubcommandBuilder,
|
||||
} from 'discord.js';
|
||||
import { range } from 'lodash';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import * as Leaderboard from '../components/Leaderboard';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { LEAGUES } from '../constants/leagues';
|
||||
import { FIRST_ARCHIVED_RATINGS_SEASON } from '../constants/ratings';
|
||||
import { REGION_NAMES_SHORT, Region } from '../constants/regions';
|
||||
import { Region } from '../constants/regions';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import getRatingsInfo from '../core/blitz/getRatingsInfo';
|
||||
@@ -25,9 +25,11 @@ import getArchivedRatingsMidnightLeaderboard from '../core/blitzkrieg/getArchive
|
||||
import addRegionChoices from '../core/discord/addRegionChoices';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import embedNegative from '../core/discord/embedNegative';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { localizationObject } from '../core/discord/localizationObject';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { CommandRegistryPromisable } from '../events/interactionCreate';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { UserError } from '../hooks/userError';
|
||||
|
||||
export interface RatingsPlayer {
|
||||
@@ -117,339 +119,387 @@ export interface RatingsNeighbors {
|
||||
neighbors: RatingsPlayer[];
|
||||
}
|
||||
|
||||
const noOngoingSeason = embedNegative(
|
||||
'No ongoing season',
|
||||
"Wargaming didn't provide any data for this season.",
|
||||
);
|
||||
interface SimplifiedPlayer {
|
||||
id: number;
|
||||
score: number;
|
||||
position: number;
|
||||
clan: string | undefined;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
export const ratingsCommand = new Promise<CommandRegistryPromisable>(
|
||||
async (resolve) => {
|
||||
const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
|
||||
const onGoingSeason = await isOnGoingRatingsSeason();
|
||||
const seasonNumbers = range(
|
||||
FIRST_ARCHIVED_RATINGS_SEASON,
|
||||
latestArchivedSeasonNumber + (onGoingSeason ? 1 : 0) + 1,
|
||||
);
|
||||
const DEFAULT_LIMIT = 10;
|
||||
|
||||
function addSubcommands(option: SlashCommandSubcommandBuilder) {
|
||||
return option
|
||||
.addStringOption(addUsernameChoices)
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName('limit')
|
||||
.setDescription('How many neighbors to display (default: 10)')
|
||||
.setRequired(false)
|
||||
.setMinValue(5)
|
||||
.setMaxValue(30),
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('season')
|
||||
.setDescription('Season number')
|
||||
.setRequired(false)
|
||||
.addChoices(
|
||||
...seasonNumbers
|
||||
.map(
|
||||
(number, index) =>
|
||||
({
|
||||
name: `${number}${index === seasonNumbers.length - 1 && onGoingSeason ? ' (current)' : ''}`,
|
||||
value: `${number}`,
|
||||
}) satisfies APIApplicationCommandOptionChoice<string>,
|
||||
)
|
||||
.reverse(),
|
||||
export const ratingsCommand = new Promise<CommandRegistry>(async (resolve) => {
|
||||
const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
|
||||
const onGoingSeason = await isOnGoingRatingsSeason();
|
||||
const seasonNumbers = range(
|
||||
FIRST_ARCHIVED_RATINGS_SEASON,
|
||||
latestArchivedSeasonNumber + (onGoingSeason ? 1 : 0) + 1,
|
||||
);
|
||||
|
||||
function addOptions(option: SlashCommandSubcommandBuilder) {
|
||||
const { t, translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName(t`bot.commands.ratings.options.limit`)
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.commands.ratings.options.limit'),
|
||||
)
|
||||
.setDescription(
|
||||
translate('bot.commands.ratings.options.limit.description', [
|
||||
`${DEFAULT_LIMIT}`,
|
||||
]),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.ratings.options.limit.description',
|
||||
[`${DEFAULT_LIMIT}`],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const command = new SlashCommandBuilder()
|
||||
.setName('ratings')
|
||||
.setDescription('Ratings battles statistics')
|
||||
.addSubcommand((option) =>
|
||||
addSubcommands(
|
||||
option
|
||||
.setName('neighbors')
|
||||
.setDescription('Shows neighboring players on the leaderboard'),
|
||||
),
|
||||
)
|
||||
.setRequired(false)
|
||||
.setMinValue(5)
|
||||
.setMaxValue(30),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
addSubcommands(
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName(t`bot.commands.ratings.options.season`)
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.commands.ratings.options.season'),
|
||||
)
|
||||
.setDescription(
|
||||
translate('bot.commands.ratings.options.season.description'),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.ratings.options.season.description',
|
||||
),
|
||||
)
|
||||
.setRequired(false)
|
||||
.addChoices(
|
||||
...seasonNumbers
|
||||
.map((number, index) => {
|
||||
const current =
|
||||
index === seasonNumbers.length - 1 && onGoingSeason;
|
||||
|
||||
return {
|
||||
name: current
|
||||
? translate(
|
||||
'bot.commands.ratings.options.season.choices.current',
|
||||
[`${number}`],
|
||||
)
|
||||
: `${number}`,
|
||||
value: `${number}`,
|
||||
name_localizations: current
|
||||
? localizationObject(
|
||||
'bot.commands.ratings.options.season.choices.current',
|
||||
[`${number}`],
|
||||
)
|
||||
: undefined,
|
||||
} satisfies APIApplicationCommandOptionChoice<string>;
|
||||
})
|
||||
.reverse(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command: createLocalizedCommand('ratings', [
|
||||
{
|
||||
subcommand: 'neighbors',
|
||||
modify(option: SlashCommandSubcommandBuilder) {
|
||||
option.addStringOption(addUsernameChoices);
|
||||
addOptions(option);
|
||||
},
|
||||
},
|
||||
{
|
||||
subcommand: 'league',
|
||||
modify(option: SlashCommandSubcommandBuilder) {
|
||||
option
|
||||
.setName('league')
|
||||
.setDescription('Shows top players from a league')
|
||||
.addStringOption(addRegionChoices)
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('league')
|
||||
.setDescription('The league to display')
|
||||
.addStringOption((option) => {
|
||||
const { t, translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName(t`bot.commands.ratings.options.league`)
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.commands.ratings.options.league'),
|
||||
)
|
||||
.setDescription(
|
||||
translate('bot.commands.ratings.options.league.description'),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.ratings.options.league.description',
|
||||
),
|
||||
)
|
||||
.addChoices(
|
||||
...LEAGUES.map(
|
||||
({ name }, value) =>
|
||||
({
|
||||
name,
|
||||
name: translate(`common.leagues.${name}`),
|
||||
value: `${value}`,
|
||||
name_localizations: localizationObject(
|
||||
`common.leagues.${name}`,
|
||||
),
|
||||
}) satisfies APIApplicationCommandOptionChoice<string>,
|
||||
),
|
||||
)
|
||||
.setRequired(true),
|
||||
),
|
||||
),
|
||||
);
|
||||
.setRequired(true);
|
||||
})
|
||||
.addStringOption(addRegionChoices);
|
||||
addOptions(option);
|
||||
},
|
||||
},
|
||||
]),
|
||||
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
// BIG TODO: title seems to be in english
|
||||
|
||||
command,
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const subcommand = interaction.options.getSubcommand(true) as
|
||||
| 'league'
|
||||
| 'neighbors';
|
||||
const limit = interaction.options.getInteger('limit') ?? DEFAULT_LIMIT;
|
||||
const seasonOption = interaction.options.getString('season');
|
||||
const season = seasonOption
|
||||
? parseInt(seasonOption)
|
||||
: onGoingSeason
|
||||
? latestArchivedSeasonNumber + 1
|
||||
: latestArchivedSeasonNumber;
|
||||
const isCurrentSeason = season === latestArchivedSeasonNumber + 1;
|
||||
let titleName: string;
|
||||
let titleImage: string | undefined;
|
||||
let titleDescription: string;
|
||||
let playersAfter: number;
|
||||
let players: SimplifiedPlayer[] | undefined;
|
||||
let midnightLeaderboard: BlitzkriegRatingsLeaderboard | undefined;
|
||||
let playerId: number | undefined;
|
||||
let regionRatingsInfo: RatingsInfo;
|
||||
|
||||
async handler(interaction) {
|
||||
interface SimplifiedPlayer {
|
||||
id: number;
|
||||
score: number;
|
||||
position: number;
|
||||
clan: string | undefined;
|
||||
nickname: string;
|
||||
}
|
||||
if (subcommand === 'league') {
|
||||
const leagueIndex = parseInt(interaction.options.getString('league')!);
|
||||
const region = interaction.options.getString('region') as Region;
|
||||
regionRatingsInfo = await (isCurrentSeason
|
||||
? getRatingsInfo(region)
|
||||
: getArchivedRatingsInfo(region, season!));
|
||||
|
||||
const subcommand = interaction.options.getSubcommand(true) as
|
||||
| 'league'
|
||||
| 'neighbors';
|
||||
const limit = interaction.options.getInteger('limit') ?? 10;
|
||||
const seasonOption = interaction.options.getString('season');
|
||||
const season = seasonOption
|
||||
? parseInt(seasonOption)
|
||||
: onGoingSeason
|
||||
? latestArchivedSeasonNumber + 1
|
||||
: latestArchivedSeasonNumber;
|
||||
const isCurrentSeason = season === latestArchivedSeasonNumber + 1;
|
||||
let titleName: string;
|
||||
let titleImage: string | undefined;
|
||||
let titleDescription: string;
|
||||
let playersBefore: number;
|
||||
let playersAfter: number;
|
||||
let players: SimplifiedPlayer[] | undefined;
|
||||
let midnightLeaderboard: BlitzkriegRatingsLeaderboard | undefined;
|
||||
let playerId: number | undefined;
|
||||
let regionRatingsInfo: RatingsInfo;
|
||||
if (regionRatingsInfo.detail !== undefined)
|
||||
return t`bot.commands.ratings.body.no_ongoing_season`;
|
||||
|
||||
if (subcommand === 'league') {
|
||||
const leagueIndex = parseInt(
|
||||
interaction.options.getString('league')!,
|
||||
);
|
||||
const region = interaction.options.getString('region') as Region;
|
||||
regionRatingsInfo = await (isCurrentSeason
|
||||
? getRatingsInfo(region)
|
||||
: getArchivedRatingsInfo(region, season!));
|
||||
const result = isCurrentSeason
|
||||
? await getRatingsLeague(region, leagueIndex).then(async (result) =>
|
||||
result.slice(0, limit).map(
|
||||
(player) =>
|
||||
({
|
||||
id: player.spa_id,
|
||||
score: player.score,
|
||||
position: player.number,
|
||||
clan: player.clan_tag,
|
||||
nickname: player.nickname,
|
||||
}) satisfies SimplifiedPlayer,
|
||||
),
|
||||
)
|
||||
: await getArchivedRatingsLeaderboard(region, season!).then(
|
||||
async (players) => {
|
||||
const firstPlayerIndex =
|
||||
leagueIndex === 0
|
||||
? 0
|
||||
: players.findIndex(
|
||||
(player) =>
|
||||
player.score < LEAGUES[leagueIndex - 1].minScore,
|
||||
);
|
||||
const lastPlayerIndex = firstPlayerIndex + limit - 1;
|
||||
const trimmed = players.slice(
|
||||
firstPlayerIndex,
|
||||
lastPlayerIndex + 1,
|
||||
);
|
||||
const clanData = await getClanAccountInfo(
|
||||
region,
|
||||
trimmed.map(({ id }) => id),
|
||||
['clan'],
|
||||
);
|
||||
|
||||
if (regionRatingsInfo.detail !== undefined) return noOngoingSeason;
|
||||
return trimmed.map(
|
||||
(player, index) =>
|
||||
({
|
||||
id: player.id,
|
||||
score: player.score,
|
||||
position: firstPlayerIndex + index + 1,
|
||||
clan: clanData[player.id]?.clan?.tag,
|
||||
nickname:
|
||||
clanData[index]?.account_name ??
|
||||
translate('bot.commands.ratings.body.deleted_player', [
|
||||
`${player.id}`,
|
||||
]),
|
||||
}) satisfies SimplifiedPlayer,
|
||||
);
|
||||
},
|
||||
);
|
||||
const leagueInfo = regionRatingsInfo.leagues[leagueIndex];
|
||||
midnightLeaderboard = isCurrentSeason
|
||||
? await getArchivedRatingsMidnightLeaderboard(
|
||||
region,
|
||||
regionRatingsInfo.current_season,
|
||||
)
|
||||
: await getArchivedRatingsMidnightLeaderboard(region, season!);
|
||||
|
||||
const result = isCurrentSeason
|
||||
? await getRatingsLeague(region, leagueIndex).then(async (result) =>
|
||||
result.slice(0, limit).map(
|
||||
players = result;
|
||||
playersAfter =
|
||||
regionRatingsInfo.count - result[result.length - 1].position;
|
||||
titleName = `${
|
||||
leagueInfo
|
||||
? translate(`common.leagues.${LEAGUES[leagueInfo.index].name}`)
|
||||
: ''
|
||||
} - ${translate(`common.regions.short.${region}`)}`;
|
||||
titleImage = leagueInfo.big_icon.startsWith('http')
|
||||
? leagueInfo.big_icon
|
||||
: `https:${leagueInfo.big_icon}`;
|
||||
titleDescription = translate('bot.commands.ratings.body.top_players', [
|
||||
`${limit}`,
|
||||
]);
|
||||
} else {
|
||||
const { region, id } = await resolvePlayerFromCommand(interaction);
|
||||
regionRatingsInfo = isCurrentSeason
|
||||
? await getRatingsInfo(region)
|
||||
: await getArchivedRatingsInfo(region, season!);
|
||||
|
||||
if (regionRatingsInfo.detail !== undefined)
|
||||
return t`bot.commands.ratings.body.no_ongoing_season`;
|
||||
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||
const neighbors = isCurrentSeason
|
||||
? await getRatingsNeighbors(region, id, Math.round(limit / 2)).then(
|
||||
(players) =>
|
||||
players.neighbors.map(
|
||||
(player) =>
|
||||
({
|
||||
id: player.spa_id,
|
||||
score: player.score,
|
||||
position: player.number,
|
||||
score: player.score,
|
||||
clan: player.clan_tag,
|
||||
nickname: player.nickname,
|
||||
}) satisfies SimplifiedPlayer,
|
||||
),
|
||||
)
|
||||
: await getArchivedRatingsLeaderboard(region, season!).then(
|
||||
async (players) => {
|
||||
const firstPlayerIndex =
|
||||
leagueIndex === 0
|
||||
? 0
|
||||
: players.findIndex(
|
||||
(player) =>
|
||||
player.score < LEAGUES[leagueIndex - 1].minScore,
|
||||
);
|
||||
const lastPlayerIndex = firstPlayerIndex + limit - 1;
|
||||
const trimmed = players.slice(
|
||||
firstPlayerIndex,
|
||||
lastPlayerIndex + 1,
|
||||
);
|
||||
const clanData = await getClanAccountInfo(
|
||||
region,
|
||||
trimmed.map(({ id }) => id),
|
||||
['clan'],
|
||||
)
|
||||
: await getArchivedRatingsLeaderboard(region, season!).then(
|
||||
async (players) => {
|
||||
const playerIndex = players.findIndex(
|
||||
(player) => player.id === id,
|
||||
);
|
||||
|
||||
if (playerIndex === -1) {
|
||||
throw new UserError(
|
||||
translate('bot.commands.ratings.body.no_participation', [
|
||||
markdownEscape(accountInfo.nickname),
|
||||
`${season}`,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return trimmed.map(
|
||||
(player, index) =>
|
||||
({
|
||||
id: player.id,
|
||||
score: player.score,
|
||||
position: firstPlayerIndex + index + 1,
|
||||
clan: clanData[player.id]?.clan?.tag,
|
||||
nickname:
|
||||
clanData[index]?.account_name ??
|
||||
`Deleted ${player.id}`,
|
||||
}) satisfies SimplifiedPlayer,
|
||||
);
|
||||
},
|
||||
);
|
||||
const leagueInfo = regionRatingsInfo.leagues[leagueIndex];
|
||||
midnightLeaderboard = isCurrentSeason
|
||||
? await getArchivedRatingsMidnightLeaderboard(
|
||||
region,
|
||||
regionRatingsInfo.current_season,
|
||||
)
|
||||
: await getArchivedRatingsMidnightLeaderboard(region, season!);
|
||||
const halfRange = Math.round(limit / 2);
|
||||
const trimmed = players.slice(
|
||||
playerIndex - halfRange,
|
||||
playerIndex + halfRange + 1,
|
||||
);
|
||||
const clanData = await getClanAccountInfo(
|
||||
region,
|
||||
trimmed.map((player) => player.id),
|
||||
['clan'],
|
||||
);
|
||||
|
||||
players = result;
|
||||
playersBefore = result ? result[0].position - 1 : 0;
|
||||
playersAfter =
|
||||
regionRatingsInfo.count - result[result.length - 1].position;
|
||||
titleName = `${
|
||||
leagueInfo ? leagueInfo.title : 'No Ongoing Season'
|
||||
} - ${REGION_NAMES_SHORT[region]}`;
|
||||
titleImage = leagueInfo.big_icon.startsWith('http')
|
||||
? leagueInfo.big_icon
|
||||
: `https:${leagueInfo.big_icon}`;
|
||||
titleDescription = `Top ${limit} players`;
|
||||
} else {
|
||||
const { region, id } = await resolvePlayerFromCommand(interaction);
|
||||
regionRatingsInfo = isCurrentSeason
|
||||
? await getRatingsInfo(region)
|
||||
: await getArchivedRatingsInfo(region, season!);
|
||||
return trimmed.map((player, index) => ({
|
||||
id: player.id,
|
||||
score: player.score,
|
||||
position: playerIndex + index + 1,
|
||||
clan: clanData[index]?.clan?.tag,
|
||||
nickname: clanData[index]
|
||||
? clanData[index]!.account_name
|
||||
: `Deleted ${player.id}`,
|
||||
}));
|
||||
},
|
||||
);
|
||||
midnightLeaderboard = isCurrentSeason
|
||||
? await getArchivedRatingsMidnightLeaderboard(
|
||||
region,
|
||||
regionRatingsInfo.current_season,
|
||||
)
|
||||
: await getArchivedRatingsMidnightLeaderboard(region, season!);
|
||||
|
||||
if (regionRatingsInfo.detail !== undefined) return noOngoingSeason;
|
||||
players = neighbors;
|
||||
titleImage = clan
|
||||
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
|
||||
: undefined;
|
||||
titleName = neighbors ? accountInfo.nickname : '';
|
||||
titleDescription = t`bot.commands.ratings.body.neighbors`;
|
||||
playersAfter =
|
||||
regionRatingsInfo.count - neighbors[neighbors.length - 1].position;
|
||||
playerId = id;
|
||||
}
|
||||
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||
const neighbors = isCurrentSeason
|
||||
? await getRatingsNeighbors(region, id, Math.round(limit / 2)).then(
|
||||
(players) =>
|
||||
players.neighbors.map(
|
||||
(player) =>
|
||||
({
|
||||
id: player.spa_id,
|
||||
position: player.number,
|
||||
score: player.score,
|
||||
clan: player.clan_tag,
|
||||
nickname: player.nickname,
|
||||
}) satisfies SimplifiedPlayer,
|
||||
),
|
||||
)
|
||||
: await getArchivedRatingsLeaderboard(region, season!).then(
|
||||
async (players) => {
|
||||
const playerIndex = players.findIndex(
|
||||
(player) => player.id === id,
|
||||
);
|
||||
const resolvedRegionRatingsInfo: typeof regionRatingsInfo & {
|
||||
detail: undefined;
|
||||
} = regionRatingsInfo;
|
||||
|
||||
if (playerIndex === -1) {
|
||||
throw new UserError(
|
||||
`# ${markdownEscape(accountInfo.nickname)} didn't play ratings in season ${season}\nThis player did not participate in this season or did not get past calibration.`,
|
||||
);
|
||||
}
|
||||
|
||||
const halfRange = Math.round(limit / 2);
|
||||
const trimmed = players.slice(
|
||||
playerIndex - halfRange,
|
||||
playerIndex + halfRange + 1,
|
||||
);
|
||||
const clanData = await getClanAccountInfo(
|
||||
region,
|
||||
trimmed.map((player) => player.id),
|
||||
['clan'],
|
||||
);
|
||||
|
||||
return trimmed.map((player, index) => ({
|
||||
id: player.id,
|
||||
score: player.score,
|
||||
position: playerIndex + index + 1,
|
||||
clan: clanData[index]?.clan?.tag,
|
||||
nickname: clanData[index]
|
||||
? clanData[index]!.account_name
|
||||
: `Deleted ${player.id}`,
|
||||
}));
|
||||
},
|
||||
);
|
||||
midnightLeaderboard = isCurrentSeason
|
||||
? await getArchivedRatingsMidnightLeaderboard(
|
||||
region,
|
||||
regionRatingsInfo.current_season,
|
||||
)
|
||||
: await getArchivedRatingsMidnightLeaderboard(region, season!);
|
||||
|
||||
players = neighbors;
|
||||
titleImage = clan
|
||||
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
|
||||
: undefined;
|
||||
titleName = neighbors
|
||||
? accountInfo.nickname
|
||||
: `No Ongoing Season - ${REGION_NAMES_SHORT[region]}`;
|
||||
titleDescription = 'Ratings neighbors';
|
||||
playersBefore = neighbors ? neighbors[0].position - 1 : 0;
|
||||
playersAfter =
|
||||
regionRatingsInfo.count - neighbors[neighbors.length - 1].position;
|
||||
playerId = id;
|
||||
}
|
||||
|
||||
const resolvedRegionRatingsInfo: typeof regionRatingsInfo & {
|
||||
detail: undefined;
|
||||
} = regionRatingsInfo;
|
||||
|
||||
const items = players.map((player) => {
|
||||
const reward = resolvedRegionRatingsInfo.rewards.find(
|
||||
(reward) =>
|
||||
player.position >= reward.from_position &&
|
||||
player.position <= reward.to_position,
|
||||
);
|
||||
const midnightIndex = midnightLeaderboard?.findIndex(
|
||||
(item) => item.id === player.id,
|
||||
);
|
||||
const midnightScore =
|
||||
midnightIndex === undefined
|
||||
? player.score
|
||||
: midnightLeaderboard![midnightIndex].score;
|
||||
const midnightPosition =
|
||||
midnightIndex === undefined ? player.position : midnightIndex + 1;
|
||||
|
||||
return (
|
||||
<Leaderboard.Item
|
||||
nickname={player.nickname}
|
||||
score={player.score}
|
||||
position={player.position}
|
||||
clan={player.clan}
|
||||
reward={reward}
|
||||
deltaScore={player.score - midnightScore}
|
||||
deltaPosition={midnightPosition - player.position}
|
||||
key={player.id}
|
||||
highlight={player.id === playerId}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const items = players.map((player) => {
|
||||
const reward = resolvedRegionRatingsInfo.rewards.find(
|
||||
(reward) =>
|
||||
player.position >= reward.from_position &&
|
||||
player.position <= reward.to_position,
|
||||
);
|
||||
const midnightIndex = midnightLeaderboard?.findIndex(
|
||||
(item) => item.id === player.id,
|
||||
);
|
||||
const midnightScore =
|
||||
midnightIndex === undefined
|
||||
? player.score
|
||||
: midnightLeaderboard![midnightIndex].score;
|
||||
const midnightPosition =
|
||||
midnightIndex === undefined ? player.position : midnightIndex + 1;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<TitleBar
|
||||
name={titleName}
|
||||
image={titleImage}
|
||||
description={titleDescription}
|
||||
/>
|
||||
|
||||
{items && (
|
||||
<Leaderboard.Root>
|
||||
{playersBefore > 0 && (
|
||||
<Leaderboard.Gap
|
||||
message={`+ ${playersBefore.toLocaleString()} more`}
|
||||
/>
|
||||
)}
|
||||
{items}
|
||||
{playersAfter > 0 && (
|
||||
<Leaderboard.Gap
|
||||
message={`+ ${playersAfter.toLocaleString()} more`}
|
||||
/>
|
||||
)}
|
||||
</Leaderboard.Root>
|
||||
)}
|
||||
</Wrapper>
|
||||
<Leaderboard.Item
|
||||
nickname={player.nickname}
|
||||
score={player.score}
|
||||
position={player.position}
|
||||
clan={player.clan}
|
||||
reward={reward}
|
||||
deltaScore={player.score - midnightScore}
|
||||
deltaPosition={midnightPosition - player.position}
|
||||
key={player.id}
|
||||
highlight={player.id === playerId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
},
|
||||
);
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={titleName}
|
||||
image={titleImage}
|
||||
description={titleDescription}
|
||||
/>
|
||||
|
||||
{items && (
|
||||
<Leaderboard.Root>
|
||||
{items}
|
||||
{playersAfter > 0 && (
|
||||
<Leaderboard.Gap
|
||||
message={translate('bot.commands.ratings.body.more', [
|
||||
`${playersAfter.toLocaleString(interaction.locale)}`,
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
</Leaderboard.Root>
|
||||
)}
|
||||
</CommandWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
});
|
||||
+60
-34
@@ -1,46 +1,72 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { Locale } from 'discord.js';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { REGION_NAMES, Region } from '../constants/regions';
|
||||
import { Region } from '../constants/regions';
|
||||
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
|
||||
import fetchBlitz from '../core/blitz/fetchBlitz';
|
||||
import addClanChoices from '../core/discord/addClanChoices';
|
||||
import addRegionChoices from '../core/discord/addRegionChoices';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import embedInfo from '../core/discord/embedInfo';
|
||||
import { localizationObject } from '../core/discord/localizationObject';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { ClanList } from '../types/clanList';
|
||||
|
||||
export const searchClansCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
const DEFAULT_LIMIT = 25;
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('search-clans')
|
||||
.setDescription('Search clans')
|
||||
.addStringOption(addRegionChoices)
|
||||
.addStringOption((option) => addClanChoices(option).setAutocomplete(false))
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName('limit')
|
||||
.setDescription('The size of the search result (default: 25)')
|
||||
.setMinValue(1)
|
||||
.setMaxValue(100),
|
||||
),
|
||||
export const searchClansCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
async handler(interaction) {
|
||||
const server = interaction.options.getString('region') as Region;
|
||||
const clan = interaction.options.getString('clan')!;
|
||||
const limit = interaction.options.getInteger('limit') ?? 25;
|
||||
const clanList = await fetchBlitz<ClanList>(
|
||||
`https://api.wotblitz.${server}/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${clan}&limit=${limit}`,
|
||||
);
|
||||
command: createLocalizedCommand('search-clans')
|
||||
.addStringOption(addRegionChoices)
|
||||
.addStringOption((option) =>
|
||||
addClanChoices(option).setAutocomplete(false),
|
||||
)
|
||||
.addIntegerOption((option) => {
|
||||
const { t, translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return embedInfo(
|
||||
`Clan search for "${markdownEscape(clan)}" in ${REGION_NAMES[server]}`,
|
||||
clanList.length === 0
|
||||
? 'No clans found.'
|
||||
: `\`\`\`\n${clanList
|
||||
.map((clan) => `${clan.name} [${clan.tag}]`)
|
||||
.join('\n')}\n\`\`\``,
|
||||
);
|
||||
},
|
||||
};
|
||||
return option
|
||||
.setName(t`bot.commands.search_clans.options.limit`)
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.commands.search_clans.options.limit'),
|
||||
)
|
||||
.setDescription(
|
||||
translate('bot.commands.search_clans.options.limit.description', [
|
||||
`${DEFAULT_LIMIT}`,
|
||||
]),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.search_clans.options.limit.description',
|
||||
[`${DEFAULT_LIMIT}`],
|
||||
),
|
||||
)
|
||||
.setMinValue(1)
|
||||
.setMaxValue(100);
|
||||
}),
|
||||
|
||||
async handler(interaction) {
|
||||
const { translate } = translator(interaction.locale);
|
||||
const server = interaction.options.getString('region') as Region;
|
||||
const clan = interaction.options.getString('clan')!;
|
||||
const limit = interaction.options.getInteger('limit') ?? DEFAULT_LIMIT;
|
||||
const clanList = await fetchBlitz<ClanList>(
|
||||
`https://api.wotblitz.${server}/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${clan}&limit=${limit}`,
|
||||
);
|
||||
|
||||
return embedInfo(
|
||||
translate('bot.commands.search_clans.body.title', [
|
||||
markdownEscape(clan),
|
||||
translate(`common.regions.normal.${server}`),
|
||||
]),
|
||||
clanList.length === 0
|
||||
? translate('bot.commands.search_clans.body.no_results')
|
||||
: `\`\`\`\n${clanList
|
||||
.map((clan) => `${clan.name} [${clan.tag}]`)
|
||||
.join('\n')}\n\`\`\``,
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { Locale } from 'discord.js';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { REGION_NAMES, Region } from '../constants/regions';
|
||||
import { Region } from '../constants/regions';
|
||||
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
|
||||
import fetchBlitz from '../core/blitz/fetchBlitz';
|
||||
import {
|
||||
@@ -9,48 +9,72 @@ import {
|
||||
} from '../core/blitz/searchPlayersAcrossRegions';
|
||||
import addRegionChoices from '../core/discord/addRegionChoices';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import embedInfo from '../core/discord/embedInfo';
|
||||
import { localizationObject } from '../core/discord/localizationObject';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
export const searchPlayersCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
const DEFAULT_LIMIT = 25;
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('search-players')
|
||||
.setDescription('Search players in a Blitz server')
|
||||
.addStringOption(addRegionChoices)
|
||||
.addStringOption((option) =>
|
||||
addUsernameChoices(option).setAutocomplete(false).setRequired(true),
|
||||
)
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName('limit')
|
||||
.setDescription('The size of the search result (default: 25)')
|
||||
.setMinValue(1)
|
||||
.setMaxValue(100),
|
||||
),
|
||||
export const searchPlayersCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
const { t, translate } = translator(Locale.EnglishUS);
|
||||
|
||||
async handler(interaction) {
|
||||
const server = interaction.options.getString('region') as Region;
|
||||
const name = interaction.options.getString('username')!;
|
||||
const limit = interaction.options.getInteger('limit') ?? 25;
|
||||
const trimmedSearch = name.trim();
|
||||
const players = usernamePattern.test(trimmedSearch)
|
||||
? await fetchBlitz<AccountList>(
|
||||
`https://api.wotblitz.${server}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodeURIComponent(
|
||||
trimmedSearch,
|
||||
)}&limit=${limit}`,
|
||||
)
|
||||
: [];
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
return embedInfo(
|
||||
`Player search for "${markdownEscape(name)}" in ${REGION_NAMES[server]}`,
|
||||
`\`\`\`${
|
||||
players.length === 0
|
||||
? 'No players found.'
|
||||
: players.map((player) => player.nickname).join('\n')
|
||||
}\`\`\``,
|
||||
);
|
||||
},
|
||||
};
|
||||
command: createLocalizedCommand('search-players')
|
||||
.addStringOption(addRegionChoices)
|
||||
.addStringOption((option) =>
|
||||
addUsernameChoices(option).setAutocomplete(false).setRequired(true),
|
||||
)
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName(t`bot.commands.search_players.options.limit`)
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.commands.search_players.options.limit'),
|
||||
)
|
||||
.setDescription(
|
||||
translate('bot.commands.search_players.options.limit.description', [
|
||||
`${DEFAULT_LIMIT}`,
|
||||
]),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.search_players.options.limit.description',
|
||||
[`${DEFAULT_LIMIT}`],
|
||||
),
|
||||
)
|
||||
.setMinValue(1)
|
||||
.setMaxValue(100),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const { translate } = translator(interaction.locale);
|
||||
const server = interaction.options.getString('region') as Region;
|
||||
const name = interaction.options.getString('username')!;
|
||||
const limit = interaction.options.getInteger('limit') ?? 25;
|
||||
const trimmedSearch = name.trim();
|
||||
const players = usernamePattern.test(trimmedSearch)
|
||||
? await fetchBlitz<AccountList>(
|
||||
`https://api.wotblitz.${server}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodeURIComponent(
|
||||
trimmedSearch,
|
||||
)}&limit=${limit}`,
|
||||
)
|
||||
: [];
|
||||
|
||||
return embedInfo(
|
||||
translate('bot.commands.search_players.body.title', [
|
||||
markdownEscape(trimmedSearch),
|
||||
translate(`common.regions.normal.${server}`),
|
||||
]),
|
||||
`\`\`\`${
|
||||
players.length === 0
|
||||
? translate('bot.commands.search_players.body.no_results')
|
||||
: players.map((player) => player.nickname).join('\n')
|
||||
}\`\`\``,
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
+47
-36
@@ -1,40 +1,51 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { go } from 'fuzzysort';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { tankNames } from '../core/blitzkrieg/tankDefinitions';
|
||||
import addTankChoices from '../core/discord/addTankChoices';
|
||||
import embedInfo from '../core/discord/embedInfo';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
// _____ _____ _ _ _ _ _____ ____ __ __ ______ ____ _____ _ __
|
||||
// |_ _| / ____| | | | /\ | | | | / ____/ __ \| \/ | ____| | _ \ /\ / ____| |/ /
|
||||
// | | | (___ | |__| | / \ | | | | | | | | | | \ / | |__ | |_) | / \ | | | ' /
|
||||
// | | \___ \| __ | / /\ \ | | | | | | | | | | |\/| | __| | _ < / /\ \| | | <
|
||||
// _| |_ ____) | | | |/ ____ \| |____| |____ | |___| |__| | | | | |____ | |_) / ____ \ |____| . \
|
||||
// |_____| |_____/|_| |_/_/ \_\______|______| \_____\____/|_| |_|______| |____/_/ \_\_____|_|\_\
|
||||
|
||||
export const searchTanksCommand: CommandRegistry = {
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
// import { SlashCommandBuilder } from 'discord.js';
|
||||
// import { go } from 'fuzzysort';
|
||||
// import markdownEscape from 'markdown-escape';
|
||||
// import { tankNames } from '../core/blitzkrieg/tankDefinitions';
|
||||
// import addTankChoices from '../core/discord/addTankChoices';
|
||||
// import embedInfo from '../core/discord/embedInfo';
|
||||
// import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
command: new SlashCommandBuilder()
|
||||
.setName('search-tanks')
|
||||
.setDescription('Search tanks')
|
||||
.addStringOption((option) => addTankChoices(option).setAutocomplete(false))
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName('limit')
|
||||
.setDescription('The size of the search result (default: 25)')
|
||||
.setMinValue(1)
|
||||
.setMaxValue(100),
|
||||
),
|
||||
// export const searchTanksCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
// resolve({
|
||||
// inProduction: true,
|
||||
// inPublic: true,
|
||||
|
||||
async handler(interaction) {
|
||||
const tank = interaction.options.getString('tank')!;
|
||||
const limit = interaction.options.getInteger('limit') ?? 25;
|
||||
const results = go(tank, await tankNames, {
|
||||
limit,
|
||||
keys: ['combined'],
|
||||
}).map((result) => result.obj.original);
|
||||
// command: new SlashCommandBuilder()
|
||||
// .setName('search-tanks')
|
||||
// .setDescription('Search tanks')
|
||||
// .addStringOption((option) =>
|
||||
// addTankChoices(option).setAutocomplete(false),
|
||||
// )
|
||||
// .addIntegerOption((option) =>
|
||||
// option
|
||||
// .setName('limit')
|
||||
// .setDescription('The size of the search result (default: 25)')
|
||||
// .setMinValue(1)
|
||||
// .setMaxValue(100),
|
||||
// ),
|
||||
|
||||
return embedInfo(
|
||||
`Tank search for "${markdownEscape(tank)}"`,
|
||||
results.length === 0
|
||||
? 'No tanks found.'
|
||||
: `\`\`\`\n${results.join('\n')}\n\`\`\``,
|
||||
);
|
||||
},
|
||||
};
|
||||
// async handler(interaction) {
|
||||
// const tank = interaction.options.getString('tank')!;
|
||||
// const limit = interaction.options.getInteger('limit') ?? 25;
|
||||
// const results = go(tank, await tankNames, {
|
||||
// limit,
|
||||
// keys: ['combined'],
|
||||
// }).map((result) => result.obj.original);
|
||||
|
||||
// return embedInfo(
|
||||
// `Tank search for "${markdownEscape(tank)}"`,
|
||||
// results.length === 0
|
||||
// ? 'No tanks found.'
|
||||
// : `\`\`\`\n${results.join('\n')}\n\`\`\``,
|
||||
// );
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
+58
-52
@@ -1,8 +1,8 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { Locale } from 'discord.js';
|
||||
import AllStatsOverview from '../components/AllStatsOverview';
|
||||
import NoData, { NoDataType } from '../components/NoData';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import NoData from '../components/NoData';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import Wrapper from '../components/Wrapper';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
|
||||
@@ -15,6 +15,7 @@ import autocompleteTanks from '../core/discord/autocompleteTanks';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { buttonRefresh } from '../core/discord/buttonRefresh';
|
||||
import commandToURL from '../core/discord/commandToURL';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
|
||||
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
|
||||
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
|
||||
@@ -27,80 +28,85 @@ import resolvePlayerFromCommand, {
|
||||
ResolvedPlayer,
|
||||
} from '../core/discord/resolvePlayerFromCommand';
|
||||
import { StatFilters, filterStats } from '../core/statistics/filterStats';
|
||||
import { CommandRegistryPromisable } from '../events/interactionCreate';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
async function render(
|
||||
{ region, id }: ResolvedPlayer,
|
||||
{ start, end, name }: ResolvedPeriod,
|
||||
filters: StatFilters,
|
||||
locale: Locale,
|
||||
) {
|
||||
const { nickname } = await getAccountInfo(region, id);
|
||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined;
|
||||
const diffedTankStats = await getStatsInPeriod(region, id, start, end);
|
||||
const diffedTankStats = await getStatsInPeriod(
|
||||
region,
|
||||
id,
|
||||
start,
|
||||
end,
|
||||
locale,
|
||||
);
|
||||
const { stats, supplementary } = await filterStats(diffedTankStats, filters);
|
||||
const filterDescriptions = await filtersToDescription(filters);
|
||||
const filterDescriptions = await filtersToDescription(filters, locale);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
name={nickname}
|
||||
title={nickname}
|
||||
image={clanImage}
|
||||
description={`${name} • ${filterDescriptions}`}
|
||||
/>
|
||||
|
||||
{!stats.battles && <NoData type={NoDataType.BattlesInPeriod} />}
|
||||
{!stats.battles && <NoData type="battles_in_period" locale={locale} />}
|
||||
{stats.battles > 0 && (
|
||||
<AllStatsOverview stats={stats} supplementaryStats={supplementary} />
|
||||
<AllStatsOverview
|
||||
locale={locale}
|
||||
stats={stats}
|
||||
supplementaryStats={supplementary}
|
||||
/>
|
||||
)}
|
||||
</Wrapper>
|
||||
</CommandWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const statsCommand = new Promise<CommandRegistryPromisable>(
|
||||
async (resolve) => {
|
||||
const command = await addPeriodicFilterOptions(
|
||||
new SlashCommandBuilder()
|
||||
.setName('stats')
|
||||
.setDescription('Regular battles statistics'),
|
||||
export const statsCommand = new Promise<CommandRegistry>(async (resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
command: await addPeriodicFilterOptions(
|
||||
createLocalizedCommand('stats'),
|
||||
(option) => option.addStringOption(addUsernameChoices),
|
||||
);
|
||||
),
|
||||
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
async handler(interaction) {
|
||||
const player = await resolvePlayerFromCommand(interaction);
|
||||
const period = resolvePeriodFromCommand(player.region, interaction);
|
||||
const filters = await getFiltersFromCommand(interaction);
|
||||
const path = commandToURL(interaction, {
|
||||
...player,
|
||||
...getCustomPeriodParams(interaction),
|
||||
...filters,
|
||||
});
|
||||
|
||||
command,
|
||||
return Promise.all([
|
||||
render(player, period, filters, interaction.locale),
|
||||
buttonRefresh(interaction, path),
|
||||
getBlitzStarsLinkButton(player.region, player.id),
|
||||
]);
|
||||
},
|
||||
|
||||
async handler(interaction) {
|
||||
const player = await resolvePlayerFromCommand(interaction);
|
||||
const period = resolvePeriodFromCommand(player.region, interaction);
|
||||
const filters = await getFiltersFromCommand(interaction);
|
||||
const path = commandToURL(interaction, {
|
||||
...player,
|
||||
...getCustomPeriodParams(interaction),
|
||||
...filters,
|
||||
});
|
||||
autocomplete: (interaction) => {
|
||||
autocompleteUsername(interaction);
|
||||
autocompleteTanks(interaction);
|
||||
},
|
||||
|
||||
return Promise.all([
|
||||
render(player, period, filters),
|
||||
buttonRefresh(interaction, path),
|
||||
getBlitzStarsLinkButton(player.region, player.id),
|
||||
]);
|
||||
},
|
||||
async button(interaction) {
|
||||
const player = await resolvePlayerFromButton(interaction);
|
||||
const period = resolvePeriodFromButton(player.region, interaction);
|
||||
const filters = getFiltersFromButton(interaction);
|
||||
|
||||
autocomplete: (interaction) => {
|
||||
autocompleteUsername(interaction);
|
||||
autocompleteTanks(interaction);
|
||||
},
|
||||
|
||||
async button(interaction) {
|
||||
const player = await resolvePlayerFromButton(interaction);
|
||||
const period = resolvePeriodFromButton(player.region, interaction);
|
||||
const filters = getFiltersFromButton(interaction);
|
||||
|
||||
return await render(player, period, filters);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
return await render(player, period, filters, interaction.locale);
|
||||
},
|
||||
});
|
||||
});
|
||||
+44
-50
@@ -1,12 +1,12 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { encyclopediaInfo } from '../core/blitz/encyclopediaInfo';
|
||||
import { NATIONS } from '../core/blitzkrieg/tankDefinitions';
|
||||
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
||||
import { addFilterOptions } from '../core/discord/addFilterOptions';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteTanks from '../core/discord/autocompleteTanks';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import buttonPrimary from '../core/discord/buttonPrimary';
|
||||
import { buttonRefresh } from '../core/discord/buttonRefresh';
|
||||
import commandToURL from '../core/discord/commandToURL';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
|
||||
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
|
||||
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
|
||||
@@ -14,58 +14,52 @@ import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
|
||||
import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand';
|
||||
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { CommandRegistryPromisable } from '../events/interactionCreate';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { renderBreakdown } from './breakdown';
|
||||
|
||||
export const todayCommand = new Promise<CommandRegistryPromisable>(
|
||||
async (resolve) => {
|
||||
const awaitedEncyclopediaInfo = await encyclopediaInfo;
|
||||
const command = addFilterOptions(
|
||||
new SlashCommandBuilder()
|
||||
.setName('today')
|
||||
.setDescription("Today's played tanks and statistics"),
|
||||
awaitedEncyclopediaInfo,
|
||||
).addStringOption(addUsernameChoices);
|
||||
export const todayCommand = new Promise<CommandRegistry>(async (resolve) => {
|
||||
const nations = await NATIONS;
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
|
||||
resolve({
|
||||
inProduction: true,
|
||||
inPublic: true,
|
||||
command: addFilterOptions(
|
||||
createLocalizedCommand('today'),
|
||||
nations,
|
||||
).addStringOption(addUsernameChoices),
|
||||
|
||||
command,
|
||||
async handler(interaction) {
|
||||
const player = await resolvePlayerFromCommand(interaction);
|
||||
const period = resolvePeriodFromCommand(
|
||||
player.region,
|
||||
interaction,
|
||||
'today',
|
||||
);
|
||||
const filters = await getFiltersFromCommand(interaction);
|
||||
const path = commandToURL(interaction, {
|
||||
...player,
|
||||
...getCustomPeriodParams(interaction, true),
|
||||
...filters,
|
||||
});
|
||||
|
||||
async handler(interaction) {
|
||||
const player = await resolvePlayerFromCommand(interaction);
|
||||
const period = resolvePeriodFromCommand(
|
||||
player.region,
|
||||
interaction,
|
||||
'today',
|
||||
);
|
||||
const filters = await getFiltersFromCommand(interaction);
|
||||
const path = commandToURL(interaction, {
|
||||
...player,
|
||||
...getCustomPeriodParams(interaction, true),
|
||||
...filters,
|
||||
});
|
||||
return [
|
||||
...(await renderBreakdown(player, period, filters, interaction.locale)),
|
||||
buttonRefresh(interaction, path),
|
||||
await getBlitzStarsLinkButton(player.region, player.id),
|
||||
];
|
||||
},
|
||||
|
||||
return [
|
||||
...(await renderBreakdown(player, period, filters)),
|
||||
buttonPrimary(path, 'Refresh'),
|
||||
await getBlitzStarsLinkButton(player.region, player.id),
|
||||
];
|
||||
},
|
||||
autocomplete: (interaction) => {
|
||||
autocompleteUsername(interaction);
|
||||
autocompleteTanks(interaction);
|
||||
},
|
||||
|
||||
autocomplete: (interaction) => {
|
||||
autocompleteUsername(interaction);
|
||||
autocompleteTanks(interaction);
|
||||
},
|
||||
async button(interaction) {
|
||||
const player = await resolvePlayerFromButton(interaction);
|
||||
const period = resolvePeriodFromButton(player.region, interaction);
|
||||
const filters = getFiltersFromButton(interaction);
|
||||
|
||||
async button(interaction) {
|
||||
const player = await resolvePlayerFromButton(interaction);
|
||||
const period = resolvePeriodFromButton(player.region, interaction);
|
||||
const filters = getFiltersFromButton(interaction);
|
||||
|
||||
return await renderBreakdown(player, period, filters);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
return await renderBreakdown(player, period, filters, interaction.locale);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,21 @@
|
||||
import { Locale } from 'discord.js';
|
||||
import { Percentile } from '../../../../constants/percentiles';
|
||||
import { translator } from '../../../../core/localization/translator';
|
||||
import getWN8Percentile from '../../../../core/statistics/getWN8Percentile';
|
||||
import { theme } from '../../../../stitches.config';
|
||||
import { PERCENTILE_COLORS } from '../../../PercentileIndicator';
|
||||
import { Glow } from './components/Glow';
|
||||
|
||||
export interface WN8DisplayProps {
|
||||
WN8?: number;
|
||||
wn8?: number;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export const PERCENTILE_NAMES = {
|
||||
[Percentile.VeryBad]: 'Very Bad',
|
||||
[Percentile.Bad]: 'Bad',
|
||||
[Percentile.BelowAverage]: 'Below Average',
|
||||
[Percentile.Average]: 'Average',
|
||||
[Percentile.AboveAverage]: 'Above Average',
|
||||
[Percentile.Good]: 'Good',
|
||||
[Percentile.VeryGood]: 'Very Good',
|
||||
[Percentile.Great]: 'Great',
|
||||
[Percentile.Unicum]: 'Unicum',
|
||||
[Percentile.SuperUnicum]: 'Super Unicum',
|
||||
};
|
||||
|
||||
export function WN8Display({ WN8 }: WN8DisplayProps) {
|
||||
export function WN8Display({ wn8, locale }: WN8DisplayProps) {
|
||||
const percentile =
|
||||
WN8 === undefined ? Percentile.VeryBad : getWN8Percentile(WN8);
|
||||
wn8 === undefined ? Percentile.VeryBad : getWN8Percentile(wn8);
|
||||
const color = PERCENTILE_COLORS[percentile];
|
||||
const { translate } = translator(locale);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -59,7 +50,7 @@ export function WN8Display({ WN8 }: WN8DisplayProps) {
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
{WN8 === undefined ? '--' : WN8.toFixed(0)}
|
||||
{wn8 === undefined ? '--' : wn8.toFixed(0)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
@@ -67,7 +58,7 @@ export function WN8Display({ WN8 }: WN8DisplayProps) {
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
{PERCENTILE_NAMES[percentile]}
|
||||
{translate(`common.wn8_percentile.${percentile}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export function WN8SurroundingStat({
|
||||
style={{
|
||||
color: theme.colors.textHighContrast,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Locale } from 'discord.js';
|
||||
import { AllStats, SupplementaryStats } from '../../core/blitz/getAccountInfo';
|
||||
import { translator } from '../../core/localization/translator';
|
||||
import { WN8Display } from './components/WN8Display';
|
||||
import {
|
||||
WN8SurroundingStat,
|
||||
@@ -8,12 +10,16 @@ import {
|
||||
export interface AllStatsOverviewProps {
|
||||
stats: AllStats;
|
||||
supplementaryStats: SupplementaryStats;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function AllStatsOverview({
|
||||
stats,
|
||||
locale,
|
||||
supplementaryStats,
|
||||
}: AllStatsOverviewProps) {
|
||||
const { t } = translator(locale);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -32,18 +38,18 @@ export default function AllStatsOverview({
|
||||
}}
|
||||
>
|
||||
<WN8SurroundingStat
|
||||
label="Battles"
|
||||
label={t`bot.commands.stats.body.battles`}
|
||||
value={stats.battles.toLocaleString()}
|
||||
align={WN8SurroundingStatAlign.Right}
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label="Winrate"
|
||||
label={t`bot.commands.stats.body.winrate`}
|
||||
value={`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`}
|
||||
align={WN8SurroundingStatAlign.Right}
|
||||
padded
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label="Damage"
|
||||
label={t`bot.commands.stats.body.damage`}
|
||||
value={Math.round(
|
||||
stats.damage_dealt / stats.battles,
|
||||
).toLocaleString()}
|
||||
@@ -51,13 +57,13 @@ export default function AllStatsOverview({
|
||||
padded
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label="Dmg. ratio"
|
||||
label={t`bot.commands.stats.body.damage_ratio`}
|
||||
value={(stats.damage_dealt / stats.damage_received).toFixed(2)}
|
||||
align={WN8SurroundingStatAlign.Right}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WN8Display WN8={supplementaryStats.WN8} />
|
||||
<WN8Display locale={locale} wn8={supplementaryStats.WN8} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
@@ -68,25 +74,25 @@ export default function AllStatsOverview({
|
||||
}}
|
||||
>
|
||||
<WN8SurroundingStat
|
||||
label="Tier"
|
||||
label={t`bot.commands.stats.body.tier`}
|
||||
value={
|
||||
supplementaryStats.tier ? supplementaryStats.tier.toFixed(2) : '--'
|
||||
}
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label="Survival"
|
||||
label={t`bot.commands.stats.body.survival`}
|
||||
value={`${(100 * (stats.survived_battles / stats.battles)).toFixed(
|
||||
2,
|
||||
)}%`}
|
||||
padded
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label="Accuracy"
|
||||
label={t`bot.commands.stats.body.accuracy`}
|
||||
value={`${(100 * (stats.hits / stats.shots)).toFixed(2)}%`}
|
||||
padded
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label="Kill ratio"
|
||||
label={t`bot.commands.stats.body.kill_ratio`}
|
||||
value={(
|
||||
stats.frags /
|
||||
(stats.battles - stats.survived_battles)
|
||||
|
||||
+94
-88
@@ -1,28 +1,29 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Euler, Group, Mesh, Vector3 } from 'three';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { Euler, Group, Vector3 } from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { ArmorMesh } from '../../../../../../../../../components/ArmorMesh';
|
||||
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../../../constants/axis';
|
||||
import { I_HAT, J_HAT, K_HAT } from '../../../constants/axis';
|
||||
import {
|
||||
ModelTransformEventData,
|
||||
modelTransformEvent,
|
||||
} from '../../../../../../../../../core/blitzkrieg/modelTransform';
|
||||
import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
|
||||
import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
|
||||
import { useArmor } from '../../../../../../../../../hooks/useArmor';
|
||||
import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions';
|
||||
import { useDuel } from '../../../../../../../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia';
|
||||
} from '../../../core/blitzkrieg/modelTransform';
|
||||
import { nameToArmorId } from '../../../core/blitzkrieg/nameToArmorId';
|
||||
import { resolveArmor } from '../../../core/blitzkrieg/resolveThickness';
|
||||
import { useArmor } from '../../../hooks/useArmor';
|
||||
import { useModelDefinitions } from '../../../hooks/useModelDefinitions';
|
||||
import { useDuel } from '../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../stores/tankopedia';
|
||||
import { CoreArmorSceneComponent } from './CoreArmorSceneComponent';
|
||||
|
||||
export function ArmorHighlighting() {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
export const CoreArmorScene = memo(() => {
|
||||
const wrapper = useRef<Group>(null);
|
||||
const modelDefinitions = useModelDefinitions();
|
||||
const gunContainer = useRef<Group>(null);
|
||||
const turretContainer = useRef<Group>(null);
|
||||
const gunContainer = useRef<Group>(null);
|
||||
const initialTankopediaState = useTankopediaPersistent.getState();
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelDefinitions) return;
|
||||
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
@@ -33,14 +34,17 @@ export function ArmorHighlighting() {
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretPosition = new Vector3();
|
||||
const turretRotation = new Euler();
|
||||
const gunPosition = new Vector3();
|
||||
const gunRotation = new Euler();
|
||||
|
||||
function handleModelTransform({ pitch, yaw }: ModelTransformEventData) {
|
||||
if (yaw === undefined) return;
|
||||
|
||||
function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
|
||||
gunPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
@@ -53,6 +57,9 @@ export function ArmorHighlighting() {
|
||||
gunRotation.set(pitch, 0, 0);
|
||||
gunContainer.current?.position.copy(gunPosition);
|
||||
gunContainer.current?.rotation.copy(gunRotation);
|
||||
|
||||
if (yaw === undefined) return;
|
||||
|
||||
turretPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
@@ -80,6 +87,16 @@ export function ArmorHighlighting() {
|
||||
turretContainer.current?.position.copy(turretPosition);
|
||||
turretContainer.current?.rotation.copy(turretRotation);
|
||||
}
|
||||
|
||||
handleModelTransform(useDuel.getState().protagonist!);
|
||||
modelTransformEvent.on(handleModelTransform);
|
||||
|
||||
return () => {
|
||||
modelTransformEvent.off(handleModelTransform);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = useTankopediaPersistent.subscribe(
|
||||
(state) => state.mode,
|
||||
(mode) => {
|
||||
@@ -87,27 +104,24 @@ export function ArmorHighlighting() {
|
||||
},
|
||||
);
|
||||
|
||||
handleModelTransform(protagonist);
|
||||
modelTransformEvent.on(handleModelTransform);
|
||||
|
||||
return () => {
|
||||
modelTransformEvent.off(handleModelTransform);
|
||||
unsubscribe();
|
||||
};
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const armorGltf = useArmor(protagonist.tank.id);
|
||||
const tank = useDuel((state) => state.protagonist!.tank);
|
||||
const track = useDuel((state) => state.protagonist!.track);
|
||||
const turret = useDuel((state) => state.protagonist!.turret);
|
||||
const gun = useDuel((state) => state.protagonist!.gun);
|
||||
const armorGltf = useArmor(tank.id);
|
||||
const armorNodes = Object.values(armorGltf.nodes);
|
||||
const tankModelDefinition = modelDefinitions[protagonist.tank.id];
|
||||
const trackModelDefinition = tankModelDefinition.tracks[protagonist.track.id];
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
const tankModelDefinition = modelDefinitions[tank.id];
|
||||
const trackModelDefinition = tankModelDefinition.tracks[track.id];
|
||||
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
);
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
@@ -118,96 +132,88 @@ export function ArmorHighlighting() {
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const maxThickness = Math.max(
|
||||
trackModelDefinition.thickness,
|
||||
gunModelDefinition.thickness,
|
||||
...armorNodes
|
||||
.map((node) => {
|
||||
const armorId = nameToArmorId(node.name);
|
||||
return resolveArmor(tankModelDefinition.armor, armorId).thickness;
|
||||
})
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={wrapper}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
visible={initialTankopediaState.mode === 'armor'}
|
||||
position={hullOrigin}
|
||||
>
|
||||
{armorNodes.map((node) => {
|
||||
const isHull = node.name.startsWith('hull_');
|
||||
const isVisible = isHull;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
tankModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined || spaced) return null;
|
||||
|
||||
return (
|
||||
<ArmorMesh
|
||||
maxThickness={maxThickness}
|
||||
key={node.uuid}
|
||||
geometry={(node as Mesh).geometry}
|
||||
thickness={thickness}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<group ref={turretContainer}>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentTurret = node.name.startsWith(
|
||||
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentTurret;
|
||||
const isHull = node.name.startsWith('hull_');
|
||||
const isVisible = isHull;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
turretModelDefinition.armor,
|
||||
tankModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined || spaced) return null;
|
||||
if (!isVisible || spaced || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<ArmorMesh
|
||||
maxThickness={maxThickness}
|
||||
<CoreArmorSceneComponent
|
||||
key={node.uuid}
|
||||
geometry={(node as Mesh).geometry}
|
||||
position={turretOrigin}
|
||||
thickness={thickness}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
|
||||
<group ref={gunContainer}>
|
||||
<group ref={turretContainer}>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentGun = node.name.startsWith(
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
const isCurrentTurret = node.name.startsWith(
|
||||
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentGun;
|
||||
const isVisible = isCurrentTurret;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
gunModelDefinition.armor,
|
||||
turretModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined || spaced) return null;
|
||||
if (!isVisible || spaced || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<ArmorMesh
|
||||
maxThickness={maxThickness}
|
||||
key={node.uuid}
|
||||
geometry={(node as Mesh).geometry}
|
||||
position={turretOrigin.clone().add(gunOrigin)}
|
||||
thickness={thickness}
|
||||
/>
|
||||
<group key={node.uuid} position={turretOrigin}>
|
||||
<CoreArmorSceneComponent
|
||||
key={node.uuid}
|
||||
thickness={thickness}
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
<group ref={gunContainer}>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentGun = node.name.startsWith(
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentGun;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
gunModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || spaced || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
key={node.uuid}
|
||||
position={turretOrigin.clone().add(gunOrigin)}
|
||||
>
|
||||
<CoreArmorSceneComponent thickness={thickness} node={node} />
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
ShaderMaterial,
|
||||
Vector2,
|
||||
WebGLRenderTarget,
|
||||
} from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { canSplash } from '../../../../core/blitz/canSplash';
|
||||
import { isExplosive } from '../../../../core/blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
|
||||
import { hasEquipment } from '../../../../core/blitzkrieg/hasEquipment';
|
||||
import { jsxTree } from '../../../../core/blitzkrieg/jsxTree';
|
||||
import { ShellDefinition } from '../../../../core/blitzkrieg/tankDefinitions';
|
||||
import { EquipmentMatrix, useDuel } from '../../../../stores/duel';
|
||||
import {
|
||||
TankopediaPersistent,
|
||||
useTankopediaPersistent,
|
||||
} from '../../../../stores/tankopedia';
|
||||
import fragmentShader from './shaders/fragment.glsl';
|
||||
import vertexShader from './shaders/vertex.glsl';
|
||||
|
||||
export const spacedArmorRenderTarget = new WebGLRenderTarget();
|
||||
|
||||
interface CoreArmorSceneComponentProps {
|
||||
thickness: number;
|
||||
node: Object3D;
|
||||
}
|
||||
|
||||
const excludeMaterial = new MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
});
|
||||
|
||||
export function CoreArmorSceneComponent({
|
||||
node,
|
||||
thickness,
|
||||
}: CoreArmorSceneComponentProps) {
|
||||
const material = new ShaderMaterial({
|
||||
fragmentShader,
|
||||
vertexShader,
|
||||
|
||||
transparent: true,
|
||||
|
||||
uniforms: {
|
||||
thickness: { value: null },
|
||||
penetration: { value: null },
|
||||
caliber: { value: null },
|
||||
ricochet: { value: null },
|
||||
normalization: { value: null },
|
||||
isExplosive: { value: null },
|
||||
canSplash: { value: null },
|
||||
damage: { value: null },
|
||||
explosionRadius: { value: null },
|
||||
greenPenetration: { value: null },
|
||||
opaque: { value: null },
|
||||
|
||||
resolution: { value: new Vector2() },
|
||||
spacedArmorBuffer: { value: null },
|
||||
spacedArmorDepth: { value: null },
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleShellChange(shell: ShellDefinition) {
|
||||
material.uniforms.penetration.value = resolveNearPenetration(
|
||||
shell.penetration,
|
||||
);
|
||||
material.uniforms.caliber.value = shell.caliber;
|
||||
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
|
||||
material.uniforms.normalization.value = degToRad(
|
||||
shell.normalization ?? 0,
|
||||
);
|
||||
material.uniforms.isExplosive.value = isExplosive(shell.type);
|
||||
material.uniforms.canSplash.value = canSplash(shell.type);
|
||||
material.uniforms.damage.value = shell.damage.armor;
|
||||
material.uniforms.explosionRadius.value = shell.explosionRadius;
|
||||
}
|
||||
function handleVisualChange(
|
||||
visual: TankopediaPersistent['model']['visual'],
|
||||
) {
|
||||
material.uniforms.greenPenetration.value = visual.greenPenetration;
|
||||
material.uniforms.opaque.value = visual.opaque || visual.wireframe;
|
||||
material.wireframe = visual.wireframe;
|
||||
}
|
||||
async function handleProtagonistEquipmentChange(
|
||||
equipment: EquipmentMatrix,
|
||||
) {
|
||||
const hasEnhancedArmor = await hasEquipment(110, false, equipment);
|
||||
material.uniforms.thickness.value = hasEnhancedArmor
|
||||
? thickness * 1.04
|
||||
: thickness;
|
||||
}
|
||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const penetration = resolveNearPenetration(shell.penetration);
|
||||
const hasCalibratedShells = await hasEquipment(103, true, equipment);
|
||||
material.uniforms.penetration.value = hasCalibratedShells
|
||||
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
|
||||
: penetration;
|
||||
}
|
||||
|
||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||
handleVisualChange(useTankopediaPersistent.getState().model.visual);
|
||||
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
|
||||
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
|
||||
|
||||
const unsubscribes = [
|
||||
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
||||
useTankopediaPersistent.subscribe(
|
||||
(state) => state.model.visual,
|
||||
handleVisualChange,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.protagonist!.equipment,
|
||||
handleProtagonistEquipmentChange,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.antagonist!.equipment,
|
||||
handleAntagonistEquipmentChange,
|
||||
),
|
||||
];
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame(({ gl }) => {
|
||||
gl.getSize(material.uniforms.resolution.value).multiplyScalar(
|
||||
gl.getPixelRatio(),
|
||||
);
|
||||
material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
|
||||
material.uniforms.spacedArmorDepth.value =
|
||||
spacedArmorRenderTarget.depthTexture;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{jsxTree(node, {
|
||||
renderOrder: 0,
|
||||
material: excludeMaterial,
|
||||
})}
|
||||
{jsxTree(node, {
|
||||
renderOrder: 1,
|
||||
material,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewPosition;
|
||||
varying mat4 vProjectionMatrix;
|
||||
|
||||
uniform float thickness;
|
||||
uniform float penetration;
|
||||
uniform float caliber;
|
||||
uniform float ricochet;
|
||||
uniform float normalization;
|
||||
uniform vec2 resolution;
|
||||
uniform bool isExplosive;
|
||||
uniform bool canSplash;
|
||||
uniform float damage;
|
||||
uniform float explosionRadius;
|
||||
uniform bool greenPenetration;
|
||||
uniform bool opaque;
|
||||
|
||||
uniform highp sampler2D spacedArmorBuffer;
|
||||
uniform highp sampler2D spacedArmorDepth;
|
||||
|
||||
float depthToDistance(float depth) {
|
||||
vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
|
||||
vec4 eyePosition = inverse(vProjectionMatrix) * clipPosition;
|
||||
return length(eyePosition.xyz / eyePosition.w);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float penetrationChance = -1.0;
|
||||
float splashChance = -1.0;
|
||||
|
||||
vec2 screenCoordinates = gl_FragCoord.xy / resolution;
|
||||
float angle = acos(dot(vNormal, -vViewPosition) / length(vViewPosition));
|
||||
|
||||
vec4 spacedArmorBufferFragment = texture2D(spacedArmorBuffer, screenCoordinates);
|
||||
bool isUnderSpacedArmor = spacedArmorBufferFragment.a != 0.0;
|
||||
|
||||
bool threeCalibersRule = caliber > thickness * 3.0 || isUnderSpacedArmor;
|
||||
if (!threeCalibersRule && angle >= ricochet) {
|
||||
penetrationChance = 0.0;
|
||||
} else {
|
||||
bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0;
|
||||
float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization;
|
||||
float finalThickness = thickness / cos(max(0.0, angle - finalNormalization));
|
||||
float remainingPenetration = penetration;
|
||||
|
||||
// world space is in meters
|
||||
vec4 spacedArmorDepthFragment = texture2D(spacedArmorDepth, screenCoordinates);
|
||||
float spacedArmorDistance = depthToDistance(spacedArmorDepthFragment.r);
|
||||
float coreArmorDistance = depthToDistance(gl_FragCoord.z);
|
||||
float distanceFromSpacedArmor = coreArmorDistance - spacedArmorDistance;
|
||||
|
||||
if (isUnderSpacedArmor) {
|
||||
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
|
||||
remainingPenetration -= spacedArmorThickness;
|
||||
|
||||
if (isExplosive && !canSplash && remainingPenetration > 0.0) {
|
||||
// there is a 50% penetration loss per meter for HEAT shells
|
||||
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 {
|
||||
splashChance = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
float alpha = opaque ? 1.0 : 0.5;
|
||||
vec3 color = vec3(1.0, splashChance * 0.392, 0.0);
|
||||
if (greenPenetration) {
|
||||
// non-trigonometric red-green mixing: https://www.desmos.com/calculator/ceo1oq7l67
|
||||
float falling = -pow(penetrationChance, 2.0) + 1.0;
|
||||
float gaining = -pow(penetrationChance - 1.0, 2.0) + 1.0;
|
||||
gl_FragColor = vec4(falling * color + gaining * vec3(0.0, 1.0, 0.0), alpha);
|
||||
} else {
|
||||
gl_FragColor = vec4(color, (1.0 - penetrationChance) * alpha);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,306 @@
|
||||
import { Card, Flex, Inset, Text } from '@radix-ui/themes';
|
||||
import { Html } from '@react-three/drei';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Mesh } from 'three';
|
||||
import { radToDeg } from 'three/src/math/MathUtils';
|
||||
import { isExplosive } from '../../../core/blitz/isExplosive';
|
||||
import { NaNFallback } from '../../../core/math/NaNFallback';
|
||||
import { normalToEuler } from '../../../core/math/normalToEuler';
|
||||
import { AngledPenetration } from '../../../icons/AngledPenetration';
|
||||
import { Block } from '../../../icons/Block';
|
||||
import { NominalPenetration } from '../../../icons/NominalPenetration';
|
||||
import { Ricochet } from '../../../icons/Ricochet';
|
||||
import { useDuel } from '../../../stores/duel';
|
||||
import {
|
||||
ShotLayer,
|
||||
ShotLayerBase,
|
||||
ShotLayerGap,
|
||||
ShotLayerNonExternal,
|
||||
mutateTankopediaTemporary,
|
||||
useTankopediaTemporary,
|
||||
} from '../../../stores/tankopedia';
|
||||
import { ArmorType } from './SpacedArmorScene';
|
||||
|
||||
const layerTypeNames: Record<ArmorType | 'null', string> = {
|
||||
[ArmorType.Core]: 'Core',
|
||||
[ArmorType.Spaced]: 'Spaced',
|
||||
[ArmorType.External]: 'External',
|
||||
null: 'Gap',
|
||||
};
|
||||
|
||||
const LENGTH_INFINITY = 4;
|
||||
const TRACER_THICK = 1 / 64;
|
||||
const TRACER_THIN = TRACER_THICK / 2;
|
||||
|
||||
export function ShotDisplay() {
|
||||
const shot = useTankopediaTemporary((state) => state.shot);
|
||||
const preMidPointTracer = useRef<Mesh>(null);
|
||||
const postMidPointTracer = useRef<Mesh>(null);
|
||||
const hasMultipleLayers = shot?.layers.some(
|
||||
(layer, index) => index !== 0 && layer.type !== null,
|
||||
);
|
||||
const { shell } = useDuel.getState().antagonist!;
|
||||
const explosive = isExplosive(shell.type);
|
||||
const midPointLayer = (shot?.layers.findLast(
|
||||
(layer) => layer.type !== null && layer.status === 'ricochet',
|
||||
) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase;
|
||||
const firstLayer = shot?.layers[0] as Exclude<ShotLayer, ShotLayerGap>;
|
||||
const lastLayer = shot?.layers.at(-1)!;
|
||||
const distanceFromSpacedArmor = shot
|
||||
? firstLayer.point.distanceTo(shot.point)
|
||||
: undefined;
|
||||
const preMidPointLayers = shot?.layers.slice(
|
||||
0,
|
||||
shot.layers.indexOf(midPointLayer as ShotLayer),
|
||||
);
|
||||
const postMidPointLayers = shot?.layers.slice(
|
||||
shot.layers.indexOf(midPointLayer as ShotLayer),
|
||||
shot.layers.length,
|
||||
);
|
||||
const preMidPointGap = shot
|
||||
? preMidPointLayers!.reduce((accumulator, layer) => {
|
||||
if (layer.type === null) return accumulator + layer.distance;
|
||||
return accumulator;
|
||||
}, 0) + LENGTH_INFINITY
|
||||
: undefined;
|
||||
const postMidPointGap = shot
|
||||
? postMidPointLayers!.reduce((accumulator, layer) => {
|
||||
if (layer.type === null) return accumulator + layer.distance;
|
||||
return accumulator;
|
||||
}, 0) || LENGTH_INFINITY
|
||||
: undefined;
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!shot || !preMidPointTracer.current) return;
|
||||
|
||||
const t1 = clock.elapsedTime % 1;
|
||||
const t2 = (clock.elapsedTime + 0.5) % 1;
|
||||
|
||||
preMidPointTracer.current.position.set(0, (1 - t1) * preMidPointGap!, 0);
|
||||
preMidPointTracer.current.scale.set(1, 1 - 2 * Math.abs(t1 - 0.5), 1);
|
||||
|
||||
if (!postMidPointTracer.current) return;
|
||||
|
||||
postMidPointTracer.current.position.set(0, t2 * postMidPointGap!, 0);
|
||||
postMidPointTracer.current.scale.set(1, 1 - 2 * Math.abs(t2 - 0.5), 1);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function resetShot() {
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
const unsubscribes = [
|
||||
useDuel.subscribe((state) => state.protagonist?.tank, resetShot),
|
||||
useDuel.subscribe((state) => state.antagonist?.tank, resetShot),
|
||||
];
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
});
|
||||
|
||||
if (!shot) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<group
|
||||
position={midPointLayer.point}
|
||||
rotation={normalToEuler(midPointLayer.shellNormal)}
|
||||
>
|
||||
<mesh position={[0, preMidPointGap! / 2, 0]}>
|
||||
<cylinderGeometry args={[TRACER_THIN, TRACER_THIN, preMidPointGap]} />
|
||||
<meshBasicMaterial depthTest={false} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, preMidPointGap! / 2, 0]} ref={preMidPointTracer}>
|
||||
<cylinderGeometry
|
||||
args={[TRACER_THICK, TRACER_THICK, preMidPointGap]}
|
||||
/>
|
||||
<meshBasicMaterial
|
||||
color={
|
||||
lastLayer.status === 'blocked'
|
||||
? 0xff4040
|
||||
: lastLayer.status === 'penetration'
|
||||
? 0x00ff00
|
||||
: 0xffff00
|
||||
}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{midPointLayer.status === 'ricochet' && (
|
||||
<group
|
||||
position={midPointLayer.point}
|
||||
rotation={normalToEuler(
|
||||
midPointLayer.shellNormal
|
||||
.clone()
|
||||
.reflect(midPointLayer.surfaceNormal)
|
||||
.multiplyScalar(-1),
|
||||
)}
|
||||
>
|
||||
<mesh position={[0, postMidPointGap! / 2, 0]}>
|
||||
<cylinderGeometry
|
||||
args={[TRACER_THIN, TRACER_THIN, postMidPointGap]}
|
||||
/>
|
||||
<meshBasicMaterial depthTest={false} />
|
||||
</mesh>
|
||||
|
||||
<mesh
|
||||
position={[0, postMidPointGap! / 2, 0]}
|
||||
ref={postMidPointTracer}
|
||||
>
|
||||
<cylinderGeometry
|
||||
args={[TRACER_THICK, TRACER_THICK, postMidPointGap]}
|
||||
/>
|
||||
<meshBasicMaterial color={0xffff00} />
|
||||
</mesh>
|
||||
</group>
|
||||
)}
|
||||
|
||||
{shot.layers.map((layer, index) => {
|
||||
if (layer.type === null) return;
|
||||
|
||||
const shellRotation = normalToEuler(layer.shellNormal);
|
||||
const nextLayer = shot.layers[index + 1] as ShotLayerGap | undefined;
|
||||
const trueStatus =
|
||||
nextLayer?.status === 'blocked' ? 'blocked' : layer.status;
|
||||
|
||||
return (
|
||||
<>
|
||||
<group
|
||||
key={`${index}-line`}
|
||||
position={layer.point}
|
||||
rotation={shellRotation}
|
||||
>
|
||||
<Html
|
||||
center
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
backgroundColor:
|
||||
trueStatus === 'penetration'
|
||||
? '#00ff00c0'
|
||||
: trueStatus === 'blocked'
|
||||
? '#ff0000c0'
|
||||
: '#ffff00c0',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Inset>
|
||||
{hasMultipleLayers && (
|
||||
<Text
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
color:
|
||||
trueStatus === 'ricochet' ? 'black' : undefined,
|
||||
}}
|
||||
>
|
||||
<b>{layer.index + 1}</b>
|
||||
</Text>
|
||||
)}
|
||||
</Inset>
|
||||
</Card>
|
||||
</Html>
|
||||
</group>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
|
||||
<Html
|
||||
position={midPointLayer.point}
|
||||
style={{
|
||||
transform: 'translateY(-50%)',
|
||||
marginLeft: 32,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Card style={{ width: 256 + 32 }}>
|
||||
<Flex direction="column">
|
||||
<Text>
|
||||
{lastLayer.status[0].toUpperCase()}
|
||||
{lastLayer.status.slice(1)}
|
||||
{lastLayer.status === 'penetration' &&
|
||||
`: ${Math.round(shell.damage.armor)}hp`}
|
||||
{lastLayer.status === 'blocked' &&
|
||||
shell.type === 'he' &&
|
||||
`: ${NaNFallback(
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
0.5 *
|
||||
shell.damage.armor *
|
||||
(1 -
|
||||
distanceFromSpacedArmor! / shell.explosionRadius!) -
|
||||
1.1 *
|
||||
(midPointLayer as ShotLayerNonExternal)
|
||||
.thicknessAngled,
|
||||
),
|
||||
),
|
||||
0,
|
||||
)}hp`}
|
||||
</Text>
|
||||
|
||||
{shot.layers.map((layer, index) => {
|
||||
if (layer.type === null && !explosive) return null;
|
||||
|
||||
return (
|
||||
<Flex key={index} align="center" gap="2">
|
||||
<Flex>
|
||||
{hasMultipleLayers && (
|
||||
<Text size="2" style={{ width: 16 }}>
|
||||
{layer.type === null ? null : `${layer.index + 1}.`}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="2">{layerTypeNames[`${layer.type}`]}</Text>
|
||||
</Flex>
|
||||
|
||||
{layer.type !== null && (
|
||||
<>
|
||||
<Flex align="center" style={{ width: 64 }}>
|
||||
<NominalPenetration width={16} height={16} />
|
||||
<Text size="2">{Math.round(layer.thickness)}mm</Text>
|
||||
</Flex>
|
||||
|
||||
{layer.type !== ArmorType.External && (
|
||||
<Flex align="center">
|
||||
{layer.status === 'ricochet' ? (
|
||||
<Ricochet width={16} height={16} />
|
||||
) : layer.status === 'blocked' ? (
|
||||
<Block width={16} height={16} />
|
||||
) : (
|
||||
<AngledPenetration width={16} height={16} />
|
||||
)}
|
||||
<Text size="2">
|
||||
{Math.round(layer.thicknessAngled)}mm (
|
||||
{Math.round(radToDeg(layer.angle))}°)
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{layer.type === null && (
|
||||
<Text size="2">
|
||||
{Math.round(layer.distance * 1000)}mm (-
|
||||
{Math.round(Math.min(100, layer.distance * 50))}%
|
||||
penetration)
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Html>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { Euler, Group, Scene, Vector3 } from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { I_HAT, J_HAT, K_HAT } from '../../../constants/axis';
|
||||
import {
|
||||
ModelTransformEventData,
|
||||
modelTransformEvent,
|
||||
} from '../../../core/blitzkrieg/modelTransform';
|
||||
import { nameToArmorId } from '../../../core/blitzkrieg/nameToArmorId';
|
||||
import { resolveArmor } from '../../../core/blitzkrieg/resolveThickness';
|
||||
import { useArmor } from '../../../hooks/useArmor';
|
||||
import { useModel } from '../../../hooks/useModel';
|
||||
import { useModelDefinitions } from '../../../hooks/useModelDefinitions';
|
||||
import { useDuel } from '../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../stores/tankopedia';
|
||||
import { SpacedArmorSceneComponent } from './SpacedArmorSceneComponent';
|
||||
|
||||
export enum ArmorType {
|
||||
Core,
|
||||
Spaced,
|
||||
External,
|
||||
}
|
||||
|
||||
interface SpacedArmorSceneProps {
|
||||
scene: Scene;
|
||||
}
|
||||
|
||||
export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
||||
const wrapper = useRef<Group>(null);
|
||||
const modelDefinitions = useModelDefinitions();
|
||||
const turretContainer = useRef<Group>(null);
|
||||
const gunContainer = useRef<Group>(null);
|
||||
const initialTankopediaState = useTankopediaPersistent.getState();
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelDefinitions) return;
|
||||
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretPosition = new Vector3();
|
||||
const turretRotation = new Euler();
|
||||
const gunPosition = new Vector3();
|
||||
const gunRotation = new Euler();
|
||||
|
||||
function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
|
||||
gunPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
.sub(turretOrigin)
|
||||
.sub(gunOrigin)
|
||||
.applyAxisAngle(I_HAT, pitch)
|
||||
.add(gunOrigin)
|
||||
.add(turretOrigin)
|
||||
.add(hullOrigin);
|
||||
gunRotation.set(pitch, 0, 0);
|
||||
gunContainer.current?.position.copy(gunPosition);
|
||||
gunContainer.current?.rotation.copy(gunRotation);
|
||||
|
||||
if (yaw === undefined) return;
|
||||
|
||||
turretPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
.sub(turretOrigin)
|
||||
.applyAxisAngle(new Vector3(0, 0, 1), yaw);
|
||||
turretRotation.set(0, 0, yaw);
|
||||
|
||||
if (tankModelDefinition.turretRotation) {
|
||||
const initialPitch = -degToRad(
|
||||
tankModelDefinition.turretRotation.pitch,
|
||||
);
|
||||
const initialYaw = -degToRad(tankModelDefinition.turretRotation.yaw);
|
||||
const initialRoll = -degToRad(tankModelDefinition.turretRotation.roll);
|
||||
|
||||
turretPosition
|
||||
.applyAxisAngle(I_HAT, initialPitch)
|
||||
.applyAxisAngle(J_HAT, initialRoll)
|
||||
.applyAxisAngle(K_HAT, initialYaw);
|
||||
turretRotation.x += initialPitch;
|
||||
turretRotation.y += initialRoll;
|
||||
turretRotation.z += initialYaw;
|
||||
}
|
||||
|
||||
turretPosition.add(turretOrigin).add(hullOrigin);
|
||||
turretContainer.current?.position.copy(turretPosition);
|
||||
turretContainer.current?.rotation.copy(turretRotation);
|
||||
}
|
||||
|
||||
handleModelTransform(useDuel.getState().protagonist!);
|
||||
modelTransformEvent.on(handleModelTransform);
|
||||
|
||||
return () => {
|
||||
modelTransformEvent.off(handleModelTransform);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = useTankopediaPersistent.subscribe(
|
||||
(state) => state.mode,
|
||||
(mode) => {
|
||||
if (wrapper.current) wrapper.current.visible = mode === 'armor';
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const tank = useDuel((state) => state.protagonist!.tank);
|
||||
const track = useDuel((state) => state.protagonist!.track);
|
||||
const turret = useDuel((state) => state.protagonist!.turret);
|
||||
const gun = useDuel((state) => state.protagonist!.gun);
|
||||
const armorGltf = useArmor(tank.id);
|
||||
const { gltf: modelGltf } = useModel(tank.id);
|
||||
const armorNodes = Object.values(armorGltf.nodes);
|
||||
const modelNodes = Object.values(modelGltf.nodes);
|
||||
const tankModelDefinition = modelDefinitions[tank.id];
|
||||
const trackModelDefinition = tankModelDefinition.tracks[track.id];
|
||||
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={wrapper}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
visible={initialTankopediaState.mode === 'armor'}
|
||||
>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isHull = node.name.startsWith('hull_');
|
||||
const isVisible = isHull;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
tankModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<SpacedArmorSceneComponent
|
||||
scene={scene}
|
||||
key={node.uuid}
|
||||
type={spaced ? ArmorType.Spaced : ArmorType.Core}
|
||||
thickness={thickness}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
{modelNodes.map((node) => {
|
||||
const isWheel = node.name.startsWith('chassis_wheel_');
|
||||
const isTrack = node.name.startsWith('chassis_track_');
|
||||
const isVisible = isWheel || isTrack;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<SpacedArmorSceneComponent
|
||||
scene={scene}
|
||||
key={node.uuid}
|
||||
type={ArmorType.External}
|
||||
thickness={trackModelDefinition.thickness}
|
||||
variant="track"
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<group ref={turretContainer}>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentTurret = node.name.startsWith(
|
||||
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentTurret;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
turretModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<group key={node.uuid} position={turretOrigin}>
|
||||
<SpacedArmorSceneComponent
|
||||
scene={scene}
|
||||
key={node.uuid}
|
||||
type={spaced ? ArmorType.Spaced : ArmorType.Core}
|
||||
thickness={thickness}
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
<group ref={gunContainer}>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentGun = node.name.startsWith(
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentGun;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
gunModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
key={node.uuid}
|
||||
position={turretOrigin.clone().add(gunOrigin)}
|
||||
>
|
||||
<SpacedArmorSceneComponent
|
||||
scene={scene}
|
||||
type={spaced ? ArmorType.Spaced : ArmorType.Core}
|
||||
thickness={thickness}
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
|
||||
{modelNodes.map((node) => {
|
||||
const isCurrentGun =
|
||||
node.name ===
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
|
||||
const isVisible = isCurrentGun;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<SpacedArmorSceneComponent
|
||||
scene={scene}
|
||||
key={node.uuid}
|
||||
type={ArmorType.External}
|
||||
thickness={gunModelDefinition.thickness}
|
||||
variant="gun"
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
});
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
AdditiveBlending,
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
ShaderMaterial,
|
||||
} from 'three';
|
||||
import { ArmorUserData, ExternalModuleVariant } from '../..';
|
||||
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
|
||||
import { hasEquipment } from '../../../../../../core/blitzkrieg/hasEquipment';
|
||||
import { jsxTree } from '../../../../../../core/blitzkrieg/jsxTree';
|
||||
import { ShellDefinition } from '../../../../../../core/blitzkrieg/tankDefinitions';
|
||||
import { EquipmentMatrix, useDuel } from '../../../../../../stores/duel';
|
||||
import { ArmorType } from '../../../SpacedArmorScene';
|
||||
import fragmentShader from './shaders/fragment.glsl';
|
||||
import vertexShader from './shaders/vertex.glsl';
|
||||
|
||||
interface SpacedArmorSubExternalProps {
|
||||
node: Object3D;
|
||||
thickness: number;
|
||||
variant: ExternalModuleVariant;
|
||||
}
|
||||
|
||||
export function SpacedArmorSubExternal({
|
||||
node,
|
||||
thickness,
|
||||
variant,
|
||||
}: SpacedArmorSubExternalProps) {
|
||||
const material = new ShaderMaterial({
|
||||
fragmentShader,
|
||||
vertexShader,
|
||||
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
blending: AdditiveBlending,
|
||||
|
||||
uniforms: {
|
||||
thickness: { value: null },
|
||||
penetration: { value: null },
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleShellChange(shell: ShellDefinition) {
|
||||
material.uniforms.penetration.value = resolveNearPenetration(
|
||||
shell.penetration,
|
||||
);
|
||||
}
|
||||
async function handleProtagonistEquipmentChange(
|
||||
equipment: EquipmentMatrix,
|
||||
) {
|
||||
const hasEnhancedArmor = await hasEquipment(110, false, equipment);
|
||||
material.uniforms.thickness.value = hasEnhancedArmor
|
||||
? thickness * 1.04
|
||||
: thickness;
|
||||
}
|
||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const penetration = resolveNearPenetration(shell.penetration);
|
||||
const hasCalibratedShells = await hasEquipment(103, true, equipment);
|
||||
material.uniforms.penetration.value = hasCalibratedShells
|
||||
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
|
||||
: penetration;
|
||||
}
|
||||
|
||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
|
||||
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
|
||||
|
||||
const unsubscribes = [
|
||||
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
||||
useDuel.subscribe(
|
||||
(state) => state.protagonist!.equipment,
|
||||
handleProtagonistEquipmentChange,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.antagonist!.equipment,
|
||||
handleAntagonistEquipmentChange,
|
||||
),
|
||||
];
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{jsxTree(node, {
|
||||
renderOrder: 3,
|
||||
material: new MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
depthTest: true,
|
||||
depthWrite: true,
|
||||
}),
|
||||
onClick() {},
|
||||
userData: {
|
||||
type: ArmorType.External,
|
||||
thickness,
|
||||
variant,
|
||||
} satisfies ArmorUserData,
|
||||
})}
|
||||
{jsxTree(node, {
|
||||
renderOrder: 4,
|
||||
material,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
uniform float thickness;
|
||||
uniform float penetration;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = vec4(thickness / penetration, 0.0, 0.0, 1.0);
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
void main() {
|
||||
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_Position = projectionMatrix * vViewPosition4;
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
AdditiveBlending,
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
ShaderMaterial,
|
||||
} from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { ArmorUserData } from '../..';
|
||||
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
|
||||
import { hasEquipment } from '../../../../../../core/blitzkrieg/hasEquipment';
|
||||
import { jsxTree } from '../../../../../../core/blitzkrieg/jsxTree';
|
||||
import { ShellDefinition } from '../../../../../../core/blitzkrieg/tankDefinitions';
|
||||
import { EquipmentMatrix, useDuel } from '../../../../../../stores/duel';
|
||||
import { ArmorType } from '../../../SpacedArmorScene';
|
||||
import fragmentShader from './shaders/fragment.glsl';
|
||||
import vertexShader from './shaders/vertex.glsl';
|
||||
|
||||
interface SpacedArmorSubSpacedProps {
|
||||
node: Object3D;
|
||||
thickness: number;
|
||||
}
|
||||
|
||||
const depthWriteMaterial = new MeshBasicMaterial({
|
||||
depthWrite: true,
|
||||
colorWrite: false,
|
||||
});
|
||||
|
||||
export function SpacedArmorSubSpaced({
|
||||
node,
|
||||
thickness,
|
||||
}: SpacedArmorSubSpacedProps) {
|
||||
const material = new ShaderMaterial({
|
||||
fragmentShader,
|
||||
vertexShader,
|
||||
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
blending: AdditiveBlending,
|
||||
|
||||
uniforms: {
|
||||
thickness: { value: null },
|
||||
penetration: { value: null },
|
||||
caliber: { value: null },
|
||||
ricochet: { value: null },
|
||||
normalization: { value: null },
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleShellChange(shell: ShellDefinition) {
|
||||
material.uniforms.penetration.value = resolveNearPenetration(
|
||||
shell.penetration,
|
||||
);
|
||||
material.uniforms.caliber.value = shell.caliber;
|
||||
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
|
||||
material.uniforms.normalization.value = degToRad(
|
||||
shell.normalization ?? 0,
|
||||
);
|
||||
}
|
||||
async function handleProtagonistEquipmentChange(
|
||||
equipment: EquipmentMatrix,
|
||||
) {
|
||||
const hasEnhancedArmor = await hasEquipment(110, false, equipment);
|
||||
material.uniforms.thickness.value = hasEnhancedArmor
|
||||
? thickness * 1.04
|
||||
: thickness;
|
||||
}
|
||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const penetration = resolveNearPenetration(shell.penetration);
|
||||
const hasCalibratedShells = await hasEquipment(103, true, equipment);
|
||||
material.uniforms.penetration.value = hasCalibratedShells
|
||||
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
|
||||
: penetration;
|
||||
}
|
||||
|
||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
|
||||
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
|
||||
|
||||
const unsubscribes = [
|
||||
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
||||
useDuel.subscribe(
|
||||
(state) => state.protagonist!.equipment,
|
||||
handleProtagonistEquipmentChange,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.antagonist!.equipment,
|
||||
handleAntagonistEquipmentChange,
|
||||
),
|
||||
];
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{jsxTree(node, {
|
||||
renderOrder: 2,
|
||||
material,
|
||||
onClick() {},
|
||||
userData: {
|
||||
type: ArmorType.Spaced,
|
||||
thickness,
|
||||
} satisfies ArmorUserData,
|
||||
})}
|
||||
{jsxTree(node, {
|
||||
renderOrder: 5,
|
||||
material: depthWriteMaterial,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewPosition;
|
||||
|
||||
uniform float thickness;
|
||||
uniform float penetration;
|
||||
uniform float caliber;
|
||||
uniform float ricochet;
|
||||
uniform float normalization;
|
||||
|
||||
void main() {
|
||||
float angle = acos(dot(vNormal, -vViewPosition) / length(vViewPosition));
|
||||
|
||||
bool threeCalibersRule = caliber > thickness * 3.0;
|
||||
if (!threeCalibersRule && angle >= ricochet) {
|
||||
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0;
|
||||
float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization;
|
||||
float finalThickness = thickness / cos(max(0.0, angle - finalNormalization));
|
||||
|
||||
gl_FragColor = vec4(finalThickness / penetration, 0.0, 0.0, 1.0);
|
||||
}
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
varying vec3 vViewPosition;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewPosition;
|
||||
|
||||
void main() {
|
||||
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
|
||||
|
||||
vViewPosition = vViewPosition4.xyz;
|
||||
vNormal = normalMatrix * normal;
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
gl_Position = projectionMatrix * vViewPosition4;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useThree } from '@react-three/fiber';
|
||||
import {
|
||||
Intersection,
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
Scene,
|
||||
Vector3,
|
||||
} from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { canSplash } from '../../../../core/blitz/canSplash';
|
||||
import { isExplosive } from '../../../../core/blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
|
||||
import { hasEquipment } from '../../../../core/blitzkrieg/hasEquipment';
|
||||
import { jsxTree } from '../../../../core/blitzkrieg/jsxTree';
|
||||
import { useDuel } from '../../../../stores/duel';
|
||||
import {
|
||||
Shot,
|
||||
ShotLayerBase,
|
||||
useTankopediaTemporary,
|
||||
} from '../../../../stores/tankopedia';
|
||||
import { ArmorType } from '../SpacedArmorScene';
|
||||
import { SpacedArmorSubExternal } from './components/SpacedArmorSubExternal';
|
||||
import { SpacedArmorSubSpaced } from './components/SpacedArmorSubSpaced';
|
||||
|
||||
type SpacedArmorSceneComponentProps = {
|
||||
node: Object3D;
|
||||
thickness: number;
|
||||
scene: Scene;
|
||||
} & (
|
||||
| {
|
||||
type: Exclude<ArmorType, ArmorType.External>;
|
||||
}
|
||||
| {
|
||||
type: ArmorType.External;
|
||||
variant: ExternalModuleVariant;
|
||||
}
|
||||
);
|
||||
|
||||
export type ExternalModuleVariant = 'gun' | 'track';
|
||||
export type ArmorUserData = {
|
||||
thickness: number;
|
||||
} & (
|
||||
| {
|
||||
type: Exclude<ArmorType, ArmorType.External>;
|
||||
}
|
||||
| {
|
||||
type: ArmorType.External;
|
||||
variant: ExternalModuleVariant;
|
||||
}
|
||||
);
|
||||
type ArmorMeshIntersection = Intersection & {
|
||||
object: Object3D & { userData: ArmorUserData };
|
||||
};
|
||||
|
||||
const omitMaterial = new MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
depthWrite: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Render orders allocations:
|
||||
* - 0 : core omission
|
||||
* - 1-2: spaced without depth buffer write but do test to allow additive blending
|
||||
* - 3-4: external with depth buffer write in first pass and read in second to simplify geometry
|
||||
* - 5 : spaced depth write
|
||||
*/
|
||||
export function SpacedArmorSceneComponent({
|
||||
node,
|
||||
thickness,
|
||||
scene,
|
||||
...props
|
||||
}: SpacedArmorSceneComponentProps) {
|
||||
const camera = useThree((state) => state.camera);
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.type === ArmorType.Core &&
|
||||
jsxTree(node, {
|
||||
renderOrder: 0,
|
||||
material: omitMaterial,
|
||||
userData: {
|
||||
type: ArmorType.Core,
|
||||
thickness,
|
||||
} satisfies ArmorUserData,
|
||||
|
||||
async onClick(event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const hasCalibratedShells = await hasEquipment(103, true);
|
||||
const hasEnhancedArmor = await hasEquipment(110);
|
||||
const thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1;
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const explosive = isExplosive(shell.type);
|
||||
const splashing = canSplash(shell.type);
|
||||
const penetration =
|
||||
resolveNearPenetration(shell.penetration) *
|
||||
(hasCalibratedShells
|
||||
? isExplosive(shell.type)
|
||||
? 1.1
|
||||
: 1.05
|
||||
: 1);
|
||||
const shot: Shot = {
|
||||
layers: [],
|
||||
point: event.point,
|
||||
}; // TODO: type this
|
||||
let remainingPenetration = penetration;
|
||||
|
||||
const shellNormal = camera.position
|
||||
.clone()
|
||||
.sub(event.point)
|
||||
.normalize();
|
||||
|
||||
pushLogs(shellNormal, filterIntersections(event.intersections), 0);
|
||||
|
||||
function filterIntersections(intersectionsRaw: Intersection[]) {
|
||||
const intersectionsAll = intersectionsRaw.filter(
|
||||
({ object }) => object.userData.type !== undefined,
|
||||
) as ArmorMeshIntersection[];
|
||||
const intersectionsWithDuplicates = intersectionsAll.slice(
|
||||
0,
|
||||
intersectionsAll.findIndex(
|
||||
({ object }) => object.userData.type === ArmorType.Core,
|
||||
) + 1,
|
||||
);
|
||||
const firstGunIntersectionIndex =
|
||||
intersectionsWithDuplicates.findIndex(
|
||||
({ object }) =>
|
||||
object.userData.type === ArmorType.External &&
|
||||
object.userData.variant === 'gun',
|
||||
);
|
||||
const firstTrackIntersectionIndex =
|
||||
intersectionsWithDuplicates.findIndex(
|
||||
({ object }) =>
|
||||
object.userData.type === ArmorType.External &&
|
||||
object.userData.variant === 'track',
|
||||
);
|
||||
let intersections = intersectionsWithDuplicates;
|
||||
|
||||
if (firstGunIntersectionIndex !== -1) {
|
||||
intersections = intersections.filter(({ object }, index) => {
|
||||
return object.userData.type === ArmorType.External &&
|
||||
object.userData.variant === 'gun'
|
||||
? index === firstGunIntersectionIndex
|
||||
: true;
|
||||
});
|
||||
}
|
||||
if (firstTrackIntersectionIndex !== -1) {
|
||||
intersections = intersections.filter(({ object }, index) => {
|
||||
return object.userData.type === ArmorType.External &&
|
||||
object.userData.variant === 'track'
|
||||
? index === firstTrackIntersectionIndex
|
||||
: true;
|
||||
});
|
||||
}
|
||||
|
||||
return intersections;
|
||||
}
|
||||
|
||||
function pushLogs(
|
||||
shellNormal: Vector3,
|
||||
intersections: ArmorMeshIntersection[],
|
||||
startingIndex: number,
|
||||
) {
|
||||
let layerIndex = startingIndex;
|
||||
let loopIndex = 0;
|
||||
|
||||
if (layerIndex !== 0 && intersections.length > 0) {
|
||||
const distance = (
|
||||
shot.layers.at(-1) as ShotLayerBase
|
||||
).point.distanceTo(intersections[0].point);
|
||||
if (explosive && !splashing)
|
||||
remainingPenetration -= 0.5 * remainingPenetration * distance;
|
||||
const blocked = remainingPenetration < 0;
|
||||
|
||||
shot.layers.push({
|
||||
type: null,
|
||||
status: blocked ? 'blocked' : 'penetration',
|
||||
distance,
|
||||
});
|
||||
|
||||
if (blocked) return;
|
||||
}
|
||||
|
||||
for (const intersection of intersections) {
|
||||
const surfaceNormal = intersection
|
||||
.normal!.clone()
|
||||
.applyQuaternion(
|
||||
event.object.getWorldQuaternion(new Quaternion()),
|
||||
);
|
||||
const thickness =
|
||||
thicknessCoefficient * intersection.object.userData.thickness;
|
||||
|
||||
if (layerIndex !== 0 && startingIndex === 0) {
|
||||
const previousIntersection = intersections[layerIndex - 1];
|
||||
const distance =
|
||||
intersection.distance - previousIntersection.distance;
|
||||
if (explosive && !splashing)
|
||||
remainingPenetration -=
|
||||
0.5 * remainingPenetration * distance;
|
||||
const wasted = remainingPenetration < 0;
|
||||
|
||||
shot.layers.push({
|
||||
type: null,
|
||||
status: wasted ? 'blocked' : 'penetration',
|
||||
distance,
|
||||
});
|
||||
|
||||
if (wasted) return;
|
||||
}
|
||||
|
||||
if (intersection.object.userData.type === ArmorType.External) {
|
||||
remainingPenetration -= thickness;
|
||||
const blocked = remainingPenetration < 0;
|
||||
|
||||
shot.layers.push({
|
||||
type: ArmorType.External,
|
||||
index: layerIndex,
|
||||
shellNormal,
|
||||
surfaceNormal,
|
||||
point: intersection.point,
|
||||
thickness: intersection.object.userData.thickness,
|
||||
status: blocked ? 'blocked' : 'penetration',
|
||||
});
|
||||
|
||||
if (blocked) break;
|
||||
} else {
|
||||
const type = intersection.object.userData.type;
|
||||
const angle = surfaceNormal.angleTo(shellNormal);
|
||||
const ricochet = degToRad(shell.ricochet ?? 90);
|
||||
const normalization = degToRad(shell.normalization ?? 0);
|
||||
const threeCalibersRule =
|
||||
shell.caliber > thickness * 3 || layerIndex > 0;
|
||||
const twoCalibersRule = shell.caliber > thickness * 2;
|
||||
const finalNormalization = twoCalibersRule
|
||||
? (1.4 * normalization * shell.caliber) / (2.0 * thickness)
|
||||
: normalization;
|
||||
const finalThickness =
|
||||
thickness === 0
|
||||
? 0
|
||||
: thickness /
|
||||
Math.cos(Math.max(0, angle - finalNormalization));
|
||||
|
||||
if (!threeCalibersRule && angle >= ricochet) {
|
||||
shot.layers.push({
|
||||
type,
|
||||
index: layerIndex,
|
||||
shellNormal,
|
||||
surfaceNormal,
|
||||
point: intersection.point,
|
||||
thickness,
|
||||
thicknessAngled: finalThickness,
|
||||
angle,
|
||||
status: 'ricochet',
|
||||
});
|
||||
|
||||
remainingPenetration *= 0.75;
|
||||
|
||||
const raycaster = new Raycaster();
|
||||
const newShellNormal = shellNormal
|
||||
.clone()
|
||||
.reflect(surfaceNormal);
|
||||
const newCameraNormal = newShellNormal
|
||||
.clone()
|
||||
.multiplyScalar(-1);
|
||||
|
||||
raycaster.set(intersection.point, newCameraNormal);
|
||||
pushLogs(
|
||||
newShellNormal,
|
||||
filterIntersections(
|
||||
raycaster.intersectObjects(scene.children, true),
|
||||
),
|
||||
layerIndex + 1,
|
||||
);
|
||||
|
||||
break;
|
||||
} else {
|
||||
remainingPenetration -= finalThickness;
|
||||
const blocked = remainingPenetration < 0;
|
||||
|
||||
shot.layers.push({
|
||||
type,
|
||||
index: layerIndex,
|
||||
shellNormal,
|
||||
surfaceNormal,
|
||||
point: intersection.point,
|
||||
thickness,
|
||||
thicknessAngled: finalThickness,
|
||||
angle,
|
||||
status: blocked ? 'blocked' : 'penetration',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
layerIndex++;
|
||||
loopIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
useTankopediaTemporary.setState({ shot });
|
||||
},
|
||||
})}
|
||||
|
||||
{props.type === ArmorType.Spaced && (
|
||||
<SpacedArmorSubSpaced node={node} thickness={thickness} />
|
||||
)}
|
||||
|
||||
{props.type === ArmorType.External && (
|
||||
<SpacedArmorSubExternal
|
||||
node={node}
|
||||
thickness={thickness}
|
||||
variant={props.variant}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createPortal, useFrame } from '@react-three/fiber';
|
||||
import { memo, useState } from 'react';
|
||||
import { DepthTexture, Scene, Vector2 } from 'three';
|
||||
import { CoreArmorScene } from './components/CoreArmorScene';
|
||||
import { spacedArmorRenderTarget } from './components/CoreArmorSceneComponent';
|
||||
import { SpacedArmorScene } from './components/SpacedArmorScene';
|
||||
|
||||
export const Armor = memo(() => {
|
||||
const [spacedArmorScene] = useState(() => new Scene());
|
||||
const [coreArmorScene] = useState(() => new Scene());
|
||||
const spacedArmorPortal = createPortal(
|
||||
<SpacedArmorScene scene={spacedArmorScene} />,
|
||||
spacedArmorScene,
|
||||
);
|
||||
const coreArmorPortal = createPortal(<CoreArmorScene />, coreArmorScene);
|
||||
|
||||
const renderSize = new Vector2();
|
||||
const newRenderSize = new Vector2();
|
||||
|
||||
useFrame(({ gl, camera, scene }) => {
|
||||
gl.autoClear = true;
|
||||
|
||||
gl.getSize(newRenderSize).multiplyScalar(gl.getPixelRatio());
|
||||
|
||||
if (!newRenderSize.equals(renderSize)) {
|
||||
renderSize.copy(newRenderSize);
|
||||
spacedArmorRenderTarget.depthTexture = new DepthTexture(
|
||||
renderSize.x,
|
||||
renderSize.y,
|
||||
);
|
||||
}
|
||||
|
||||
spacedArmorRenderTarget.setSize(renderSize.x, renderSize.y);
|
||||
gl.setRenderTarget(spacedArmorRenderTarget);
|
||||
gl.render(spacedArmorScene, camera);
|
||||
|
||||
gl.autoClear = false;
|
||||
|
||||
gl.setRenderTarget(null);
|
||||
gl.render(scene, camera);
|
||||
|
||||
gl.clearDepth();
|
||||
gl.render(coreArmorScene, camera);
|
||||
}, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
{spacedArmorPortal}
|
||||
{coreArmorPortal}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,459 +0,0 @@
|
||||
import { MeshProps, useFrame, useThree } from '@react-three/fiber';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import {
|
||||
IUniform,
|
||||
Mesh,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
ShaderMaterial,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { canRicochet } from '../../../../core/blitz/canRicochet';
|
||||
import { canSplash } from '../../../../core/blitz/canSplash';
|
||||
import { isExplosive } from '../../../../core/blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
|
||||
import { hasEquipment } from '../../../../core/blitzkrieg/hasEquipment';
|
||||
import { useAwait } from '../../../../hooks/useAwait';
|
||||
import { useDuel } from '../../../../stores/duel';
|
||||
import {
|
||||
ArmorPiercingLayer,
|
||||
Shot,
|
||||
mutateTankopediaTemporary,
|
||||
useTankopediaPersistent,
|
||||
} from '../../../../stores/tankopedia';
|
||||
import { externalModuleMaskRenderTarget } from '../ExternalModuleMask';
|
||||
import { spacedArmorDepthRenderTarget } from '../SpacedArmorDepth';
|
||||
import fragmentShader from './shaders/fragment.glsl';
|
||||
import vertexShader from './shaders/vertex.glsl';
|
||||
|
||||
interface ArmorMeshProps extends MeshProps {
|
||||
thickness: number;
|
||||
maxThickness: number;
|
||||
}
|
||||
|
||||
export type ArmorMeshUserData = {
|
||||
type: 'coreArmor' | 'spacedArmor' | 'externalModule';
|
||||
thickness: number;
|
||||
};
|
||||
|
||||
export const ArmorMesh = memo<ArmorMeshProps>(
|
||||
({ thickness, maxThickness, ...props }) => {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const initialShell = useDuel.getState().antagonist!.shell;
|
||||
const initialTankopedia = useTankopediaPersistent.getState();
|
||||
const material = useRef<ShaderMaterial>(null);
|
||||
const explosionCapable = isExplosive(initialShell.type);
|
||||
const ricochetCapable = canRicochet(initialShell.type);
|
||||
const cameraNormal = new Vector3();
|
||||
const shellNormal = new Vector3();
|
||||
const surfaceNormal = new Vector3();
|
||||
const mesh = useRef<Mesh>(null);
|
||||
const scene = useThree((state) => state.scene);
|
||||
const hasCalibratedShells = useAwait(hasEquipment(103, true));
|
||||
const hasEnhancedArmor = useAwait(hasEquipment(110));
|
||||
const wireframe = initialTankopedia.model.visual.wireframe;
|
||||
|
||||
useEffect(() => {
|
||||
async function updateQuickEquipments() {
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const nearPenetration = resolveNearPenetration(shell.penetration);
|
||||
const hasCalibratedShells = await hasEquipment(103, true);
|
||||
const hasEnhancedArmor = await hasEquipment(110);
|
||||
|
||||
if (material.current) {
|
||||
material.current.uniforms.thickness.value =
|
||||
thickness * (hasEnhancedArmor ? 1.04 : 1);
|
||||
material.current.uniforms.penetration.value =
|
||||
nearPenetration *
|
||||
(hasCalibratedShells ? (isExplosive(shell.type) ? 1.1 : 1.05) : 1);
|
||||
}
|
||||
}
|
||||
|
||||
function updateShellProperties() {
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
|
||||
if (material.current) {
|
||||
material.current.uniforms.canRicochet.value = canRicochet(shell.type);
|
||||
material.current.uniforms.isExplosive.value = isExplosive(shell.type);
|
||||
material.current.uniforms.canSplash.value = canSplash(shell.type);
|
||||
material.current.uniforms.caliber.value = shell.caliber;
|
||||
material.current.uniforms.ricochetAngle.value = degToRad(
|
||||
shell.ricochet ?? 90,
|
||||
);
|
||||
material.current.uniforms.damage.value = shell.damage.armor;
|
||||
material.current.uniforms.explosionRadius.value =
|
||||
shell.explosionRadius ?? 0;
|
||||
}
|
||||
|
||||
updateQuickEquipments();
|
||||
}
|
||||
|
||||
const unsubscribes = [
|
||||
useDuel.subscribe(
|
||||
(state) => state.protagonist!.equipment,
|
||||
updateQuickEquipments,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.antagonist!.equipment,
|
||||
updateQuickEquipments,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.antagonist!.shell,
|
||||
updateShellProperties,
|
||||
),
|
||||
useTankopediaPersistent.subscribe(
|
||||
(state) => state.model.visual.greenPenetration,
|
||||
(greenPenetration) => {
|
||||
if (material.current) {
|
||||
material.current.uniforms.greenPenetration.value =
|
||||
greenPenetration;
|
||||
}
|
||||
},
|
||||
),
|
||||
useTankopediaPersistent.subscribe(
|
||||
(state) => state.model.visual.wireframe,
|
||||
(wireframe) => {
|
||||
if (material.current) {
|
||||
material.current.wireframe = wireframe;
|
||||
material.current.uniforms.wireframe.value = wireframe;
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame(({ gl, camera }) => {
|
||||
if (material.current) {
|
||||
gl.getSize(material.current.uniforms.resolution.value);
|
||||
|
||||
(
|
||||
material.current.uniforms.resolution as IUniform<Vector2>
|
||||
).value.multiplyScalar(gl.getPixelRatio());
|
||||
|
||||
material.current.uniforms.externalModuleMask.value =
|
||||
externalModuleMaskRenderTarget.texture;
|
||||
material.current.uniforms.spacedArmorDepth.value =
|
||||
spacedArmorDepthRenderTarget.depthTexture;
|
||||
material.current.uniforms.spacedArmorMask.value =
|
||||
spacedArmorDepthRenderTarget.texture;
|
||||
material.current.uniforms.projectionMatrixInverse.value =
|
||||
camera.projectionMatrixInverse;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<mesh {...props} renderOrder={0}>
|
||||
<meshBasicMaterial colorWrite={false} />
|
||||
</mesh>
|
||||
|
||||
<mesh
|
||||
{...props}
|
||||
renderOrder={1}
|
||||
userData={
|
||||
{
|
||||
type: 'coreArmor',
|
||||
thickness,
|
||||
} satisfies ArmorMeshUserData
|
||||
}
|
||||
ref={mesh}
|
||||
onClick={async (event) => {
|
||||
// if (useTankopediaTemporary.getState().mode !== 'armor') return;
|
||||
|
||||
shellNormal.copy(
|
||||
cameraNormal.copy(camera.position).sub(event.point).normalize(),
|
||||
);
|
||||
surfaceNormal
|
||||
.copy(event.normal!)
|
||||
.applyQuaternion(
|
||||
event.object.getWorldQuaternion(new Quaternion()),
|
||||
);
|
||||
const angle = Math.acos(surfaceNormal.dot(shellNormal));
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const hasCalibratedShells = await hasEquipment(103, true);
|
||||
const resolvedPenetration =
|
||||
resolveNearPenetration(shell.penetration) *
|
||||
(hasCalibratedShells
|
||||
? isExplosive(shell.type)
|
||||
? 1.1
|
||||
: 1.05
|
||||
: 1);
|
||||
const indexedObjects = new Set<ArmorMeshUserData['type']>();
|
||||
const intersections = event.intersections
|
||||
.filter(({ object }) => Boolean(object.userData.type))
|
||||
.map((event) => {
|
||||
const typedEvent = event as typeof event & {
|
||||
object: (typeof event)['object'] & {
|
||||
userData: ArmorMeshUserData;
|
||||
};
|
||||
};
|
||||
|
||||
return typedEvent;
|
||||
})
|
||||
.filter(({ object }) => {
|
||||
if (indexedObjects.has(object.userData.type)) return false;
|
||||
indexedObjects.add(object.userData.type);
|
||||
return true;
|
||||
});
|
||||
const intersectionsTillFirstCoreArmor = intersections.slice(
|
||||
0,
|
||||
intersections.findIndex(
|
||||
({ object }) => object.userData.type === 'coreArmor',
|
||||
) + 1,
|
||||
);
|
||||
const coreArmorMesh =
|
||||
intersectionsTillFirstCoreArmor.at(-1)!.object;
|
||||
const hasEnhancedArmor = await hasEquipment(110);
|
||||
const thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1;
|
||||
|
||||
if (mesh.current !== (coreArmorMesh as unknown as Mesh)) return;
|
||||
|
||||
let hasRicochet = false;
|
||||
const armorPiercingLayersRaw = intersectionsTillFirstCoreArmor
|
||||
.map((event, index) => {
|
||||
if (hasRicochet) return null;
|
||||
|
||||
switch (event.object.userData.type) {
|
||||
case 'externalModule': {
|
||||
return {
|
||||
nominal:
|
||||
event.object.userData.thickness * thicknessCoefficient,
|
||||
angled:
|
||||
event.object.userData.thickness * thicknessCoefficient,
|
||||
type: 'external',
|
||||
distance: event.distance,
|
||||
ricochet: false,
|
||||
} satisfies ArmorPiercingLayer;
|
||||
}
|
||||
|
||||
case 'spacedArmor':
|
||||
case 'coreArmor': {
|
||||
const threeCalibersRule =
|
||||
shell.caliber >
|
||||
3 *
|
||||
(event.object.userData.thickness *
|
||||
thicknessCoefficient);
|
||||
|
||||
if (
|
||||
index === 0 &&
|
||||
canRicochet(shell.type) &&
|
||||
!threeCalibersRule &&
|
||||
angle >= degToRad(shell.ricochet!)
|
||||
) {
|
||||
hasRicochet = true;
|
||||
}
|
||||
|
||||
const twoCalibersRule =
|
||||
shell.caliber >
|
||||
2 *
|
||||
(event.object.userData.thickness *
|
||||
thicknessCoefficient);
|
||||
const normalization =
|
||||
twoCalibersRule && event.object.userData.thickness > 0
|
||||
? ((shell.normalization ?? 0) * 1.4 * shell.caliber) /
|
||||
(2 *
|
||||
event.object.userData.thickness *
|
||||
thicknessCoefficient)
|
||||
: shell.normalization ?? 0;
|
||||
const angled =
|
||||
(event.object.userData.thickness * thicknessCoefficient) /
|
||||
Math.cos(angle - degToRad(normalization));
|
||||
|
||||
return {
|
||||
ricochet: hasRicochet,
|
||||
nominal:
|
||||
event.object.userData.thickness * thicknessCoefficient,
|
||||
angled,
|
||||
type:
|
||||
event.object.userData.type === 'coreArmor'
|
||||
? 'core'
|
||||
: 'spaced',
|
||||
distance: event.distance,
|
||||
} satisfies ArmorPiercingLayer;
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
const gaps = armorPiercingLayersRaw.map((layerA, aIndex) => {
|
||||
if (aIndex === armorPiercingLayersRaw.length - 1) return null;
|
||||
const layerB = armorPiercingLayersRaw[aIndex + 1];
|
||||
|
||||
return {
|
||||
type: 'gap',
|
||||
gap: Math.abs(layerB.distance - layerA.distance),
|
||||
} satisfies ArmorPiercingLayer;
|
||||
});
|
||||
const armorPiercingLayers: ArmorPiercingLayer[] = [];
|
||||
|
||||
armorPiercingLayersRaw.forEach((layer, index) => {
|
||||
const gap = gaps[index];
|
||||
|
||||
armorPiercingLayers.push(layer);
|
||||
if (gap) armorPiercingLayers.push(gap);
|
||||
});
|
||||
|
||||
let remainingPenetration = resolvedPenetration;
|
||||
const explosiveCapable = isExplosive(shell.type);
|
||||
|
||||
armorPiercingLayers.forEach((layer) => {
|
||||
if (layer.type !== 'gap') {
|
||||
if (layer.ricochet) {
|
||||
remainingPenetration *= 0.75;
|
||||
} else {
|
||||
remainingPenetration -= layer.angled;
|
||||
}
|
||||
} else if (explosiveCapable) {
|
||||
// there is a 50% penetration loss per meter for HE based shells
|
||||
remainingPenetration -= 0.5 * remainingPenetration * layer.gap;
|
||||
}
|
||||
|
||||
remainingPenetration = Math.max(0, remainingPenetration);
|
||||
});
|
||||
|
||||
const hasPenetrated = remainingPenetration > 0;
|
||||
let ricochet: Shot['ricochet'] = undefined;
|
||||
|
||||
if (hasRicochet) {
|
||||
const raycaster = new Raycaster();
|
||||
const bounceNormal = shellNormal
|
||||
.clone()
|
||||
.multiplyScalar(-1)
|
||||
.sub(
|
||||
surfaceNormal
|
||||
.clone()
|
||||
.multiplyScalar(
|
||||
2 *
|
||||
surfaceNormal.dot(
|
||||
shellNormal.clone().multiplyScalar(-1),
|
||||
),
|
||||
),
|
||||
);
|
||||
raycaster.set(event.point, bounceNormal);
|
||||
const intersections = raycaster
|
||||
.intersectObjects(scene.children, true)
|
||||
.filter(
|
||||
(intersection) =>
|
||||
typeof intersection.object.userData?.type === 'string',
|
||||
);
|
||||
const indexOfCoreArmor = intersections.findIndex(
|
||||
({ object }) => object.userData.type === 'coreArmor',
|
||||
);
|
||||
const intersectionsTillFirstCoreArmor = intersections.slice(
|
||||
0,
|
||||
indexOfCoreArmor + 1,
|
||||
);
|
||||
|
||||
if (intersectionsTillFirstCoreArmor.length > 0) {
|
||||
intersectionsTillFirstCoreArmor.forEach((intersection) => {
|
||||
switch (intersection.object.userData.type) {
|
||||
case 'externalModule': {
|
||||
remainingPenetration -=
|
||||
intersection.object.userData.thickness;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'spacedArmor':
|
||||
case 'coreArmor': {
|
||||
const angle = bounceNormal.angleTo(
|
||||
intersection.object.position.clone().sub(event.point),
|
||||
);
|
||||
const twoCalibersRule =
|
||||
shell.caliber >
|
||||
2 *
|
||||
(intersection.object.userData.thickness *
|
||||
thicknessCoefficient);
|
||||
const normalization = twoCalibersRule
|
||||
? ((shell.normalization ?? 0) * 1.4 * shell.caliber) /
|
||||
(2 *
|
||||
intersection.object.userData.thickness *
|
||||
thicknessCoefficient)
|
||||
: shell.normalization ?? 0;
|
||||
const angled =
|
||||
(intersection.object.userData.thickness *
|
||||
thicknessCoefficient) /
|
||||
Math.cos(angle - degToRad(normalization));
|
||||
|
||||
remainingPenetration -= angled;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ricochet = {
|
||||
point: intersectionsTillFirstCoreArmor
|
||||
.at(-1)!
|
||||
.point.toArray(),
|
||||
distance: intersectionsTillFirstCoreArmor.at(-1)!.distance,
|
||||
penetration: remainingPenetration > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = {
|
||||
type: hasRicochet
|
||||
? 'ricochet'
|
||||
: hasPenetrated
|
||||
? 'penetration'
|
||||
: 'block',
|
||||
point: event.point.toArray(),
|
||||
thicknesses: armorPiercingLayers,
|
||||
shellNormal: shellNormal.toArray(),
|
||||
surfaceNormal: surfaceNormal.toArray(),
|
||||
angle,
|
||||
ricochet,
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
<shaderMaterial
|
||||
wireframe={wireframe}
|
||||
ref={material}
|
||||
fragmentShader={fragmentShader}
|
||||
vertexShader={vertexShader}
|
||||
transparent
|
||||
depthWrite={false}
|
||||
uniforms={{
|
||||
resolution: { value: new Vector2() },
|
||||
externalModuleMask: { value: null },
|
||||
spacedArmorDepth: { value: null },
|
||||
spacedArmorMask: { value: null },
|
||||
projectionMatrixInverse: { value: null },
|
||||
zNear: { value: camera.near },
|
||||
zFar: { value: camera.far },
|
||||
greenPenetration: {
|
||||
value: initialTankopedia.model.visual.greenPenetration,
|
||||
},
|
||||
maxThickness: { value: maxThickness },
|
||||
isExplosive: { value: explosionCapable },
|
||||
canRicochet: { value: ricochetCapable },
|
||||
canSplash: { value: canSplash(initialShell.type) },
|
||||
thickness: {
|
||||
value: thickness * (hasEnhancedArmor ? 1.04 : 1),
|
||||
},
|
||||
penetration: {
|
||||
value:
|
||||
resolveNearPenetration(initialShell.penetration) *
|
||||
(hasCalibratedShells ? (explosionCapable ? 1.1 : 1.05) : 1),
|
||||
},
|
||||
caliber: { value: initialShell.caliber },
|
||||
ricochetAngle: { value: degToRad(initialShell.ricochet ?? 90) },
|
||||
normalization: {
|
||||
value: degToRad(initialShell.normalization ?? 0),
|
||||
},
|
||||
damage: { value: initialShell.damage.armor },
|
||||
explosionRadius: { value: initialShell.explosionRadius ?? 0 },
|
||||
wireframe: { value: wireframe },
|
||||
}}
|
||||
/>
|
||||
</mesh>
|
||||
</>
|
||||
);
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
@@ -1,120 +0,0 @@
|
||||
#define PI 3.1415926535897932384626433832795
|
||||
|
||||
varying vec3 vViewPosition;
|
||||
varying vec3 vNormal;
|
||||
uniform bool isExplosive;
|
||||
uniform bool canRicochet;
|
||||
uniform bool canSplash;
|
||||
uniform float thickness;
|
||||
uniform float penetration;
|
||||
uniform float caliber;
|
||||
uniform float ricochetAngle;
|
||||
uniform float normalization;
|
||||
uniform sampler2D externalModuleMask;
|
||||
uniform highp sampler2D spacedArmorDepth;
|
||||
uniform sampler2D spacedArmorMask;
|
||||
uniform vec2 resolution;
|
||||
varying mat4 vProjectionMatrix;
|
||||
uniform float maxThickness;
|
||||
uniform bool greenPenetration;
|
||||
uniform float damage;
|
||||
uniform float explosionRadius;
|
||||
uniform bool wireframe;
|
||||
|
||||
float depthToDistance(float depth) {
|
||||
mat4 projectionMatrixInverse = inverse(vProjectionMatrix);
|
||||
vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
|
||||
vec4 eyePosition = projectionMatrixInverse * clipPosition;
|
||||
vec3 NDCPosition = eyePosition.xyz / eyePosition.w;
|
||||
float worldDistance = length(NDCPosition);
|
||||
|
||||
return worldDistance;
|
||||
}
|
||||
|
||||
float angleTo(vec3 a, vec3 b) {
|
||||
return acos(dot(a, b) / (length(a) * length(b)));
|
||||
}
|
||||
|
||||
void main() {
|
||||
float penetrationChance = -1.0;
|
||||
float splashChance = -1.0;
|
||||
|
||||
vec2 screenCoordinates = gl_FragCoord.xy / resolution;
|
||||
vec4 externalModuleMaskColor = texture2D(externalModuleMask, screenCoordinates);
|
||||
vec4 spacedArmorMaskColor = texture2D(spacedArmorMask, screenCoordinates);
|
||||
bool isUnderExternalModule = externalModuleMaskColor.r == 1.0;
|
||||
bool isUnderSpacedArmor = spacedArmorMaskColor.a == 1.0;
|
||||
float spacedArmorAngle = spacedArmorMaskColor.r * (PI / 2.0);
|
||||
|
||||
float coreArmorAngle = angleTo(vNormal, -vViewPosition);
|
||||
bool coreArmorThreeCalibersRule = caliber > 3.0 * thickness;
|
||||
float spacedArmorNominalThickness = spacedArmorMaskColor.g * maxThickness;
|
||||
bool spacedArmorThreeCalibersRule = isUnderSpacedArmor && caliber > 3.0 * spacedArmorNominalThickness;
|
||||
bool hasRicochetedSpacedArmor = isUnderSpacedArmor && !spacedArmorThreeCalibersRule && spacedArmorAngle >= ricochetAngle;
|
||||
bool hasRicochetedCoreArmor = !hasRicochetedSpacedArmor && !isUnderExternalModule && canRicochet && !coreArmorThreeCalibersRule && coreArmorAngle >= ricochetAngle;
|
||||
|
||||
if (hasRicochetedCoreArmor || hasRicochetedSpacedArmor) {
|
||||
penetrationChance = 0.0;
|
||||
splashChance = 0.0;
|
||||
} else {
|
||||
// time to chip away at the penetration, one case at a time
|
||||
float remainingPenetration = penetration;
|
||||
|
||||
if (isUnderExternalModule) {
|
||||
// external modules don't care about angle, they'll reduce penetration by their thickness
|
||||
float externalModuleThickness = externalModuleMaskColor.g * maxThickness;
|
||||
remainingPenetration -= externalModuleThickness;
|
||||
}
|
||||
|
||||
if (isUnderSpacedArmor) {
|
||||
// spaced armor on the other hand, does care about angle
|
||||
bool spacedArmorTwoCalibersRule = caliber > 2.0 * spacedArmorNominalThickness && spacedArmorNominalThickness > 0.0;
|
||||
float finalSpacedArmorNormalization = spacedArmorTwoCalibersRule ? (normalization * 1.4 * caliber) / (2.0 * spacedArmorNominalThickness) : normalization;
|
||||
float finalSpacedArmorThickness = spacedArmorNominalThickness / cos(spacedArmorAngle - finalSpacedArmorNormalization);
|
||||
remainingPenetration -= finalSpacedArmorThickness;
|
||||
}
|
||||
remainingPenetration = max(remainingPenetration, 0.0);
|
||||
|
||||
float spacedArmorDepth = texture2D(spacedArmorDepth, screenCoordinates).r;
|
||||
float spacedArmorDistance = depthToDistance(spacedArmorDepth);
|
||||
float coreArmorDistance = depthToDistance(gl_FragCoord.z);
|
||||
float distanceFromSpacedArmor = abs(coreArmorDistance - spacedArmorDistance);
|
||||
if (isExplosive) {
|
||||
// there is a 50% penetration loss per meter for HE based shells
|
||||
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
|
||||
}
|
||||
|
||||
bool coreArmorTwoCalibersRule = caliber > 2.0 * thickness && thickness > 0.0;
|
||||
float finalNormalization = coreArmorTwoCalibersRule ? (normalization * 1.4 * caliber) / (2.0 * thickness) : normalization;
|
||||
float finalCoreArmorThickness = thickness / cos(coreArmorAngle - finalNormalization);
|
||||
float deltaPenetration = finalCoreArmorThickness - remainingPenetration;
|
||||
float randomRadius = remainingPenetration * 0.05;
|
||||
|
||||
if (deltaPenetration > randomRadius) {
|
||||
// even with a 5% buff to the penetration, can't penetrate
|
||||
penetrationChance = 0.0;
|
||||
} else if (deltaPenetration < -randomRadius) {
|
||||
// even with a 5% nerf, can penetrate
|
||||
penetrationChance = 1.0;
|
||||
} else {
|
||||
// transition area
|
||||
penetrationChance = max(1.0 - (deltaPenetration + randomRadius) / (2.0 * randomRadius), 0.0);
|
||||
}
|
||||
|
||||
if (canSplash) {
|
||||
// only allow splasing if the damage equation deals more than 0 damage
|
||||
float nominalArmorThickness = finalCoreArmorThickness - remainingPenetration;
|
||||
float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * nominalArmorThickness;
|
||||
splashChance = finalDamage > 0.0 ? 1.0 : 0.0;
|
||||
} else {
|
||||
splashChance = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
vec3 color = vec3(1.0, splashChance * 0.392, 0.0);
|
||||
if (greenPenetration || wireframe) {
|
||||
gl_FragColor = vec4(mix(color, vec3(0.0, 1.0, 0.0), penetrationChance), wireframe ? 1.0 : 0.5);
|
||||
} else {
|
||||
gl_FragColor = vec4(color, (1.0 - penetrationChance) * 0.5);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Color, MeshBasicMaterial, Object3D, WebGLRenderTarget } from 'three';
|
||||
import { jsxTree } from '../../../core/blitzkrieg/jsxTree';
|
||||
import { ArmorMeshUserData } from './Armor';
|
||||
|
||||
export const externalModuleMaskRenderTarget = new WebGLRenderTarget();
|
||||
|
||||
type ArmorMeshExternalModuleMaskProps = {
|
||||
ornamental?: boolean;
|
||||
node: Object3D;
|
||||
} & (
|
||||
| {
|
||||
exclude?: false;
|
||||
maxThickness: number;
|
||||
thickness: number;
|
||||
}
|
||||
| {
|
||||
exclude: true;
|
||||
}
|
||||
);
|
||||
|
||||
const excludeMaterial = new MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
});
|
||||
|
||||
export function ArmorMeshExternalModuleMask({
|
||||
ornamental = false,
|
||||
node,
|
||||
...props
|
||||
}: ArmorMeshExternalModuleMaskProps) {
|
||||
return (
|
||||
<>
|
||||
{props.exclude &&
|
||||
!ornamental &&
|
||||
jsxTree(node, { renderOrder: 0, material: excludeMaterial })}
|
||||
|
||||
{!props.exclude &&
|
||||
jsxTree(node, {
|
||||
renderOrder: 1,
|
||||
onClick: ornamental ? () => {} : undefined,
|
||||
userData: {
|
||||
type: 'externalModule',
|
||||
thickness: props.thickness,
|
||||
} satisfies ArmorMeshUserData,
|
||||
material: new MeshBasicMaterial({
|
||||
color: new Color(1, props.thickness / props.maxThickness, 0),
|
||||
depthWrite: false,
|
||||
colorWrite: !ornamental,
|
||||
}),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import {
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
ShaderMaterial,
|
||||
WebGLRenderTarget,
|
||||
} from 'three';
|
||||
import { jsxTree } from '../../../../core/blitzkrieg/jsxTree';
|
||||
import { ArmorMeshUserData } from '../Armor';
|
||||
import fragmentShader from './shaders/fragment.glsl';
|
||||
import vertexShader from './shaders/vertex.glsl';
|
||||
|
||||
export const spacedArmorDepthRenderTarget = new WebGLRenderTarget();
|
||||
|
||||
type ArmorMeshSpacedArmorDepthProps = {
|
||||
ornamental?: boolean;
|
||||
node: Object3D;
|
||||
} & (
|
||||
| {
|
||||
include?: false;
|
||||
isExternalModule: boolean;
|
||||
}
|
||||
| {
|
||||
include: true;
|
||||
thickness: number;
|
||||
maxThickness: number;
|
||||
}
|
||||
);
|
||||
|
||||
const excludeMaterial = new MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
});
|
||||
const includeMaterial = new MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
export function ArmorMeshSpacedArmorDepth({
|
||||
ornamental = false,
|
||||
node,
|
||||
...props
|
||||
}: ArmorMeshSpacedArmorDepthProps) {
|
||||
return (
|
||||
<>
|
||||
{!props.include &&
|
||||
!ornamental &&
|
||||
jsxTree(node, {
|
||||
renderOrder: props.isExternalModule ? 2 : 0,
|
||||
material: excludeMaterial,
|
||||
})}
|
||||
|
||||
{props.include &&
|
||||
jsxTree(node, {
|
||||
renderOrder: 1,
|
||||
onClick: ornamental ? () => {} : undefined,
|
||||
userData: {
|
||||
type: 'spacedArmor',
|
||||
thickness: props.thickness,
|
||||
} satisfies ArmorMeshUserData,
|
||||
material: ornamental
|
||||
? includeMaterial
|
||||
: new ShaderMaterial({
|
||||
fragmentShader,
|
||||
vertexShader,
|
||||
uniforms: {
|
||||
thickness: { value: props.thickness },
|
||||
maxThickness: { value: props.maxThickness },
|
||||
},
|
||||
}),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
#define PI 3.1415926535897932384626433832795
|
||||
|
||||
varying vec3 vViewPosition;
|
||||
varying vec3 vNormal;
|
||||
uniform float thickness;
|
||||
uniform float maxThickness;
|
||||
|
||||
void main() {
|
||||
float angle = acos(dot(vNormal, -normalize(vViewPosition)));
|
||||
gl_FragColor = vec4(angle / (PI / 2.0), thickness / maxThickness, 0.0, 1.0);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './components/Armor';
|
||||
export * from './components/ExternalModuleMask';
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
GrayColor,
|
||||
PALETTES,
|
||||
} from '../../../constants/radixColors';
|
||||
import { TREE_TYPE_ICONS, TankType, TreeType } from '../../Tanks';
|
||||
import { TREE_TYPE_ICONS, TankClass, TreeType } from '../../Tanks';
|
||||
import { RowStat } from './RowStat';
|
||||
|
||||
export interface RowStatItem {
|
||||
@@ -22,7 +22,7 @@ interface RowProps {
|
||||
title: string;
|
||||
minimized?: boolean;
|
||||
treeType?: TreeType;
|
||||
tankType?: TankType;
|
||||
tankType?: TankClass;
|
||||
color?: AccentColor | GrayColor;
|
||||
stats: (RowStatItem | undefined)[];
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function RowStat({
|
||||
<span
|
||||
style={{
|
||||
color: theme.textHighContrast,
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
export interface WrapperProps {
|
||||
interface CommandWrapperProps {
|
||||
children: ReactNode;
|
||||
naked?: boolean;
|
||||
fat?: boolean;
|
||||
}
|
||||
|
||||
export default function Wrapper({ children, naked, fat }: WrapperProps) {
|
||||
export default function CommandWrapper({ children, fat }: CommandWrapperProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: `16px ${fat ? 0 : 16}px`,
|
||||
gap: 32,
|
||||
gap: 16,
|
||||
width: 480,
|
||||
color: theme.colors.textHighContrast,
|
||||
|
||||
...(!naked && {
|
||||
background: 'url(https://i.imgur.com/PhS06NJ.png)',
|
||||
}),
|
||||
background: 'url(https://i.imgur.com/PhS06NJ.png)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Locale } from 'discord.js';
|
||||
import { AllStats, SupplementaryStats } from '../../core/blitz/getAccountInfo';
|
||||
import { translator } from '../../core/localization/translator';
|
||||
import isNumber from '../../core/math/isNumber';
|
||||
import getWN8Percentile from '../../core/statistics/getWN8Percentile';
|
||||
import { Card } from './components/Card';
|
||||
@@ -8,12 +10,15 @@ import { Row } from './components/Row';
|
||||
export interface GenericAllStatsProps {
|
||||
stats: AllStats;
|
||||
supplementaryStats?: SupplementaryStats;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function GenericAllStats({
|
||||
stats,
|
||||
supplementaryStats,
|
||||
locale,
|
||||
}: GenericAllStatsProps) {
|
||||
const { t } = translator(locale);
|
||||
const damageRatio = stats.damage_dealt / stats.damage_received;
|
||||
const killsToDeathRatio =
|
||||
stats.frags / (stats.battles - stats.survived_battles);
|
||||
@@ -22,27 +27,27 @@ export default function GenericAllStats({
|
||||
<Root>
|
||||
<Row>
|
||||
<Card
|
||||
title="Games"
|
||||
title={t`bot.commands.full_stats.body.cards.games`}
|
||||
items={[
|
||||
{
|
||||
label: 'Winrate',
|
||||
label: t`bot.commands.full_stats.body.cards.games.winrate`,
|
||||
value: `${(100 * (stats.wins / stats.battles)).toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
label: 'Battles',
|
||||
label: t`bot.commands.full_stats.body.cards.games.battles`,
|
||||
value: stats.battles,
|
||||
},
|
||||
{
|
||||
label: 'Wins / Losses',
|
||||
label: t`bot.commands.full_stats.body.cards.games.wins_losses`,
|
||||
value: `${stats.wins} / ${stats.losses}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Card
|
||||
title="Efficiency"
|
||||
title={t`bot.commands.full_stats.body.cards.efficiency`}
|
||||
items={[
|
||||
{
|
||||
label: 'WN8',
|
||||
label: t`bot.commands.full_stats.body.cards.efficiency.wn8`,
|
||||
value:
|
||||
supplementaryStats && isNumber(supplementaryStats.WN8)
|
||||
? supplementaryStats.WN8!.toFixed(0)
|
||||
@@ -52,14 +57,14 @@ export default function GenericAllStats({
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: 'Survival',
|
||||
label: t`bot.commands.full_stats.body.cards.efficiency.survival`,
|
||||
value: `${(
|
||||
100 *
|
||||
(stats.survived_battles / stats.battles)
|
||||
).toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
label: 'Accuracy',
|
||||
label: t`bot.commands.full_stats.body.cards.efficiency.accuracy`,
|
||||
value: `${((stats.hits / stats.shots) * 100).toFixed(2)}%`,
|
||||
},
|
||||
]}
|
||||
@@ -67,14 +72,14 @@ export default function GenericAllStats({
|
||||
</Row>
|
||||
<Row>
|
||||
<Card
|
||||
title="Lethality"
|
||||
title={t`bot.commands.full_stats.body.cards.lethality`}
|
||||
items={[
|
||||
{
|
||||
label: 'Average damage',
|
||||
label: t`bot.commands.full_stats.body.cards.lethality.average_damage`,
|
||||
value: (stats.damage_dealt / stats.battles).toFixed(0),
|
||||
},
|
||||
{
|
||||
label: 'Damage per tier',
|
||||
label: t`bot.commands.full_stats.body.cards.lethality.damage_per_tier`,
|
||||
value: supplementaryStats?.tier
|
||||
? (
|
||||
stats.damage_dealt /
|
||||
@@ -84,32 +89,32 @@ export default function GenericAllStats({
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: 'Damage ratio',
|
||||
label: t`bot.commands.full_stats.body.cards.lethality.damage_ratio`,
|
||||
value: isFinite(damageRatio) ? damageRatio.toFixed(2) : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Average kills',
|
||||
label: t`bot.commands.full_stats.body.cards.lethality.average_kills`,
|
||||
value: (stats.frags / stats.battles).toFixed(0),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Card
|
||||
title="Miscellaneous"
|
||||
title={t`bot.commands.full_stats.body.cards.miscellaneous`}
|
||||
items={[
|
||||
{
|
||||
label: 'Average tier',
|
||||
label: t`bot.commands.full_stats.body.cards.miscellaneous.average_tier`,
|
||||
value: supplementaryStats?.tier?.toFixed(2),
|
||||
},
|
||||
{
|
||||
label: 'Average spots',
|
||||
label: t`bot.commands.full_stats.body.cards.miscellaneous.average_spots`,
|
||||
value: (stats.spotted / stats.battles).toFixed(2),
|
||||
},
|
||||
{
|
||||
label: 'Average XP',
|
||||
label: t`bot.commands.full_stats.body.cards.miscellaneous.average_xp`,
|
||||
value: (stats.xp / stats.battles).toFixed(0),
|
||||
},
|
||||
{
|
||||
label: 'Kills to death ratio',
|
||||
label: t`bot.commands.full_stats.body.cards.miscellaneous.kills_to_death_ratio`,
|
||||
value: isFinite(killsToDeathRatio)
|
||||
? killsToDeathRatio.toFixed(2)
|
||||
: undefined,
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function GenericStatsRow({ stat }: GenericStatsRowProps) {
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { createPortal, useFrame, useThree } from '@react-three/fiber';
|
||||
import { isInteger } from 'lodash';
|
||||
import { ReactNode } from 'react';
|
||||
import { Scene } from 'three';
|
||||
|
||||
interface HeadsUpDisplayProps {
|
||||
priority?: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
/**
|
||||
* Renders `children` above the default scene, even if the priority is a
|
||||
* negative number.
|
||||
*/
|
||||
export function HeadsUpDisplay({
|
||||
priority = 1,
|
||||
children,
|
||||
}: HeadsUpDisplayProps) {
|
||||
const { gl: renderer, scene: defaultScene, camera } = useThree();
|
||||
const scene = new Scene();
|
||||
const portal = createPortal(children, scene, {
|
||||
events: { priority: priority + 1 },
|
||||
});
|
||||
|
||||
useFrame(() => {
|
||||
if (priority === 1) {
|
||||
renderer.clear();
|
||||
renderer.render(defaultScene, camera);
|
||||
}
|
||||
|
||||
renderer.clearDepth();
|
||||
renderer.render(scene, camera);
|
||||
}, priority);
|
||||
|
||||
renderer.autoClear = false;
|
||||
if (!isInteger(priority)) {
|
||||
throw new TypeError('Priority property must be an integer');
|
||||
}
|
||||
|
||||
return portal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this component if the default scene disappears on usage of the
|
||||
* HeadsUpDisplay; caused because there are not HeadsUpDisplay components
|
||||
* with `1` priority.
|
||||
*/
|
||||
export function RenderDefault() {
|
||||
const { gl: renderer, scene, camera } = useThree();
|
||||
|
||||
useFrame(() => renderer.render(scene, camera));
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { MeshProps } from '@react-three/fiber';
|
||||
import { forwardRef } from 'react';
|
||||
import { DoubleSide, Mesh } from 'three';
|
||||
|
||||
interface InfiniteGridHelperProps extends MeshProps {
|
||||
size1?: number;
|
||||
size2?: number;
|
||||
color?: THREE.Color;
|
||||
distance?: number;
|
||||
}
|
||||
/**
|
||||
* Thanks, Fyrestar, for [THREE.InfiniteGridHelper](https://github.com/Fyrestar/THREE.InfiniteGridHelper/)!
|
||||
*/
|
||||
const InfiniteGridHelper = forwardRef<Mesh, InfiniteGridHelperProps>(
|
||||
({ size1, size2, color, distance, ...props }, ref) => {
|
||||
return (
|
||||
<mesh {...props} ref={ref} frustumCulled={false}>
|
||||
<shaderMaterial
|
||||
args={[
|
||||
{
|
||||
side: DoubleSide,
|
||||
uniforms: {
|
||||
uSize1: { value: size1 },
|
||||
uSize2: { value: size2 },
|
||||
uColor: { value: color },
|
||||
uDistance: { value: distance },
|
||||
},
|
||||
extensions: { derivatives: true },
|
||||
transparent: true,
|
||||
|
||||
vertexShader: `
|
||||
varying vec3 worldPosition;
|
||||
uniform float uDistance;
|
||||
|
||||
void main() {
|
||||
vec3 pos = position.xzy * uDistance;
|
||||
worldPosition = pos;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
|
||||
}
|
||||
`,
|
||||
|
||||
fragmentShader: `
|
||||
varying vec3 worldPosition;
|
||||
uniform float uSize1;
|
||||
uniform float uSize2;
|
||||
uniform vec3 uColor;
|
||||
uniform float uDistance;
|
||||
vec2 origin = vec2(0.0, 0.0);
|
||||
|
||||
float getGrid(float size) {
|
||||
vec2 r = worldPosition.xz / size;
|
||||
vec2 grid = abs(fract(r - 0.5) - 0.5) / fwidth(r);
|
||||
float line = min(grid.x, grid.y);
|
||||
|
||||
return (1.0 - min(line, 1.0)) * max(0.0, min(1.0, cameraPosition.y / 5.0));
|
||||
}
|
||||
|
||||
void main() {
|
||||
float d = 1.0 - min(distance(origin, worldPosition.xz) / uDistance, 1.0);
|
||||
float g1 = getGrid(uSize1);
|
||||
float g2 = getGrid(uSize2);
|
||||
gl_FragColor = vec4(uColor.rgb, mix(g2, g1, g1) * pow(d, 10.0));
|
||||
gl_FragColor.a = mix(0.5 * gl_FragColor.a, gl_FragColor.a, g2);
|
||||
|
||||
if ( gl_FragColor.a <= 0.0 ) discard;
|
||||
}
|
||||
`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<planeGeometry />
|
||||
</mesh>
|
||||
);
|
||||
},
|
||||
);
|
||||
export default InfiniteGridHelper;
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Locale } from 'discord.js';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
export enum NoDataType {
|
||||
@@ -7,16 +9,13 @@ export enum NoDataType {
|
||||
}
|
||||
|
||||
export interface NoDataProps {
|
||||
type: NoDataType;
|
||||
type: 'battles_in_period' | 'players_in_period' | 'tanks_found';
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
const NO_DATA_MESSAGES: Record<NoDataType, string> = {
|
||||
[NoDataType.BattlesInPeriod]: 'No battles played in this period',
|
||||
[NoDataType.PlayersInPeriod]: 'No players in this period',
|
||||
[NoDataType.TanksFound]: 'No tanks found',
|
||||
};
|
||||
export default function NoData({ type, locale }: NoDataProps) {
|
||||
const { translate } = translator(locale);
|
||||
|
||||
export default function NoData({ type }: NoDataProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -26,7 +25,7 @@ export default function NoData({ type }: NoDataProps) {
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16, color: theme.colors.textLowContrast }}>
|
||||
{NO_DATA_MESSAGES[type]}
|
||||
{translate(`bot.common.no_data.${type}`)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import { Card, Flex, Text } from '@radix-ui/themes';
|
||||
import { Html } from '@react-three/drei';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { ReactNode, useEffect, useRef } from 'react';
|
||||
import { Group, Mesh, Vector3 } from 'three';
|
||||
import { radToDeg } from 'three/src/math/MathUtils';
|
||||
import { isExplosive } from '../core/blitz/isExplosive';
|
||||
import { AngledPenetration } from '../icons/AngledPenetration';
|
||||
import { Block } from '../icons/Block';
|
||||
import { NominalPenetration } from '../icons/NominalPenetration';
|
||||
import { Ricochet } from '../icons/Ricochet';
|
||||
import { useDuel } from '../stores/duel';
|
||||
import { Shot, useTankopediaTemporary } from '../stores/tankopedia';
|
||||
|
||||
export const SHOT_NAMES: Record<Shot['type'], string> = {
|
||||
ricochet: 'Ricochet',
|
||||
penetration: 'Penetration',
|
||||
block: 'Blocked',
|
||||
};
|
||||
|
||||
export const SHOT_ICONS: Record<Shot['type'], ReactNode> = {
|
||||
ricochet: <Ricochet width={24} height={24} />,
|
||||
block: <Block width={24} height={24} />,
|
||||
penetration: <NominalPenetration width={24} height={24} />,
|
||||
};
|
||||
|
||||
const THICKNESS = 0.05;
|
||||
const LENGTH = 4;
|
||||
|
||||
export function ShotDisplay() {
|
||||
const shot = useTankopediaTemporary((state) => state.shot);
|
||||
const entryWrapper = useRef<Group>(null);
|
||||
const entryLine = useRef<Mesh>(null);
|
||||
const circle = useRef<Mesh>(null);
|
||||
const ricochetCircle = useRef<Mesh>(null);
|
||||
const exitWrapper = useRef<Group>(null);
|
||||
const exitLine = useRef<Mesh>(null);
|
||||
const secondLineLength = shot?.ricochet ? shot?.ricochet.distance : LENGTH;
|
||||
const number = shot && (shot.thicknesses.length > 1 || shot.ricochet);
|
||||
const shell = useDuel((state) => state.antagonist!.shell);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const t1 = clock.elapsedTime % 1;
|
||||
const t2 = (clock.elapsedTime + 0.5) % 1;
|
||||
|
||||
entryLine.current?.position.set(0, 0, LENGTH - t1 * LENGTH);
|
||||
entryLine.current?.scale.set(1, -2 * Math.abs(t1 - 0.5) + 1, 1);
|
||||
exitLine.current?.position.set(0, 0, t2 * secondLineLength);
|
||||
exitLine.current?.scale.set(1, -2 * Math.abs(t2 - 0.5) + 1, 1);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!shot) return;
|
||||
|
||||
const point = new Vector3(...shot.point);
|
||||
const shellNormal = new Vector3(...shot.shellNormal);
|
||||
const surfaceNormal = new Vector3(...shot.surfaceNormal);
|
||||
const bounceNormal = shellNormal
|
||||
.clone()
|
||||
.multiplyScalar(-1)
|
||||
.sub(
|
||||
surfaceNormal
|
||||
.clone()
|
||||
.multiplyScalar(
|
||||
2 * surfaceNormal.dot(shellNormal.clone().multiplyScalar(-1)),
|
||||
),
|
||||
);
|
||||
|
||||
entryWrapper.current?.position.set(...shot.point);
|
||||
entryWrapper.current?.lookAt(point.clone().add(shellNormal));
|
||||
exitWrapper.current?.position.set(...shot.point);
|
||||
exitWrapper.current?.lookAt(point.clone().add(bounceNormal));
|
||||
circle.current?.position.set(...shot.point);
|
||||
circle.current?.lookAt(point.clone().add(surfaceNormal));
|
||||
}, [shot]);
|
||||
|
||||
if (!shot) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<mesh ref={circle}>
|
||||
<torusGeometry args={[0.05, 0.0125, 4, 16]} />
|
||||
<meshBasicMaterial
|
||||
color={shot.type === 'penetration' ? 'green' : 'red'}
|
||||
depthTest={false}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<group ref={entryWrapper}>
|
||||
<Html
|
||||
position={[0, 0, 0]}
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
width: number ? 300 : 200,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" gap="1">
|
||||
<Flex align="center" gap="2">
|
||||
{SHOT_ICONS[shot.type]}
|
||||
<Text>
|
||||
{SHOT_NAMES[shot.type]} at {Math.round(radToDeg(shot.angle))}°
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
direction="column"
|
||||
style={{
|
||||
paddingLeft: number ? 8 : undefined,
|
||||
}}
|
||||
>
|
||||
{shot.thicknesses.map((layer, index) => (
|
||||
<Flex gap="2" key={index}>
|
||||
{number && (
|
||||
<Text>
|
||||
{index + 1}. {layer.type[0].toUpperCase()}
|
||||
{layer.type.slice(1)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Flex gap="1">
|
||||
{layer.type !== 'gap' && (
|
||||
<NominalPenetration width={24} height={24} />
|
||||
)}
|
||||
<Text>
|
||||
{Math.round(
|
||||
layer.type === 'gap'
|
||||
? layer.gap * 1000
|
||||
: layer.nominal,
|
||||
)}
|
||||
mm
|
||||
{layer.type === 'gap' && isExplosive(shell.type)
|
||||
? ` (-${Math.round(Math.min(100, 0.5 * layer.gap * 100))}% penetration)`
|
||||
: ''}
|
||||
</Text>
|
||||
</Flex>
|
||||
{layer.type !== 'external' && layer.type !== 'gap' && (
|
||||
<Flex gap="1">
|
||||
<AngledPenetration width={24} height={24} />
|
||||
<Text>{Math.round(layer.angled)}mm</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
{shot.ricochet && (
|
||||
<>
|
||||
<Flex gap="1">
|
||||
<Text>{shot.thicknesses.length + 1}. Ricochet</Text>
|
||||
<Ricochet width={24} height={24} />
|
||||
<Text>(-25% penetration)</Text>
|
||||
</Flex>
|
||||
{shot.ricochet.penetration ? (
|
||||
<Flex gap="1">
|
||||
<Text>{shot.thicknesses.length + 2}. Penetration</Text>
|
||||
<NominalPenetration width={24} height={24} />
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex gap="1">
|
||||
<Text>{shot.thicknesses.length + 2}. Blocked</Text>
|
||||
<Block width={24} height={24} />
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Html>
|
||||
|
||||
<mesh
|
||||
renderOrder={1}
|
||||
position={[0, 0, LENGTH / 2]}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
ref={entryLine}
|
||||
>
|
||||
<cylinderGeometry args={[THICKNESS / 2, THICKNESS / 2, LENGTH, 8]} />
|
||||
<meshBasicMaterial color="#ffff80" />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, LENGTH / 2]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[THICKNESS / 4, THICKNESS / 4, LENGTH, 8]} />
|
||||
<meshBasicMaterial color="#ffffff" depthTest={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{shot.type === 'ricochet' && (
|
||||
<group ref={exitWrapper}>
|
||||
<mesh
|
||||
renderOrder={1}
|
||||
position={[0, 0, secondLineLength / 2]}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
ref={exitLine}
|
||||
>
|
||||
<cylinderGeometry
|
||||
args={[THICKNESS / 2, THICKNESS / 2, secondLineLength, 8]}
|
||||
/>
|
||||
<meshBasicMaterial color="#ff8080" />
|
||||
</mesh>
|
||||
<mesh
|
||||
position={[0, 0, secondLineLength / 2]}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<cylinderGeometry
|
||||
args={[THICKNESS / 4, THICKNESS / 4, secondLineLength, 8]}
|
||||
/>
|
||||
<meshBasicMaterial color="#ffffff" depthTest={false} />
|
||||
</mesh>
|
||||
|
||||
{shot.ricochet && (
|
||||
<mesh ref={ricochetCircle} position={[0, 0, secondLineLength]}>
|
||||
<icosahedronGeometry args={[0.05, 1]} />
|
||||
<meshBasicMaterial
|
||||
color={shot.ricochet.penetration ? 'green' : 'red'}
|
||||
depthTest={false}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,18 +5,19 @@ import {
|
||||
} from '../../../core/blitzkrieg/tankDefinitions';
|
||||
import { theme } from '../../../stitches.config';
|
||||
|
||||
export const TANK_TYPES = [
|
||||
'light',
|
||||
'medium',
|
||||
'heavy',
|
||||
'tankDestroyer',
|
||||
export const TANK_CLASSES = [
|
||||
'lightTank',
|
||||
'mediumTank',
|
||||
'heavyTank',
|
||||
'AT-SPG',
|
||||
] as const;
|
||||
|
||||
export const TREE_TYPES = ['researchable', 'premium', 'collector'] as const;
|
||||
|
||||
export type TankType = (typeof TANK_TYPES)[number];
|
||||
export type TankClass = (typeof TANK_CLASSES)[number];
|
||||
export type TreeType = (typeof TREE_TYPES)[number];
|
||||
|
||||
// BIG TODO: REMOVE THIS AFTER LOCALIZATION
|
||||
export const TREE_TYPE_NAMES: Record<TreeType, string> = {
|
||||
researchable: 'Tech tree',
|
||||
premium: 'Premium',
|
||||
@@ -31,7 +32,7 @@ export const TREE_TYPE_IMAGES: Record<TreeType, string> = {
|
||||
|
||||
export interface ItemProps {
|
||||
image?: string;
|
||||
tankType?: TankType;
|
||||
tankType?: TankClass;
|
||||
name: string;
|
||||
treeType: TreeType;
|
||||
}
|
||||
@@ -42,7 +43,7 @@ const TREE_TYPE_COLOR = {
|
||||
collector: '_blue',
|
||||
} as const;
|
||||
|
||||
export const TREE_TYPE_ICONS: Record<TreeType, Record<TankType, string>> = {
|
||||
export const TREE_TYPE_ICONS: Record<TreeType, Record<TankClass, string>> = {
|
||||
researchable: TANK_ICONS,
|
||||
premium: TANK_ICONS_PREMIUM,
|
||||
collector: TANK_ICONS_COLLECTOR,
|
||||
|
||||
+25
-48
@@ -1,75 +1,52 @@
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
export interface TitleBarProps {
|
||||
name: string;
|
||||
title: string;
|
||||
image?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function TitleBar({ name, image, description }: TitleBarProps) {
|
||||
export default function TitleBar({ title, image, description }: TitleBarProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
gap: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
gap: 4,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{image && (
|
||||
<img
|
||||
style={{ width: 64, height: 64, objectFit: 'contain' }}
|
||||
style={{ width: 32, height: 32, objectFit: 'contain' }}
|
||||
src={image}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
fontSize: 32,
|
||||
fontWeight: 900,
|
||||
background: `linear-gradient(180deg, ${theme.colors.textHighContrast} 0%, ${theme.colors.textLowContrast} 100%)`,
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 900,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: image ? 'flex-start' : 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textLowContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}>
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Vector3 } from 'three';
|
||||
|
||||
export const ZERO_VECTOR = new Vector3();
|
||||
export const I_HAT = new Vector3(1, 0, 0);
|
||||
export const J_HAT = new Vector3(0, 1, 0);
|
||||
export const K_HAT = new Vector3(0, 0, 1);
|
||||
@@ -1,7 +1,7 @@
|
||||
export const LEAGUES = [
|
||||
{ name: 'Diamond', minScore: 5000 },
|
||||
{ name: 'Platinum', minScore: 4000 },
|
||||
{ name: 'Gold', minScore: 3000 },
|
||||
{ name: 'Silver', minScore: 2000 },
|
||||
{ name: 'Bronze', minScore: 0 },
|
||||
{ name: 'diamond', minScore: 5000 },
|
||||
{ name: 'platinum', minScore: 4000 },
|
||||
{ name: 'gold', minScore: 3000 },
|
||||
{ name: 'silver', minScore: 2000 },
|
||||
{ name: 'bronze', minScore: 0 },
|
||||
];
|
||||
@@ -3,21 +3,11 @@ export const REGIONS = ['com', 'eu', 'asia'] as const;
|
||||
export type Region = (typeof REGIONS)[number];
|
||||
export type RegionSubdomain = 'na' | 'eu' | 'asia';
|
||||
|
||||
export const REGION_NAMES: Record<Region, string> = {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const UNLOCALIZED_REGION_NAMES: Record<Region, string> = {
|
||||
com: 'North America',
|
||||
eu: 'Europe',
|
||||
asia: 'Asia',
|
||||
};
|
||||
|
||||
// https://planetarynames.wr.usgs.gov/Abbreviations
|
||||
export const REGION_NAMES_SHORT: Record<Region, string> = {
|
||||
com: 'NA',
|
||||
eu: 'EU',
|
||||
asia: 'ASIA',
|
||||
};
|
||||
|
||||
export const REGION_SUBDOMAIN_NAMES: Record<RegionSubdomain, string> = {
|
||||
na: 'North America',
|
||||
eu: 'Europe',
|
||||
asia: 'Asia',
|
||||
};
|
||||
@@ -1,39 +1,38 @@
|
||||
import { clamp } from 'lodash';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { PitchLimits, YawLimits } from '../blitzkrieg/modelDefinitions';
|
||||
import { normalizeAnglePI } from '../math/normalizeAngle180';
|
||||
import { normalizeAngleRad } from '../math/normalizeAngleRad';
|
||||
|
||||
export function applyPitchYawLimits(
|
||||
pitch: number, // rad
|
||||
yaw: number, // rad
|
||||
pitchLimits: PitchLimits, // deg
|
||||
yawLimits?: YawLimits, // deg
|
||||
verticalStabilizer = false,
|
||||
) {
|
||||
let newPitch = pitch;
|
||||
let newYaw = yaw;
|
||||
|
||||
if (yawLimits) {
|
||||
newYaw = normalizeAnglePI(
|
||||
clamp(
|
||||
newYaw,
|
||||
-yawLimits.max * (Math.PI / 180),
|
||||
-yawLimits.min * (Math.PI / 180),
|
||||
),
|
||||
newYaw = normalizeAngleRad(
|
||||
clamp(newYaw, -degToRad(yawLimits.max), -degToRad(yawLimits.min)),
|
||||
);
|
||||
}
|
||||
|
||||
let lowerPitch = -pitchLimits.max * (Math.PI / 180);
|
||||
let upperPitch = -pitchLimits.min * (Math.PI / 180);
|
||||
const transition = (pitchLimits.transition ?? 10) * (Math.PI / 180);
|
||||
const pitchBuff = verticalStabilizer ? degToRad(3) : 0;
|
||||
let lowerPitch = -degToRad(pitchLimits.max) - pitchBuff;
|
||||
let upperPitch = -degToRad(pitchLimits.min) + pitchBuff;
|
||||
const transition = degToRad(pitchLimits.transition ?? 10);
|
||||
|
||||
if (pitchLimits.back) {
|
||||
const range = pitchLimits.back.range * (Math.PI / 180);
|
||||
const yawRotatedAbs = Math.abs(normalizeAnglePI(newYaw - Math.PI));
|
||||
const range = degToRad(pitchLimits.back.range);
|
||||
const yawRotatedAbs = Math.abs(normalizeAngleRad(newYaw - Math.PI));
|
||||
|
||||
if (yawRotatedAbs <= range + transition) {
|
||||
if (yawRotatedAbs <= range) {
|
||||
// inside range
|
||||
lowerPitch = -pitchLimits.back.max * (Math.PI / 180);
|
||||
upperPitch = -pitchLimits.back.min * (Math.PI / 180);
|
||||
lowerPitch = -degToRad(pitchLimits.back.max);
|
||||
upperPitch = -degToRad(pitchLimits.back.min);
|
||||
} else {
|
||||
// inside transition
|
||||
const transitionProgress = (yawRotatedAbs - range) / transition;
|
||||
@@ -44,21 +43,21 @@ export function applyPitchYawLimits(
|
||||
(1 - transitionProgress) * pitchLimits.back.min +
|
||||
transitionProgress * pitchLimits.min;
|
||||
|
||||
lowerPitch = -maxInterpolatedPitch * (Math.PI / 180);
|
||||
upperPitch = -minInterpolatedPitch * (Math.PI / 180);
|
||||
lowerPitch = -degToRad(maxInterpolatedPitch);
|
||||
upperPitch = -degToRad(minInterpolatedPitch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pitchLimits.front) {
|
||||
const range = pitchLimits.front.range * (Math.PI / 180);
|
||||
const yawAbs = Math.abs(normalizeAnglePI(newYaw));
|
||||
const range = degToRad(pitchLimits.front.range);
|
||||
const yawAbs = Math.abs(normalizeAngleRad(newYaw));
|
||||
|
||||
if (yawAbs <= range + transition) {
|
||||
if (yawAbs <= range) {
|
||||
// inside range
|
||||
lowerPitch = -pitchLimits.front.max * (Math.PI / 180);
|
||||
upperPitch = -pitchLimits.front.min * (Math.PI / 180);
|
||||
lowerPitch = -degToRad(pitchLimits.front.max);
|
||||
upperPitch = -degToRad(pitchLimits.front.min);
|
||||
} else {
|
||||
// inside transition
|
||||
const transitionProgress = (yawAbs - range) / transition;
|
||||
@@ -69,8 +68,8 @@ export function applyPitchYawLimits(
|
||||
(1 - transitionProgress) * pitchLimits.front.min +
|
||||
transitionProgress * pitchLimits.min;
|
||||
|
||||
lowerPitch = -maxInterpolatedPitch * (Math.PI / 180);
|
||||
upperPitch = -minInterpolatedPitch * (Math.PI / 180);
|
||||
lowerPitch = -degToRad(maxInterpolatedPitch);
|
||||
upperPitch = -degToRad(minInterpolatedPitch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { Locale } from 'discord.js';
|
||||
import { Region } from '../../constants/regions';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
|
||||
import { UserError } from '../../hooks/userError';
|
||||
import { TanksStats } from '../../types/tanksStats';
|
||||
import { translator } from '../localization/translator';
|
||||
import fetchBlitz from './fetchBlitz';
|
||||
|
||||
export default async function getTankStats(region: Region, id: number) {
|
||||
export default async function getTankStats(
|
||||
region: Region,
|
||||
id: number,
|
||||
locale: Locale,
|
||||
) {
|
||||
const { t } = translator(locale);
|
||||
const tankStats = await fetchBlitz<TanksStats>(
|
||||
`https://api.wotblitz.${region}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
|
||||
);
|
||||
|
||||
if (tankStats[id] === null) {
|
||||
throw new UserError(
|
||||
"# No tank stats available\nThis player doesn't have any stats available. This may not be the player you are looking for.",
|
||||
);
|
||||
throw new UserError(t`bot.common.errors.no_tank_stats`);
|
||||
}
|
||||
|
||||
return tankStats[id];
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { Locale } from 'discord.js';
|
||||
import { go } from 'fuzzysort';
|
||||
import { UserError } from '../../hooks/userError';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { UserError } from '../../hooks/userError';
|
||||
import {
|
||||
tankDefinitions,
|
||||
tanksDefinitionsArray,
|
||||
} from '../blitzkrieg/tankDefinitions';
|
||||
import { translator } from '../localization/translator';
|
||||
|
||||
export default async function resolveTankId(tank: string | number) {
|
||||
const number = typeof tank === 'string' ? parseInt(tank) : tank;
|
||||
export default async function resolveTankId(
|
||||
tank: string | number,
|
||||
locale: Locale,
|
||||
) {
|
||||
const { translate } = translator(locale);
|
||||
const number = typeof tank === 'string' ? Number(tank) : tank;
|
||||
|
||||
if (Number.isNaN(number)) {
|
||||
const searchResult = go(`${tank}`, await tanksDefinitionsArray, {
|
||||
@@ -17,7 +23,9 @@ export default async function resolveTankId(tank: string | number) {
|
||||
|
||||
if (searchResult.length === 0) {
|
||||
throw new UserError(
|
||||
`# Tank not found\nCould not find tank by the name "${markdownEscape(`${tank}`)}".`,
|
||||
translate('bot.common.errors.tank_name_not_found', [
|
||||
markdownEscape(`${tank}`),
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
return searchResult[0].obj.id;
|
||||
@@ -27,7 +35,7 @@ export default async function resolveTankId(tank: string | number) {
|
||||
return number;
|
||||
} else {
|
||||
throw new UserError(
|
||||
`# Tank not found\nCould not find tank by the ID "${number}". Try typing the name of the tank instead of a number.`,
|
||||
translate('bot.common.errors.tank_id_not_found', [`${number}`]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import {
|
||||
DuelMember,
|
||||
genericDefaultEquipmentMatrix,
|
||||
mutateDuel,
|
||||
} from '../../stores/duel';
|
||||
import { availableProvisions } from './availableProvisions';
|
||||
import { modelDefinitions } from './modelDefinitions';
|
||||
import { provisionDefinitions } from './provisionDefinitions';
|
||||
import { tankDefinitions } from './tankDefinitions';
|
||||
|
||||
@@ -13,6 +15,8 @@ export async function assignDuelMember(
|
||||
) {
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const awaitedProvisionDefinitions = await provisionDefinitions;
|
||||
const awaitedModelDefinitions = await modelDefinitions;
|
||||
const tankModelDefinition = awaitedModelDefinitions[id];
|
||||
const tank = awaitedTankDefinitions[id];
|
||||
const turret = tank.turrets.at(-1)!;
|
||||
const gun = turret.guns.at(-1)!;
|
||||
@@ -32,7 +36,7 @@ export async function assignDuelMember(
|
||||
shell,
|
||||
track,
|
||||
equipment: genericDefaultEquipmentMatrix,
|
||||
pitch: 0,
|
||||
pitch: degToRad(tankModelDefinition.turretRotation?.pitch ?? 0),
|
||||
yaw: 0,
|
||||
camouflage: true,
|
||||
consumables: [],
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { TREE_TYPE_NAMES } from '../../components/Tanks';
|
||||
import { encyclopediaInfo } from '../blitz/encyclopediaInfo';
|
||||
import { Locale } from 'discord.js';
|
||||
import { translator } from '../localization/translator';
|
||||
import { StatFilters } from '../statistics/filterStats';
|
||||
import { TIER_ROMAN_NUMERALS, Tier, tankDefinitions } from './tankDefinitions';
|
||||
|
||||
export async function filtersToDescription({
|
||||
nation,
|
||||
tier,
|
||||
tankType,
|
||||
treeType,
|
||||
tank,
|
||||
}: StatFilters) {
|
||||
const awaitedEncyclopediaInfo = await encyclopediaInfo;
|
||||
export async function filtersToDescription(
|
||||
{ nation, tier, tankType, treeType, tank }: StatFilters,
|
||||
locale: Locale,
|
||||
) {
|
||||
const { translate } = translator(locale);
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const info: string[] = [];
|
||||
|
||||
// TODO: somehow localize tank names?
|
||||
if (tank) return awaitedTankDefinitions[tank].name;
|
||||
if (nation) info.push(awaitedEncyclopediaInfo.vehicle_nations[nation]);
|
||||
if (tier) info.push(`Tier ${TIER_ROMAN_NUMERALS[tier as Tier]}`);
|
||||
if (tankType) info.push(awaitedEncyclopediaInfo.vehicle_types[tankType]);
|
||||
if (treeType) info.push(TREE_TYPE_NAMES[treeType]);
|
||||
if (nation) info.push(translate(`common.nations.${nation}`));
|
||||
if (tier) {
|
||||
info.push(
|
||||
translate('bot.common.filters.tier', [TIER_ROMAN_NUMERALS[tier as Tier]]),
|
||||
);
|
||||
}
|
||||
if (tankType) info.push(translate(`common.tank_class_short.${tankType}`));
|
||||
if (treeType) info.push(translate(`common.tree_type.${treeType}`));
|
||||
|
||||
return info.length === 0 ? 'No filters' : info.join(', ');
|
||||
return info.length === 0
|
||||
? translate('bot.common.filters.none')
|
||||
: info.join(', ');
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Locale } from 'discord.js';
|
||||
import { Region } from '../../constants/regions';
|
||||
import {
|
||||
GetHistoriesOptions,
|
||||
@@ -34,6 +35,7 @@ export const getTankHistoriesDefaultOptions: GetTankHistoriesOptions = {
|
||||
export default async function getTankHistories(
|
||||
server: Region,
|
||||
id: number,
|
||||
locale: Locale,
|
||||
options?: Partial<GetTankHistoriesOptions>,
|
||||
) {
|
||||
const mergedOptions = { ...getTankHistoriesDefaultOptions, ...options };
|
||||
@@ -76,7 +78,7 @@ export default async function getTankHistories(
|
||||
const latestNodes: TankHistories = [];
|
||||
|
||||
if (mergedOptions.includeLatestHistories) {
|
||||
(await getTankStats(server, id)).forEach((tankHistory) => {
|
||||
(await getTankStats(server, id, locale)).forEach((tankHistory) => {
|
||||
if (
|
||||
tankHistory.all.battles > (lastBattles[tankHistory.tank_id] ?? 0) &&
|
||||
(mergedOptions.tankId === undefined ||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useDuel } from '../../stores/duel';
|
||||
import { equipmentDefinitions } from './equipmentDefinitions';
|
||||
|
||||
export async function hasEquipment(id: number, antagonist = false) {
|
||||
export async function hasEquipment(
|
||||
id: number,
|
||||
antagonist = false,
|
||||
equipmentMatrix = useDuel.getState()[
|
||||
antagonist ? 'antagonist' : 'protagonist'
|
||||
]!.equipment,
|
||||
) {
|
||||
const awaitedEquipmentDefinitions = await equipmentDefinitions;
|
||||
const member = useDuel.getState()[antagonist ? 'antagonist' : 'protagonist']!;
|
||||
const equipmentRows =
|
||||
awaitedEquipmentDefinitions.presets[member.tank.equipment];
|
||||
const equipmentMatrix =
|
||||
useDuel.getState()[antagonist ? 'antagonist' : 'protagonist']!.equipment;
|
||||
|
||||
return equipmentRows.some((equipmentRow, rowIndex) => {
|
||||
const presetRows = awaitedEquipmentDefinitions.presets[member.tank.equipment];
|
||||
return presetRows.some((equipmentRow, rowIndex) => {
|
||||
return equipmentRow.some((equipment, columnIndex) => {
|
||||
const choice = equipmentMatrix[rowIndex][columnIndex];
|
||||
if (choice === 0) return false;
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import satori from 'satori';
|
||||
|
||||
const FONT_NAME = 'Roboto';
|
||||
const FONT_FILES = [
|
||||
{ weight: 400 as const, path: 'src/assets/fonts/Roboto.ttf' },
|
||||
{ weight: 700 as const, path: 'src/assets/fonts/Roboto-Bold.ttf' },
|
||||
{ weight: 900 as const, path: 'src/assets/fonts/Roboto-Black.ttf' },
|
||||
];
|
||||
|
||||
const fonts = Promise.all(
|
||||
FONT_FILES.map(({ path, weight }) =>
|
||||
readFile(path).then((data) => ({
|
||||
data: Buffer.from(data.buffer),
|
||||
name: FONT_NAME,
|
||||
weight,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
export default async function jsxToSvg(jsx: JSX.Element) {
|
||||
return await satori(jsx, {
|
||||
width: 480,
|
||||
fonts: await fonts,
|
||||
});
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user