mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 02:37:05 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -44,7 +44,7 @@ jobs:
|
||||
run: yarn setup:depotdownloader
|
||||
|
||||
- name: Run DepotDownloader
|
||||
run: ./temp/depotDownloader/DepotDownloader.exe -app 444200 -username ${{ secrets.DEPOT_DOWNLOADER_USERNAME }} -password "${{ secrets.DEPOT_DOWNLOADER_PASSWORD }}"
|
||||
run: ./temp/depotDownloader/DepotDownloader.exe -app 444202 -username ${{ secrets.DEPOT_DOWNLOADER_USERNAME }} -password "${{ secrets.DEPOT_DOWNLOADER_PASSWORD }}"
|
||||
|
||||
- name: Build assets
|
||||
run: |
|
||||
|
||||
@@ -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
+8
-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",
|
||||
@@ -128,6 +132,7 @@
|
||||
"TYPELESS",
|
||||
"ubjson",
|
||||
"Unicum",
|
||||
"UNLOCALIZED",
|
||||
"UNORM",
|
||||
"Unregistering",
|
||||
"vickers",
|
||||
@@ -137,6 +142,7 @@
|
||||
"webp",
|
||||
"winrate",
|
||||
"wireframe",
|
||||
"WOTB",
|
||||
"wotblitz",
|
||||
"xffn",
|
||||
"xkdkw",
|
||||
@@ -145,5 +151,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
|
||||
+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": {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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`;
|
||||
})()
|
||||
? // ? await (async () => {
|
||||
// const [installationVersion] = await readdir(`depots/${WOTB_WIN32_DEPOT}`);
|
||||
// return `depots/${WOTB_WIN32_DEPOT}/${installationVersion}/Data`;
|
||||
// })()
|
||||
`depots/${WOTB_WIN32_DEPOT}/Data`
|
||||
: 'C:/Program Files (x86)/Steam/steamapps/common/World of Tanks Blitz/Data';
|
||||
|
||||
export const POI = {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -136,12 +136,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 +164,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 +181,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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ 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';
|
||||
@@ -24,9 +25,6 @@ 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() {
|
||||
@@ -198,12 +196,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}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -10,7 +10,7 @@ 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';
|
||||
@@ -172,8 +172,8 @@ export const TankModel = memo(() => {
|
||||
}
|
||||
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;
|
||||
@@ -234,8 +234,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,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>
|
||||
|
||||
|
||||
@@ -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)[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import { clamp } from 'lodash';
|
||||
import { PitchLimits, YawLimits } from '../blitzkrieg/modelDefinitions';
|
||||
import { normalizeAnglePI } from '../math/normalizeAngle180';
|
||||
import { normalizeAngleRad } from '../math/normalizeAngleRad';
|
||||
|
||||
export function applyPitchYawLimits(
|
||||
pitch: number, // rad
|
||||
@@ -12,7 +12,7 @@ export function applyPitchYawLimits(
|
||||
let newYaw = yaw;
|
||||
|
||||
if (yawLimits) {
|
||||
newYaw = normalizeAnglePI(
|
||||
newYaw = normalizeAngleRad(
|
||||
clamp(
|
||||
newYaw,
|
||||
-yawLimits.max * (Math.PI / 180),
|
||||
@@ -27,7 +27,7 @@ export function applyPitchYawLimits(
|
||||
|
||||
if (pitchLimits.back) {
|
||||
const range = pitchLimits.back.range * (Math.PI / 180);
|
||||
const yawRotatedAbs = Math.abs(normalizeAnglePI(newYaw - Math.PI));
|
||||
const yawRotatedAbs = Math.abs(normalizeAngleRad(newYaw - Math.PI));
|
||||
|
||||
if (yawRotatedAbs <= range + transition) {
|
||||
if (yawRotatedAbs <= range) {
|
||||
@@ -52,7 +52,7 @@ export function applyPitchYawLimits(
|
||||
|
||||
if (pitchLimits.front) {
|
||||
const range = pitchLimits.front.range * (Math.PI / 180);
|
||||
const yawAbs = Math.abs(normalizeAnglePI(newYaw));
|
||||
const yawAbs = Math.abs(normalizeAngleRad(newYaw));
|
||||
|
||||
if (yawAbs <= range + transition) {
|
||||
if (yawAbs <= range) {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const SECRET_KEYS = [
|
||||
'DISCORD_TOKEN',
|
||||
'GH_TOKEN',
|
||||
'DISCORD_BLITZ_DB',
|
||||
'WOTB_DLC_CDN',
|
||||
] as const;
|
||||
|
||||
export type SECRET = typeof SECRET_KEYS extends readonly (infer T)[]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { deburr } from 'lodash';
|
||||
import { Vector2Tuple } from 'three';
|
||||
import { TankType, TreeType } from '../../components/Tanks';
|
||||
import { TankClass, TreeType } from '../../components/Tanks';
|
||||
import { asset } from './asset';
|
||||
import { fetchCdonLz4 } from './fetchCdonLz4';
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface TankDefinition {
|
||||
consumables: number;
|
||||
provisions: number;
|
||||
tier: Tier;
|
||||
type: TankType;
|
||||
class: TankClass;
|
||||
testing?: boolean;
|
||||
turrets: TurretDefinition[];
|
||||
engines: EngineDefinition[];
|
||||
@@ -180,25 +180,25 @@ export const tankNames = tanksDefinitionsArray.then((tanks) =>
|
||||
),
|
||||
);
|
||||
|
||||
export const TANK_ICONS: Record<TankType, string> = {
|
||||
tankDestroyer: 'https://i.imgur.com/BIHSEH0.png',
|
||||
light: 'https://i.imgur.com/CSNha5V.png',
|
||||
medium: 'https://i.imgur.com/wvf3ltm.png',
|
||||
heavy: 'https://i.imgur.com/ECeqlZa.png',
|
||||
export const TANK_ICONS: Record<TankClass, string> = {
|
||||
'AT-SPG': 'https://i.imgur.com/BIHSEH0.png',
|
||||
lightTank: 'https://i.imgur.com/CSNha5V.png',
|
||||
mediumTank: 'https://i.imgur.com/wvf3ltm.png',
|
||||
heavyTank: 'https://i.imgur.com/ECeqlZa.png',
|
||||
};
|
||||
|
||||
export const TANK_ICONS_PREMIUM: Record<TankType, string> = {
|
||||
tankDestroyer: 'https://i.imgur.com/TCu3EdR.png',
|
||||
light: 'https://i.imgur.com/zdkpTRb.png',
|
||||
medium: 'https://i.imgur.com/3z7eHX6.png',
|
||||
heavy: 'https://i.imgur.com/P3vbmyA.png',
|
||||
export const TANK_ICONS_PREMIUM: Record<TankClass, string> = {
|
||||
'AT-SPG': 'https://i.imgur.com/TCu3EdR.png',
|
||||
lightTank: 'https://i.imgur.com/zdkpTRb.png',
|
||||
mediumTank: 'https://i.imgur.com/3z7eHX6.png',
|
||||
heavyTank: 'https://i.imgur.com/P3vbmyA.png',
|
||||
};
|
||||
|
||||
export const TANK_ICONS_COLLECTOR: Record<TankType, string> = {
|
||||
tankDestroyer: 'https://i.imgur.com/WTjeirB.png',
|
||||
light: 'https://i.imgur.com/EwhtKkU.png',
|
||||
medium: 'https://i.imgur.com/u8YDMBh.png',
|
||||
heavy: 'https://i.imgur.com/8xRf3nc.png',
|
||||
export const TANK_ICONS_COLLECTOR: Record<TankClass, string> = {
|
||||
'AT-SPG': 'https://i.imgur.com/WTjeirB.png',
|
||||
lightTank: 'https://i.imgur.com/EwhtKkU.png',
|
||||
mediumTank: 'https://i.imgur.com/u8YDMBh.png',
|
||||
heavyTank: 'https://i.imgur.com/8xRf3nc.png',
|
||||
};
|
||||
|
||||
export const TIERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Locale } from 'discord.js';
|
||||
import { Region } from '../../constants/regions';
|
||||
import { AllStats } from '../blitz/getAccountInfo';
|
||||
import getTankHistories, {
|
||||
@@ -45,8 +46,9 @@ export default async function getStatsInPeriod(
|
||||
id: number,
|
||||
start: number,
|
||||
end: number,
|
||||
locale: Locale,
|
||||
) {
|
||||
const history = await getTankHistories(server, id, {
|
||||
const history = await getTankHistories(server, id, locale, {
|
||||
includeLatestHistories: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { SlashCommandStringOption } from 'discord.js';
|
||||
import { Locale, SlashCommandStringOption } from 'discord.js';
|
||||
import { translator } from '../localization/translator';
|
||||
import { localizationObject } from './localizationObject';
|
||||
|
||||
export default function addClanChoices(option: SlashCommandStringOption) {
|
||||
const { translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName('clan')
|
||||
.setDescription('The clan name or tag you are checking')
|
||||
.setNameLocalizations(localizationObject('bot.common.options.clan'))
|
||||
.setDescription(translate('bot.common.options.clan.description'))
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject('bot.common.options.clan.description'),
|
||||
)
|
||||
.setAutocomplete(true)
|
||||
.setMinLength(2)
|
||||
.setRequired(true);
|
||||
|
||||
@@ -1,60 +1,108 @@
|
||||
import { SlashCommandBuilder, SlashCommandSubcommandBuilder } from 'discord.js';
|
||||
import { TREE_TYPE_NAMES, TreeType } from '../../components/Tanks';
|
||||
import { EncyclopediaInfo } from '../blitz/encyclopediaInfo';
|
||||
import {
|
||||
APIApplicationCommandOptionChoice,
|
||||
Locale,
|
||||
SlashCommandBuilder,
|
||||
SlashCommandSubcommandBuilder,
|
||||
} from 'discord.js';
|
||||
import { TANK_CLASSES, TREE_TYPES } from '../../components/Tanks';
|
||||
import { translator } from '../localization/translator';
|
||||
import addTankChoices from './addTankChoices';
|
||||
import addTierChoices from './addTierChoices';
|
||||
import { localizationObject } from './localizationObject';
|
||||
|
||||
export function addFilterOptions<
|
||||
Option extends SlashCommandBuilder | SlashCommandSubcommandBuilder,
|
||||
>(option: Option, encyclopediaInfo: EncyclopediaInfo) {
|
||||
return option
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('nation')
|
||||
.setDescription('Nation')
|
||||
.addChoices(
|
||||
...Object.entries(encyclopediaInfo.vehicle_nations).map(
|
||||
([type, name]) => ({ name, value: type }),
|
||||
),
|
||||
)
|
||||
.setRequired(false),
|
||||
)
|
||||
.addStringOption((option) => addTierChoices(option).setRequired(false))
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('tank-type')
|
||||
.setDescription('Tank type')
|
||||
.addChoices(
|
||||
...Object.entries(encyclopediaInfo.vehicle_types).map(
|
||||
([type, name]) => ({
|
||||
name: name.replace(' Tank', ''),
|
||||
value: type,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.setRequired(false),
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('tree-type')
|
||||
.setDescription('Tech tree type')
|
||||
.addChoices(
|
||||
{
|
||||
name: TREE_TYPE_NAMES['researchable'],
|
||||
value: 'researchable' satisfies TreeType,
|
||||
},
|
||||
{
|
||||
name: TREE_TYPE_NAMES.premium,
|
||||
value: 'premium' satisfies TreeType,
|
||||
},
|
||||
{
|
||||
name: TREE_TYPE_NAMES.collector,
|
||||
value: 'collector' satisfies TreeType,
|
||||
},
|
||||
)
|
||||
.setRequired(false),
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
addTankChoices(option).setRequired(false),
|
||||
) as Option;
|
||||
>(option: Option, nations: string[]) {
|
||||
const { translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return (
|
||||
option
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName(translate('bot.common.options.filters.nation'))
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.common.options.filters.nation'),
|
||||
)
|
||||
.setDescription(
|
||||
translate('bot.common.options.filters.nation.description'),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject('bot.common.options.filters.nation.description'),
|
||||
)
|
||||
.addChoices(
|
||||
...nations.map(
|
||||
(nation) =>
|
||||
({
|
||||
value: nation,
|
||||
name: translate(`common.nations.${nation}`),
|
||||
name_localizations: localizationObject(
|
||||
`common.nations.${nation}`,
|
||||
),
|
||||
}) satisfies APIApplicationCommandOptionChoice,
|
||||
),
|
||||
)
|
||||
.setRequired(false),
|
||||
)
|
||||
.addStringOption((option) => addTierChoices(option).setRequired(false))
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName(translate('bot.common.options.filters.tank_class'))
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.common.options.filters.tank_class'),
|
||||
)
|
||||
.setDescription(
|
||||
translate('bot.common.options.filters.tank_class.description'),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.common.options.filters.tank_class.description',
|
||||
),
|
||||
)
|
||||
.addChoices(
|
||||
...TANK_CLASSES.map(
|
||||
(tankType) =>
|
||||
({
|
||||
value: tankType,
|
||||
name: translate(`common.tank_class_short.${tankType}`),
|
||||
name_localizations: localizationObject(
|
||||
`common.tank_class_short.${tankType}`,
|
||||
),
|
||||
}) satisfies APIApplicationCommandOptionChoice,
|
||||
),
|
||||
)
|
||||
.setRequired(false),
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName(translate('bot.common.options.filters.tree_type'))
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.common.options.filters.tree_type'),
|
||||
)
|
||||
.setDescription(
|
||||
translate('bot.common.options.filters.tree_type.description'),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.common.options.filters.tree_type.description',
|
||||
),
|
||||
)
|
||||
.addChoices(
|
||||
...TREE_TYPES.map(
|
||||
(treeType) =>
|
||||
({
|
||||
value: treeType,
|
||||
name: translate(`common.tree_type.${treeType}`),
|
||||
name_localizations: localizationObject(
|
||||
`common.tree_type.${treeType}`,
|
||||
),
|
||||
}) satisfies APIApplicationCommandOptionChoice,
|
||||
),
|
||||
)
|
||||
.setRequired(false),
|
||||
)
|
||||
// TODO: somehow localize this?
|
||||
.addStringOption((option) =>
|
||||
addTankChoices(option).setRequired(false),
|
||||
) as Option
|
||||
);
|
||||
}
|
||||
@@ -1,49 +1,120 @@
|
||||
import {
|
||||
Locale,
|
||||
SlashCommandBuilder,
|
||||
SlashCommandSubcommandBuilder,
|
||||
SlashCommandSubcommandGroupBuilder,
|
||||
} from 'discord.js';
|
||||
import { translator } from '../localization/translator';
|
||||
import { localizationObject } from './localizationObject';
|
||||
|
||||
export type PeriodType = 'today' | '30' | '60' | '90' | 'career' | 'custom';
|
||||
export type PeriodSize = 'today' | `${number}` | 'career';
|
||||
|
||||
export default function addPeriodSubCommands(
|
||||
export function addPeriodSubCommands(
|
||||
option: SlashCommandBuilder | SlashCommandSubcommandGroupBuilder,
|
||||
extra: (
|
||||
option: SlashCommandSubcommandBuilder,
|
||||
) => SlashCommandSubcommandBuilder = (option) => option,
|
||||
) {
|
||||
const { translate } = translator(Locale.EnglishUS);
|
||||
const subcommandNameLocalizations = {
|
||||
today: localizationObject('bot.common.subcommands.today'),
|
||||
30: localizationObject('bot.common.subcommands.30'),
|
||||
60: localizationObject('bot.common.subcommands.60'),
|
||||
90: localizationObject('bot.common.subcommands.90'),
|
||||
career: localizationObject('bot.common.subcommands.career'),
|
||||
custom: localizationObject('bot.common.subcommands.custom'),
|
||||
};
|
||||
const subcommandDescriptionLocalizations = {
|
||||
today: localizationObject('bot.common.subcommands.today.description'),
|
||||
30: localizationObject('bot.common.subcommands.30.description'),
|
||||
60: localizationObject('bot.common.subcommands.60.description'),
|
||||
90: localizationObject('bot.common.subcommands.90.description'),
|
||||
career: localizationObject('bot.common.subcommands.career.description'),
|
||||
custom: localizationObject('bot.common.subcommands.custom.description'),
|
||||
};
|
||||
|
||||
option
|
||||
.addSubcommand((option) =>
|
||||
extra(option.setName('today').setDescription('Today')),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(option.setName('30').setDescription('30 days')),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(option.setName('60').setDescription('60 days')),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(option.setName('90').setDescription('90 days')),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(option.setName('career').setDescription('Career')),
|
||||
extra(
|
||||
option
|
||||
.setName(translate('bot.common.subcommands.today'))
|
||||
.setNameLocalizations(subcommandNameLocalizations.today)
|
||||
.setDescription(translate('bot.common.subcommands.today.description'))
|
||||
.setDescriptionLocalizations(
|
||||
subcommandDescriptionLocalizations.today,
|
||||
),
|
||||
),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(
|
||||
option
|
||||
.setName('custom')
|
||||
.setDescription('Custom range')
|
||||
.setName(translate('bot.common.subcommands.30'))
|
||||
.setNameLocalizations(subcommandNameLocalizations[30])
|
||||
.setDescription(translate('bot.common.subcommands.30.description'))
|
||||
.setDescriptionLocalizations(subcommandDescriptionLocalizations[30]),
|
||||
),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(
|
||||
option
|
||||
.setName(translate('bot.common.subcommands.60'))
|
||||
.setNameLocalizations(subcommandNameLocalizations[60])
|
||||
.setDescription(translate('bot.common.subcommands.60.description'))
|
||||
.setDescriptionLocalizations(subcommandDescriptionLocalizations[60]),
|
||||
),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(
|
||||
option
|
||||
.setName(translate('bot.common.subcommands.90'))
|
||||
.setNameLocalizations(subcommandNameLocalizations[90])
|
||||
.setDescription(translate('bot.common.subcommands.90.description'))
|
||||
.setDescriptionLocalizations(subcommandDescriptionLocalizations[90]),
|
||||
),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(
|
||||
option
|
||||
.setName(translate('bot.common.subcommands.career'))
|
||||
.setNameLocalizations(subcommandNameLocalizations.career)
|
||||
.setDescription(
|
||||
translate('bot.common.subcommands.career.description'),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
subcommandDescriptionLocalizations.career,
|
||||
),
|
||||
),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(
|
||||
option
|
||||
.setName(translate('bot.common.subcommands.custom'))
|
||||
.setNameLocalizations(subcommandNameLocalizations.custom)
|
||||
.setDescription(
|
||||
translate('bot.common.subcommands.custom.description'),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
subcommandDescriptionLocalizations.custom,
|
||||
)
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName('start')
|
||||
.setDescription('Start in number of days ago from today')
|
||||
.setName(translate('bot.common.subcommands.custom.options.start'))
|
||||
.setDescription(
|
||||
translate(
|
||||
'bot.common.subcommands.custom.options.start.description',
|
||||
),
|
||||
)
|
||||
.setRequired(true),
|
||||
)
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName('end')
|
||||
.setDescription('End in number of days')
|
||||
.setName(translate('bot.common.subcommands.custom.options.end'))
|
||||
.setDescription(
|
||||
translate(
|
||||
'bot.common.subcommands.custom.options.end.description',
|
||||
),
|
||||
)
|
||||
.setRequired(true),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
SlashCommandSubcommandBuilder,
|
||||
SlashCommandSubcommandGroupBuilder,
|
||||
} from 'discord.js';
|
||||
import { encyclopediaInfo } from '../blitz/encyclopediaInfo';
|
||||
import { NATIONS } from '../blitzkrieg/tankDefinitions';
|
||||
import { addFilterOptions } from './addFilterOptions';
|
||||
import addPeriodSubCommands from './addPeriodSubCommands';
|
||||
import { addPeriodSubCommands } from './addPeriodSubCommands';
|
||||
|
||||
export default async function addPeriodicFilterOptions<
|
||||
OptionType extends SlashCommandBuilder | SlashCommandSubcommandGroupBuilder,
|
||||
@@ -15,11 +15,10 @@ export default async function addPeriodicFilterOptions<
|
||||
option: SlashCommandSubcommandBuilder,
|
||||
) => SlashCommandSubcommandBuilder = (option) => option,
|
||||
): Promise<OptionType> {
|
||||
const awaitedEncyclopediaInfo = await encyclopediaInfo;
|
||||
|
||||
addPeriodSubCommands(option, (option) => {
|
||||
return addFilterOptions(extra(option), awaitedEncyclopediaInfo);
|
||||
});
|
||||
const nations = await NATIONS;
|
||||
addPeriodSubCommands(option, (option) =>
|
||||
addFilterOptions(extra(option), nations),
|
||||
);
|
||||
|
||||
return option;
|
||||
}
|
||||
@@ -1,14 +1,33 @@
|
||||
import { SlashCommandStringOption } from 'discord.js';
|
||||
import { REGION_NAMES } from '../../constants/regions';
|
||||
import {
|
||||
APIApplicationCommandOptionChoice,
|
||||
Locale,
|
||||
SlashCommandStringOption,
|
||||
} from 'discord.js';
|
||||
import { REGIONS } from '../../constants/regions';
|
||||
import { translator } from '../localization/translator';
|
||||
import { localizationObject } from './localizationObject';
|
||||
|
||||
export default function addRegionChoices(option: SlashCommandStringOption) {
|
||||
const { t, translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName('region')
|
||||
.setDescription('The Blitz region you are in')
|
||||
.setName(t`bot.common.options.region`)
|
||||
.setNameLocalizations(localizationObject('bot.common.options.region'))
|
||||
.setDescription(t`bot.common.options.region.description`)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject('bot.common.options.region.description'),
|
||||
)
|
||||
.addChoices(
|
||||
{ name: REGION_NAMES.com, value: 'com' },
|
||||
{ name: REGION_NAMES.eu, value: 'eu' },
|
||||
{ name: REGION_NAMES.asia, value: 'asia' },
|
||||
...REGIONS.map(
|
||||
(region) =>
|
||||
({
|
||||
value: region,
|
||||
name: translate(`common.regions.normal.${region}`),
|
||||
name_localizations: localizationObject(
|
||||
`common.regions.normal.${region}`,
|
||||
),
|
||||
}) satisfies APIApplicationCommandOptionChoice,
|
||||
),
|
||||
)
|
||||
.setRequired(true);
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
import { SlashCommandStringOption } from 'discord.js';
|
||||
import { Locale, SlashCommandStringOption } from 'discord.js';
|
||||
import { translator } from '../localization/translator';
|
||||
import { localizationObject } from './localizationObject';
|
||||
|
||||
export default function addTankChoices(option: SlashCommandStringOption) {
|
||||
const { translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName('tank')
|
||||
.setDescription('The tank to get stats for')
|
||||
.setName(translate('bot.common.options.tank'))
|
||||
.setNameLocalizations(localizationObject('bot.common.options.tank'))
|
||||
.setDescription(translate('bot.common.options.tank.description'))
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject('bot.common.options.tank.description'),
|
||||
)
|
||||
.setAutocomplete(true)
|
||||
.setRequired(true);
|
||||
}
|
||||
@@ -1,16 +1,38 @@
|
||||
import { SlashCommandStringOption } from 'discord.js';
|
||||
import {
|
||||
APIApplicationCommandOptionChoice,
|
||||
Locale,
|
||||
SlashCommandStringOption,
|
||||
} from 'discord.js';
|
||||
import { range } from 'lodash';
|
||||
import { translator } from '../localization/translator';
|
||||
import numberToRomanNumeral from '../math/numberToRomanNumeral';
|
||||
import { localizationObject } from './localizationObject';
|
||||
|
||||
export default function addTierChoices(option: SlashCommandStringOption) {
|
||||
const { t, translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName('tier')
|
||||
.setDescription('The tier you want to see')
|
||||
.setName(t`bot.common.options.tier`)
|
||||
.setNameLocalizations(localizationObject('bot.common.options.tier'))
|
||||
.setDescription(t`bot.common.options.tier.description`)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject('bot.common.options.tier.description'),
|
||||
)
|
||||
.setChoices(
|
||||
...range(10, 0).map((tier) => ({
|
||||
name: `Tier ${tier} - ${numberToRomanNumeral(tier)}`,
|
||||
value: `${tier}`,
|
||||
})),
|
||||
...range(10, 0).map(
|
||||
(tier) =>
|
||||
({
|
||||
name: translate('bot.common.options.tier.choices.tier', [
|
||||
`${tier}`,
|
||||
numberToRomanNumeral(tier),
|
||||
]),
|
||||
value: `${tier}`,
|
||||
name_localizations: localizationObject(
|
||||
'bot.common.options.tier.choices.tier',
|
||||
[`${tier}`, numberToRomanNumeral(tier)],
|
||||
),
|
||||
}) satisfies APIApplicationCommandOptionChoice,
|
||||
),
|
||||
)
|
||||
.setRequired(true);
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import { SlashCommandStringOption } from 'discord.js';
|
||||
import { Locale, SlashCommandStringOption } from 'discord.js';
|
||||
import { translator } from '../localization/translator';
|
||||
import { localizationObject } from './localizationObject';
|
||||
|
||||
export default function addUsernameChoices(option: SlashCommandStringOption) {
|
||||
const { t } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName('username')
|
||||
.setDescription('The username you use in Blitz')
|
||||
.setName(t`bot.common.options.username`)
|
||||
.setNameLocalizations(localizationObject('bot.common.options.username'))
|
||||
.setDescription(t`bot.common.options.username.description`)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject('bot.common.options.username.description'),
|
||||
)
|
||||
.setAutocomplete(true);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
AutocompleteInteraction,
|
||||
CacheType,
|
||||
} from 'discord.js';
|
||||
import { REGION_NAMES_SHORT } from '../../constants/regions';
|
||||
import searchClansAcrossRegions from '../blitz/searchClansAcrossRegions';
|
||||
import { translator } from '../localization/translator';
|
||||
|
||||
export const DISCORD_CHOICES_MAX_NAME_SIZE = 25;
|
||||
export const OVERFLOW_SUFFIX = '…';
|
||||
@@ -12,6 +12,7 @@ export const OVERFLOW_SUFFIX = '…';
|
||||
export default async function autocompleteClan(
|
||||
interaction: AutocompleteInteraction<CacheType>,
|
||||
) {
|
||||
const { translate } = translator(interaction.locale);
|
||||
const focusedOption = interaction.options.getFocused(true);
|
||||
if (focusedOption.name !== 'clan') return;
|
||||
if (focusedOption.value.length < 2) return interaction.respond([]);
|
||||
@@ -20,7 +21,9 @@ export default async function autocompleteClan(
|
||||
await interaction.respond(
|
||||
clans
|
||||
? clans.map((clan) => {
|
||||
let name = `${clan.name} (${REGION_NAMES_SHORT[clan.region]})`;
|
||||
let name = `${clan.name} (${translate(
|
||||
`common.regions.short.${clan.region}`,
|
||||
)})`;
|
||||
|
||||
if (name.length > DISCORD_CHOICES_MAX_NAME_SIZE) {
|
||||
const overSize = name.length - DISCORD_CHOICES_MAX_NAME_SIZE;
|
||||
@@ -28,7 +31,9 @@ export default async function autocompleteClan(
|
||||
name = `${clan.name.slice(
|
||||
0,
|
||||
clan.name.length - overSize - OVERFLOW_SUFFIX.length,
|
||||
)}${OVERFLOW_SUFFIX} (${REGION_NAMES_SHORT[clan.region]})`;
|
||||
)}${OVERFLOW_SUFFIX} (${translate(
|
||||
`common.regions.short.${clan.region}`,
|
||||
)})`;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
AutocompleteInteraction,
|
||||
CacheType,
|
||||
} from 'discord.js';
|
||||
import { REGION_NAMES_SHORT } from '../../constants/regions';
|
||||
import searchPlayersAcrossRegions from '../blitz/searchPlayersAcrossRegions';
|
||||
import { translator } from '../localization/translator';
|
||||
import {
|
||||
DISCORD_CHOICES_MAX_NAME_SIZE,
|
||||
OVERFLOW_SUFFIX,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
export default async function autocompleteUsername(
|
||||
interaction: AutocompleteInteraction<CacheType>,
|
||||
) {
|
||||
const { translate } = translator(interaction.locale);
|
||||
const focusedOption = interaction.options.getFocused(true);
|
||||
if (focusedOption.name !== 'username') return;
|
||||
const players = await searchPlayersAcrossRegions(focusedOption.value);
|
||||
@@ -21,9 +22,9 @@ export default async function autocompleteUsername(
|
||||
await interaction.respond(
|
||||
players
|
||||
? players.map((player) => {
|
||||
let name = `${player.nickname} (${
|
||||
REGION_NAMES_SHORT[player.region]
|
||||
})`;
|
||||
let name = `${player.nickname} (${translate(
|
||||
`common.regions.short.${player.region}`,
|
||||
)})`;
|
||||
|
||||
if (name.length > DISCORD_CHOICES_MAX_NAME_SIZE) {
|
||||
const overSize = name.length - DISCORD_CHOICES_MAX_NAME_SIZE;
|
||||
@@ -31,7 +32,9 @@ export default async function autocompleteUsername(
|
||||
name = `${player.nickname.slice(
|
||||
0,
|
||||
player.nickname.length - overSize - OVERFLOW_SUFFIX.length,
|
||||
)}${OVERFLOW_SUFFIX} (${REGION_NAMES_SHORT[player.region]})`;
|
||||
)}${OVERFLOW_SUFFIX} (${translate(
|
||||
`common.regions.short.${player.region}`,
|
||||
)})`;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { ChatInputCommandInteraction } from 'discord.js';
|
||||
import { translator } from '../localization/translator';
|
||||
import buttonPrimary from './buttonPrimary';
|
||||
|
||||
export function buttonRefresh(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
path: string,
|
||||
) {
|
||||
const { t } = translator(interaction.locale);
|
||||
|
||||
if (
|
||||
interaction.appPermissions?.has('AttachFiles') &&
|
||||
interaction.appPermissions?.has('ReadMessageHistory') &&
|
||||
interaction.appPermissions?.has('ViewChannel')
|
||||
) {
|
||||
return buttonPrimary(path, 'Refresh');
|
||||
return buttonPrimary(path, t`bot.common.buttons.refresh`);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Locale,
|
||||
SlashCommandBuilder,
|
||||
SlashCommandSubcommandBuilder,
|
||||
SlashCommandSubcommandGroupBuilder,
|
||||
} from 'discord.js';
|
||||
import { translator } from '../localization/translator';
|
||||
import { localizationObject } from './localizationObject';
|
||||
|
||||
interface CreateLocalizedCommandSubcommand {
|
||||
subcommand: string;
|
||||
modify?: (option: SlashCommandSubcommandBuilder) => void;
|
||||
}
|
||||
|
||||
interface CreateLocalizedCommandSubcommandGroup {
|
||||
group: string;
|
||||
modify?: (option: SlashCommandSubcommandGroupBuilder) => void;
|
||||
}
|
||||
|
||||
export function createLocalizedCommand(
|
||||
command: string,
|
||||
sub?:
|
||||
| CreateLocalizedCommandSubcommand[]
|
||||
| CreateLocalizedCommandSubcommandGroup[],
|
||||
) {
|
||||
const commandPathItem = command.replaceAll('-', '_');
|
||||
const { translate } = translator(Locale.EnglishUS);
|
||||
const slashCommand = new SlashCommandBuilder()
|
||||
.setName(command)
|
||||
.setNameLocalizations(localizationObject(`bot.commands.${commandPathItem}`))
|
||||
.setDescription(translate(`bot.commands.${commandPathItem}.description`))
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(`bot.commands.${commandPathItem}.description`),
|
||||
);
|
||||
|
||||
if (sub) {
|
||||
sub.map((subItem) => {
|
||||
if ('subcommand' in subItem) {
|
||||
const subcommandNameLocalizations = localizationObject(
|
||||
`bot.commands.${commandPathItem}.subcommands.${subItem.subcommand}`,
|
||||
);
|
||||
const subcommandDescriptionLocalizations = localizationObject(
|
||||
`bot.commands.${commandPathItem}.subcommands.${subItem.subcommand}.description`,
|
||||
);
|
||||
|
||||
slashCommand.addSubcommand((option) => {
|
||||
if (subItem.modify) subItem.modify(option);
|
||||
|
||||
return option
|
||||
.setName(subItem.subcommand)
|
||||
.setNameLocalizations(subcommandNameLocalizations)
|
||||
.setDescription(
|
||||
translate(
|
||||
`bot.commands.${commandPathItem}.subcommands.${subItem.subcommand}.description`,
|
||||
),
|
||||
)
|
||||
.setDescriptionLocalizations(subcommandDescriptionLocalizations);
|
||||
});
|
||||
} else {
|
||||
const subcommandNameLocalizations = localizationObject(
|
||||
`bot.commands.${commandPathItem}.groups.${subItem.group}`,
|
||||
);
|
||||
const subcommandDescriptionLocalizations = localizationObject(
|
||||
`bot.commands.${commandPathItem}.groups.${subItem.group}.description`,
|
||||
);
|
||||
|
||||
slashCommand.addSubcommandGroup((option) => {
|
||||
if (subItem.modify) subItem.modify(option);
|
||||
|
||||
return option
|
||||
.setName(subItem.group)
|
||||
.setNameLocalizations(subcommandNameLocalizations)
|
||||
.setDescription(
|
||||
translate(
|
||||
`bot.commands.${commandPathItem}.groups.${subItem.group}.description`,
|
||||
),
|
||||
)
|
||||
.setDescriptionLocalizations(subcommandDescriptionLocalizations);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return slashCommand;
|
||||
}
|
||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user