feat: start of monorepo

This commit is contained in:
TrèsAbhi committed 2024-09-06 21:45:58 -05:00
1 parent 14f39d71ac
commit 73c7bdf0c0
260 files changed
+1236 -1235

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+52
View File
@@ -0,0 +1,52 @@
import { createVanillaExtractPlugin } from '@vanilla-extract/next-plugin';
const withVanillaExtract = createVanillaExtractPlugin({ tests: /\.css$/ });
/** @type {import('next').NextConfig} */
const nextConfig = {
...withVanillaExtract({
webpack: (config, { isServer }) => {
config.module.rules.push(
{
test: /\.glsl$/,
type: 'asset/source',
},
{
test: /\.proto$/,
type: 'asset/source',
},
);
// if (isServer) {
// writeFileSync(
// 'public/assets/versions.json',
// JSON.stringify(
// readdirSync('docs/changelogs').map((file) =>
// file.replace('.md', ''),
// ),
// ),
// );
// }
return config;
},
}),
async headers() {
return [
{
source: '/(.*)?',
headers: [{ key: 'X-Frame-Options', value: 'DENY' }],
},
];
},
distDir: 'dist/website',
reactStrictMode: true,
experimental: {
workerThreads: false,
},
};
export default nextConfig;
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@blitzkit/website",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev"
},
"dependencies": {
"@radix-ui/themes": "^3.1.3",
"@blitzkit/core": "workspace:*",
"@nivo/bar": "^0.87.0",
"three": "0.159.0",
"lodash": "^4.17.21",
"next": "^14.2.7",
"@radix-ui/react-icons": "^1.3.0",
"@react-three/drei": "^9.111.5",
"@react-three/fiber": "^8.17.6",
"react": "^18.3.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.3.1",
"react-fuzzysort": "^1.0.1",
"immer": "^10.1.1",
"zustand": "^4.5.5",
"react-promise-suspense": "^0.3.4"
},
"devDependencies": {
"@types/lodash": "^4.17.7",
"@vanilla-extract/next-plugin": "^2.4.5",
"@types/react": "^18.3.5"
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
["1.10.0","1.10.1","1.10.2","1.11.0","1.12.0","1.12.1","1.12.2","1.12.3","1.12.4","1.12.5","1.12.6","1.12.7","1.12.8","1.12.9","1.13.0","1.13.1","1.13.2","1.13.3","1.14.0","1.14.1","1.14.2","1.14.3","1.14.4","1.15.0","1.15.1","1.15.10","1.15.11","1.15.12","1.15.13","1.15.2","1.15.3","1.15.4","1.15.5","1.15.6","1.15.7","1.15.8","1.15.9","1.16.0","1.16.1","1.16.2","1.16.3","1.16.4","1.17.0","1.17.1","1.17.2","1.18.0","1.18.1","1.18.2","1.18.3","1.19.0","1.19.1","1.19.2","1.19.3","1.19.4","1.19.5","1.19.6","1.20.0","1.20.1","1.20.2","1.20.3","1.20.4","1.20.5","1.21.0","1.21.1","1.21.2","1.21.3","1.22.0","1.22.1","1.22.2","1.22.3","1.22.4","1.22.5","1.22.6","1.3.0","1.3.1","1.3.2","1.3.3","1.4.0","1.4.1","1.5.0","1.5.1","1.5.2","1.5.3","1.5.4","1.6.0","1.7.0","1.7.1","1.7.2","1.7.3-1.7.4","1.7.5","1.7.6","1.7.7","1.8.0","1.8.1","1.9.0","1.9.1","1.9.2","1.9.3","1.9.4","1.9.5"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

@@ -0,0 +1,298 @@
syntax = "proto3";
package BattleResultsGenerated;
import "PlayerAvatarImage.proto";
message Int32List {
repeated int32 values = 1;
}
message PlayerInfo {
message AvatarEntry {
int32 key = 1;
.PlayerAvatarInfo value = 2;
}
string name = 1;
int32 prebattleID = 2;
int32 team = 3;
int64 clanID = 4;
string clanTag = 5;
repeated int32 chatMessageCount = 6;
repeated .BattleResultsGenerated.PlayerInfo.AvatarEntry avatar = 7;
int32 profileBackground = 8;
int32 vehicleRank = 9;
}
message TeamInfo {
string title = 1;
string icon = 2;
}
message VehiclePublicResults {
message ExtVehAchievementsEntry {
int32 key = 1;
int32 value = 2;
}
message ExtAccAchievementsEntry {
int32 key = 1;
int32 value = 2;
}
int32 health = 1;
int32 credits = 2;
int32 xp = 3;
int32 shots = 4;
int32 directHits = 5;
int32 explosionHits = 6;
int32 piercings = 7;
int32 damageDealt = 8;
int32 damageAssistedRadio = 9;
int32 damageAssistedTrack = 10;
int32 damageReceived = 11;
int32 directHitsReceived = 12;
int32 noDamageDirectHitsReceived = 13;
int32 explosionHitsReceived = 14;
int32 piercingsReceived = 15;
int32 spotted = 16;
int32 damaged = 17;
int32 kills = 18;
int32 tdamageDealt = 19;
int32 tkills = 20;
int32 capturePoints = 21;
int32 droppedCapturePoints = 22;
int32 mileage = 23;
int32 lifeTime = 24;
int32 killerID = 25;
repeated int32 achievements = 26;
repeated .BattleResultsGenerated.VehiclePublicResults.ExtVehAchievementsEntry extVehAchievements = 27;
repeated .BattleResultsGenerated.VehiclePublicResults.ExtAccAchievementsEntry extAccAchievements = 28;
int32 xpDamage = 29;
int32 xpAssist = 30;
int32 xpTeamBonus = 31;
int32 wpEarned = 32;
int32 wpStolen = 33;
int32 heroCreditsBonus = 34;
int32 heroXpBonus = 35;
int64 accountDBID = 101;
int32 team = 102;
int32 serializedVehType = 103;
int32 gold = 104;
int32 deathReason = 105;
int32 camouflage = 106;
float mmr = 107;
float mmrDiff = 108;
int32 calibrationBattlesLeft = 109;
int32 healedHP = 114;
int32 wasHealedFor = 115;
int32 xpActivity = 36;
int32 customization = 116;
int32 potentialEnemyDamage = 117;
int32 rammingDamage = 118;
int32 helpedToKill = 119;
}
message BattleResultsFinalXp {
int32 xpDamagePure = 1;
int32 xpDamagePrem = 2;
int32 xpAssistPure = 3;
int32 xpAssistPrem = 4;
int32 xpActivityPure = 5;
int32 xpActivityPrem = 6;
int32 xpTeamBonusPure = 7;
int32 xpTeamBonusPrem = 8;
int32 healingXpPure = 9;
int32 healingXpPrem = 10;
int32 heroXpBonusPure = 11;
int32 heroXpBonusPrem = 12;
int32 earnedFreeXpPure = 13;
int32 earnedFreeXpPrem = 14;
int32 earnedAvatarXpPure = 15;
int32 earnedAvatarXpPrem = 16;
int32 xpNoModifiers = 17;
int32 xpPure = 18;
int32 xpPrem = 19;
int32 totalFreeXpPure = 20;
int32 totalFreeXpPrem = 21;
int32 totalAvatarXpPure = 22;
int32 totalAvatarXpPrem = 23;
}
message Ribbons {
int32 id = 1;
int32 grade = 2;
int32 amount = 3;
int32 value = 4;
}
message BattleResultsUpdate {
message ExcellentBattleDataEntry {
string key = 1;
int32 value = 2;
}
message StuffEntry {
int32 key = 1;
int32 value = 2;
}
message RatingBattleStuffBonusEntry {
int32 key = 1;
int32 value = 2;
}
int32 avatarXP = 1;
int32 eventCredits = 2;
int32 eventGold = 3;
int32 eventXP = 4;
int32 eventFreeXP = 5;
int32 eventAvatarXP = 6;
int32 autoRepairCost = 7;
repeated int32 autoLoadCost = 8;
repeated int32 autoEquipCost = 9;
repeated int32 autoProvCost = 10;
bool isPremium = 12;
int32 premiumXPFactor10 = 13;
int32 premiumCreditsFactor10 = 14;
int32 xpFactor10 = 15;
int32 creditsFactor10 = 16;
int32 aogasFactor10 = 17;
int32 markOfMastery = 18;
repeated .BattleResultsGenerated.BattleResultsUpdate.ExcellentBattleDataEntry excellentBattleData = 19;
bytes vehDossier = 20;
bytes accDossier = 21;
bytes vehDossierPrev = 22;
bytes accDossierPrev = 23;
int32 autoRepairDiscount = 24;
repeated int32 autoEquipDiscount = 25;
repeated int32 autoProvDiscount = 26;
repeated .BattleResultsGenerated.BattleResultsUpdate.StuffEntry stuff = 27;
int32 boosterCredits = 29;
int32 boosterXp = 30;
int32 boosterFreeXp = 31;
int32 boosterAvatarXp = 32;
int32 complimentsLimit = 33;
repeated int32 autoAbilsCost = 34;
repeated int32 autoAbilsDiscount = 35;
bool hasHealingReward = 36;
int32 healingCredits = 37;
int32 healingXp = 38;
int32 healingFreeXp = 39;
int32 healingAvatarXp = 40;
float mmr = 107;
float mmrDiff = 108;
int32 calibrationBattlesLeft = 109;
int32 ratingBattleXp = 110;
int32 ratingBattleFreeXp = 111;
int32 ratingBattleAvatarXp = 112;
repeated .BattleResultsGenerated.BattleResultsUpdate.RatingBattleStuffBonusEntry ratingBattleStuffBonus = 113;
repeated .BattleResultsGenerated.Ribbons ribbons = 114;
}
message DropData {
message StuffEntry {
int32 key = 1;
int32 value = 2;
}
int32 credits = 1;
int32 gold = 2;
int32 xp = 3;
int32 freeXP = 4;
int32 avatarXP = 5;
repeated .BattleResultsGenerated.DropData.StuffEntry stuff = 27;
}
message VehicleInteractionDetails {
message DamagedModulesEntry {
int32 key = 1;
int32 value = 2;
}
message DestroyedModulesEntry {
int32 key = 1;
int32 value = 2;
}
bool spotted = 1;
int32 deathReason = 2;
int32 directHits = 3;
int32 directHitWithoutDamage = 4;
int32 explosionHits = 5;
int32 piercings = 6;
int32 damageDealt = 7;
int32 damageAssistedTrack = 8;
int32 damageAssistedRadio = 9;
int32 fire = 10;
repeated .BattleResultsGenerated.VehicleInteractionDetails.DamagedModulesEntry damagedModules = 11;
repeated .BattleResultsGenerated.VehicleInteractionDetails.DestroyedModulesEntry destroyedModules = 12;
int32 ricochetHitWithoutDamage = 13;
}
message InteractionDetails {
message VehiclesEntry {
int32 key = 1;
.BattleResultsGenerated.VehicleInteractionDetails value = 2;
}
repeated .BattleResultsGenerated.InteractionDetails.VehiclesEntry vehicles = 1;
}
message PrebattleStats {
int32 freeXp = 1;
int32 vehicleXp = 2;
}
message BattleResultsClient {
message QuestsProgressEntry {
string key = 1;
.BattleResultsGenerated.Int32List value = 2;
}
message DropCategoriesEntry {
string key = 1;
.BattleResultsGenerated.DropData value = 2;
}
message PlayersEntry {
int64 key = 1;
.BattleResultsGenerated.PlayerInfo value = 2;
}
message VehiclesEntry {
int32 key = 1;
.BattleResultsGenerated.VehiclePublicResults value = 2;
}
int32 arenaTypeID = 1;
int32 arenaCreateTime = 2;
int32 winnerTeam = 3;
int32 finishReason = 4;
int32 duration = 5;
int32 bonusType = 6;
int32 vehLockMode = 7;
.BattleResultsGenerated.VehiclePublicResults public = 8;
int32 gameModeID = 9;
int32 observerMode = 10;
.BattleResultsGenerated.BattleResultsFinalXp finalXp = 11;
int32 repair = 136;
int32 freeXP = 137;
int32 xpPenalty = 145;
int32 creditsPenalty = 146;
int32 creditsContributionIn = 147;
int32 creditsContributionOut = 148;
.BattleResultsGenerated.BattleResultsUpdate update = 150;
int32 originalXP = 181;
int32 originalFreeXP = 182;
int32 originalCredits = 183;
repeated .BattleResultsGenerated.BattleResultsClient.QuestsProgressEntry questsProgress = 184;
repeated .BattleResultsGenerated.BattleResultsClient.DropCategoriesEntry dropCategories = 185;
.BattleResultsGenerated.TeamInfo teamInfo = 186;
repeated .BattleResultsGenerated.BattleResultsClient.PlayersEntry players = 201;
repeated .BattleResultsGenerated.BattleResultsClient.VehiclesEntry vehicles = 301;
.BattleResultsGenerated.InteractionDetails interactionDetails = 302;
.BattleResultsGenerated.PrebattleStats prebattleStats = 303;
}
@@ -0,0 +1,8 @@
syntax = "proto3";
message PlayerAvatarInfo {
uint32 id = 1;
string image = 2;
string image_retina = 3;
string grade = 4;
}
@@ -0,0 +1,47 @@
syntax = "proto3";
package blitzkit;
message AverageDefinitions {
map<uint32, AverageDefinitionsEntry> averages = 1;
Samples samples = 2;
uint64 time = 3;
}
message AverageDefinitionsEntry {
AverageDefinitionsAllStats mu = 1;
AverageDefinitionsAllStats sigma = 2;
AverageDefinitionsAllStats r = 3;
Samples samples = 4;
}
message AverageDefinitionsAllStats {
float battles = 1;
float capture_points = 2;
float damage_dealt = 3;
float damage_received = 4;
float dropped_capture_points = 5;
float frags = 6;
float frags8p = 7;
float hits = 8;
float losses = 9;
float max_frags = 10;
float max_xp = 11;
float shots = 12;
float spotted = 13;
float survived_battles = 14;
float win_and_survived = 15;
float wins = 16;
float xp = 17;
float battle_life_time = 18;
}
message Samples {
uint32 d_1 = 1;
uint32 d_7 = 2;
uint32 d_30 = 3;
uint32 d_60 = 4;
uint32 d_90 = 5;
uint32 d_120 = 6;
uint32 total = 7;
}
@@ -0,0 +1,17 @@
syntax = "proto3";
package blitzkit;
message Reviews {
map<uint32, Review> reviews = 1;
}
message Review {
float last_updated = 1;
repeated Video videos = 2;
}
message Video {
string id = 1;
string author = 2;
}
+7
View File
@@ -0,0 +1,7 @@
User-agent: *
Allow: /
Host: https://blitzkit.app
Sitemap: https://blitzkit.app/sitemap.txt
Sitemap: https://blitzkit.app/sitemap.xml
@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { Region } from '../../../../../../constants/regions';
import getRatingInfo from '../../../../../../core/blitz/getRatingInfo';
export async function GET(
request: Request,
{ params }: { params: { region: Region } },
) {
return NextResponse.json(await getRatingInfo(params.region));
}
@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { Region } from '../../../../../../../constants/regions';
import { getRatingLeague } from '../../../../../../../core/blitz/getRatingLeague';
export async function GET(
request: Request,
{ params }: { params: { region: Region; league: number } },
) {
return NextResponse.json(
await getRatingLeague(params.region, params.league, true),
);
}
@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { Region } from '../../../../../../../constants/regions';
import { searchCurrentRatingPlayers } from '../../../../../../../core/blitz/searchCurrentRatingPlayers';
export async function GET(
request: Request,
{ params }: { params: { region: Region; username: string } },
) {
return NextResponse.json(
await searchCurrentRatingPlayers(params.region, params.username),
);
}
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { assertSecret } from '../../../../../core/blitzkit/secret';
export async function GET(
request: Request,
{ params }: { params: { code: string } },
) {
const response = await fetch(
`https://www.patreon.com/api/oauth2/token?code=${
params.code
}&grant_type=authorization_code&client_id=${assertSecret(
process.env.NEXT_PUBLIC_PATREON_CLIENT_ID,
)}&client_secret=${assertSecret(
process.env.PATREON_CLIENT_SECRET,
)}&redirect_uri=${assertSecret(process.env.NEXT_PUBLIC_PATREON_REDIRECT_URI)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
},
);
const json = await response.json();
return NextResponse.json(json);
}
@@ -0,0 +1,76 @@
import { NextResponse } from 'next/server';
import { assertSecret } from '../../../../../core/blitzkit/secret';
interface PatreonMembershipResponse {
data: {
attributes: unknown;
id: `${number}`;
relationships: {
memberships: {
data: {
id: string; // uuid
type: 'member';
}[];
};
};
type: 'user';
};
included: (
| {
attributes: {
patron_status:
| 'active_patron'
| 'declined_patron'
| 'former_patron'
| null; // null = never a paid member
};
id: string; // uuid
relationships: {
campaign: {
data: {
id: `${number}`; // id of my campaign to check against
type: 'campaign';
};
links: {
related: string; // url
};
};
};
type: 'member';
}
| {
attributes: {};
id: `${number}`;
type: 'campaign'; // ignore this discriminator
}
)[];
links: {
self: 'https://www.patreon.com/api/oauth2/v2/user/124201389';
};
}
export async function GET(
request: Request,
{ params }: { params: { token: string } },
) {
const response = await fetch(
encodeURI(
'https://www.patreon.com/api/oauth2/v2/identity?include=memberships.campaign&fields[member]=patron_status',
),
{ headers: { Authorization: `Bearer ${params.token}` } },
);
if (!response.ok) return NextResponse.json(false);
const json = (await response.json()) as PatreonMembershipResponse;
return NextResponse.json(
json.included.some(
(item) =>
item.type === 'member' &&
item.relationships.campaign.data.id ===
assertSecret(process.env.PATREON_CAMPAIGN_ID) &&
item.attributes.patron_status === 'active_patron',
),
);
}
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { assertSecret } from '../../../../../core/blitzkit/secret';
export async function GET(
request: Request,
{ params }: { params: { token: string } },
) {
const response = await fetch(
`https://www.patreon.com/api/oauth2/token?grant_type=refresh_token&refresh_token=${
params.token
}&client_id=${assertSecret(
process.env.NEXT_PUBLIC_PATREON_CLIENT_ID,
)}&client_secret=${assertSecret(process.env.PATREON_CLIENT_SECRET)}`,
{ method: 'POST' },
);
const json = await response.json();
return NextResponse.json(json);
}
@@ -0,0 +1,6 @@
import { NextResponse } from 'next/server';
import { tankAverages } from '../../../core/blitzstars/tankAverages';
export async function GET() {
return NextResponse.json(await tankAverages);
}
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import { isValidBlitzId } from '../../../../../core/blitz/isValidBlitzId';
import { tankDefinitions } from '../../../../../core/blitzkit/tankDefinitions';
import { usersDatabase } from '../../../../../databases/users';
import {
BlitzkitResponse,
BlitzkitResponseError,
} from '../../../../../hooks/useTankVotes';
import { TankVoteCategory } from '../cast/route';
export interface TankVotes {
categories: Record<TankVoteCategory, number | null>;
votes: number;
last_updated?: number;
}
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } },
) {
const id = Number(params.id);
const awaitedTankDefinitions = await tankDefinitions;
if (isNaN(id) || !awaitedTankDefinitions[id]) {
return NextResponse.json({
status: 'error',
error: 'INVALID_TANK_ID',
} satisfies BlitzkitResponseError);
}
const blitzIdString = request.nextUrl.searchParams.get('player');
const blitzId = Number(blitzIdString);
const blitzToken = request.nextUrl.searchParams.get('token');
if ((blitzIdString === null) !== (blitzToken === null)) {
return NextResponse.json({
status: 'error',
error: 'UNDEFINED_TOKEN_OR_PLAYER_ID',
} satisfies BlitzkitResponseError);
}
if (
blitzIdString !== null &&
(!(await isValidBlitzId(blitzId, blitzToken ?? undefined)) ||
isNaN(blitzId))
) {
return NextResponse.json({
status: 'error',
error: 'INVALID_TOKEN_OR_PLAYER_ID',
} satisfies BlitzkitResponseError);
}
try {
const aggregation = await usersDatabase.tankVote.aggregate({
where: { id },
_count: { id: true },
_avg: {
easiness: true,
firepower: true,
maneuverability: true,
survivability: true,
},
});
const userVote = await usersDatabase.tankVote.findUnique({
where: { id, user_blitz_id: blitzId },
select: { last_updated: true },
});
return NextResponse.json({
status: 'ok',
data: {
categories: aggregation._avg,
votes: aggregation._count.id,
last_updated: userVote?.last_updated.getTime(),
},
} satisfies BlitzkitResponse<TankVotes>);
} catch (message) {
return NextResponse.json({
status: 'error',
error: 'DATABASE_ERROR',
message,
} satisfies BlitzkitResponseError);
}
}
@@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from 'next/server';
import { isValidBlitzId } from '../../../../../core/blitz/isValidBlitzId';
import { tankDefinitions } from '../../../../../core/blitzkit/tankDefinitions';
import { usersDatabase } from '../../../../../databases/users';
import {
BlitzkitResponse,
BlitzkitResponseError,
} from '../../../../../hooks/useTankVotes';
import { StarsInt } from '../../../../tools/tankopedia/[id]/components/Model/MetaSection/components/Stars';
export const TANK_VOTE_CATEGORIES = [
'easiness',
'firepower',
'maneuverability',
'survivability',
];
export type TankVoteCategory = (typeof TANK_VOTE_CATEGORIES)[number];
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } },
) {
const tankId = Number(params.id);
const awaitedTankDefinitions = await tankDefinitions;
if (isNaN(tankId) || !awaitedTankDefinitions[tankId]) {
return NextResponse.json({
status: 'error',
error: 'INVALID_TANK_ID',
} satisfies BlitzkitResponseError);
}
const blitzIdString = request.nextUrl.searchParams.get('player');
const blitzId = Number(blitzIdString);
const blitzToken = request.nextUrl.searchParams.get('token');
if (blitzIdString === null || blitzToken === null) {
return NextResponse.json({
status: 'error',
error: 'UNDEFINED_TOKEN_OR_PLAYER_ID',
} satisfies BlitzkitResponseError);
}
if (
blitzIdString !== null &&
(!(await isValidBlitzId(blitzId, blitzToken ?? undefined)) ||
isNaN(blitzId))
) {
return NextResponse.json({
status: 'error',
error: 'INVALID_TOKEN_OR_PLAYER_ID',
} satisfies BlitzkitResponseError);
}
const votes: Partial<Record<TankVoteCategory, StarsInt>> = {};
let recognizedVoteCategories = 0;
for (const category of TANK_VOTE_CATEGORIES) {
const voteRaw = request.nextUrl.searchParams.get(category);
if (voteRaw === null) continue;
const vote = Number(voteRaw);
if (isNaN(vote) || !Number.isInteger(vote) || vote < 1 || vote > 5) {
return NextResponse.json({
status: 'error',
error: 'INVALID_VOTE',
} satisfies BlitzkitResponseError);
}
votes[category] = vote as StarsInt;
recognizedVoteCategories++;
}
if (recognizedVoteCategories === 0) {
return NextResponse.json({
status: 'error',
error: 'NO_VOTES',
} satisfies BlitzkitResponseError);
}
try {
await usersDatabase.user.upsert({
where: { blitz_id: blitzId },
update: {
last_used: new Date(),
tank_votes: {
upsert: {
where: { id: tankId },
update: { ...votes, last_updated: new Date() },
create: { ...votes, id: tankId, last_updated: new Date() },
},
},
},
create: {
blitz_id: blitzId,
last_used: new Date(),
tank_votes: {
create: { ...votes, id: tankId, last_updated: new Date() },
},
},
});
return NextResponse.json({
status: 'ok',
data: undefined,
} satisfies BlitzkitResponse);
} catch (message) {
return NextResponse.json({
status: 'error',
error: 'DATABASE_ERROR',
message,
} satisfies BlitzkitResponseError);
}
}
@@ -0,0 +1,91 @@
'use client';
import { Flex, Heading, Text } from '@radix-ui/themes';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import PageWrapper from '../../../components/PageWrapper';
import * as App from '../../../stores/app';
export interface PatreonAuthResponse {
access_token: string;
expires_in: number;
token_type: 'Bearer';
scope: string;
refresh_token: string;
version: string;
}
export default function Page({
params,
searchParams,
}: {
params: { provider: string };
searchParams: { [key: string]: string };
}) {
const router = useRouter();
const mutateApp = App.useMutation();
useEffect(() => {
switch (params.provider) {
case 'wargaming': {
if (
!searchParams.expires_at ||
!searchParams.access_token ||
!searchParams.account_id
) {
break;
}
mutateApp((draft) => {
draft.logins.wargaming = {
id: Number(searchParams.account_id),
token: searchParams.access_token,
expires: Number(searchParams.expires_at) * 1000,
};
});
break;
}
case 'patreon': {
if (!searchParams.code) break;
// cannot await here because async breaks router.push :(
fetch(`/api/patreon/auth/${searchParams.code}`)
.then((response) => response.json() as Promise<PatreonAuthResponse>)
.then((data) => {
mutateApp((draft) => {
draft.logins.patreon = {
token: data.access_token,
refreshToken: data.refresh_token,
expires: Date.now() + data.expires_in * 1000,
};
});
});
break;
}
default: {
console.error(`Unknown provider: ${params.provider}`);
}
}
router.push(searchParams.return ?? '/');
});
return (
<PageWrapper>
<Flex
style={{ flex: 1 }}
align="center"
justify="center"
direction="column"
gap="2"
>
<Heading>Verifying you...</Heading>
<Text color="gray">You'll be on your way shortly.</Text>
</Flex>
</PageWrapper>
);
}
@@ -0,0 +1,193 @@
'use client';
import { Button, Flex, Heading, Link, Text } from '@radix-ui/themes';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { idToRegion } from '@blitzkit/core';
import * as App from '../../stores/app';
import { CURRENT_POLICIES_AGREEMENT_INDEX } from '../../stores/app/constants';
import { PatreonAuthResponse } from '../auth/[provider]/page';
type Extension =
| {
status: 'ok';
data: {
access_token: string;
account_id: number;
expires_at: number;
};
}
| {
status: 'error';
};
/**
* Wargaming: 14 (refresh: 7)
* Patreon: 31 (refresh: 15)
*/
export function Checks() {
const mutateApp = App.useMutation();
const logins = App.use((state) => state.logins);
const appStore = App.useStore();
const policiesAgreementIndex = App.use(
(state) => state.policiesAgreementIndex,
);
const pathname = usePathname();
const isLegal = pathname.startsWith('/legal');
const [showPoliciesAgreement, setShowPoliciesAgreement] = useState(false);
useEffect(() => {
function unsubscribe() {
window.removeEventListener('scroll', show);
window.removeEventListener('pointermove', show);
window.removeEventListener('pointerdown', show);
}
function show() {
setShowPoliciesAgreement(true);
unsubscribe();
}
if (
policiesAgreementIndex !== CURRENT_POLICIES_AGREEMENT_INDEX &&
!isLegal
) {
window.addEventListener('scroll', show);
window.addEventListener('pointermove', show);
window.addEventListener('pointerdown', show);
}
return unsubscribe;
}, [policiesAgreementIndex, isLegal]);
useEffect(() => {
if (logins.wargaming) {
const expiresIn = logins.wargaming.expires - Date.now();
const expiresInDays = expiresIn / 1000 / 60 / 60 / 24;
if (expiresInDays < 0) {
mutateApp((draft) => {
draft.logins.wargaming = undefined;
});
} else if (expiresInDays < 7) {
const { wargaming } = appStore.getState().logins;
if (!wargaming) return;
fetch(
`https://api.worldoftanks.${idToRegion(
wargaming.id,
)}/wot/auth/prolongate/?application_id=${assertSecret(process.env.WARGAMING_APPLICATION_ID)}&access_token=${
wargaming.token
}`,
)
.then((response) => response.json() as Promise<Extension>)
.then((json) => {
mutateApp((draft) => {
if (json.status === 'error') {
draft.logins.wargaming = undefined;
} else {
draft.logins.wargaming = {
id: wargaming.id,
token: json.data.access_token,
expires: json.data.expires_at * 1000,
};
}
});
});
}
}
if (logins.patreon) {
const expiresIn = logins.patreon.expires - Date.now();
const expiresInDays = expiresIn / 1000 / 60 / 60 / 24;
if (expiresInDays < 0) {
mutateApp((draft) => {
draft.logins.patreon = undefined;
});
} else if (expiresInDays < 15) {
const { patreon } = appStore.getState().logins;
if (!patreon) return;
fetch(`/api/patreon/refresh/${patreon.refreshToken}`)
.then((response) => response.json() as Promise<PatreonAuthResponse>)
.then((data) => {
mutateApp((draft) => {
draft.logins.patreon = {
token: data.access_token,
refreshToken: data.refresh_token,
expires: Date.now() + data.expires_in * 1000,
};
});
});
}
}
});
if (!showPoliciesAgreement) return null;
return (
<Flex
align="end"
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'var(--color-overlay)',
zIndex: 2,
}}
>
<Flex
p="8"
style={{
width: '100%',
background: 'var(--color-panel-solid)',
}}
justify="center"
>
<Flex
align="start"
gap="4"
style={{
maxWidth: 640 * 2,
}}
direction="column"
>
<Flex direction="column" gap="2">
<Heading>
{policiesAgreementIndex === -1
? "BlitzKit's policies"
: "BlitzKit's policies have changed"}
</Heading>
<Text>
This website utilizes cookies to perform analytics and personalize
your experience. You can learn more through{' '}
<Link href="/docs/legal/privacy-policy">our privacy policy</Link>.
You may opt out of personalized advertizement by visiting{' '}
<Link href="https://optout.aboutads.info/">WebChoices</Link>. By
using BlitzKit, you also agree to our{' '}
<Link href="/docs/legal/terms-of-service">terms of service</Link>.
</Text>
</Flex>
<Button
mt="2"
onClick={() => {
appStore.setState({
policiesAgreementIndex: CURRENT_POLICIES_AGREEMENT_INDEX,
});
setShowPoliciesAgreement(false);
}}
>
I agree
</Button>
</Flex>
</Flex>
</Flex>
);
}
@@ -0,0 +1,4 @@
export const BRANCH_NAMES: Record<string, string> = {
dev: 'dev',
opentest: 'opentest',
};
@@ -0,0 +1,69 @@
import { imgur } from '@blitzkit/core';
import { Code, Flex, Heading, Text } from '@radix-ui/themes';
import { BRANCH_NAMES } from './constants';
export function Hero() {
const isBranchNamed =
process.env.NEXT_PUBLIC_ASSET_BRANCH &&
BRANCH_NAMES[process.env.NEXT_PUBLIC_ASSET_BRANCH];
return (
<Flex
align="center"
justify="center"
style={{
position: 'relative',
background: `url(${imgur('rUPie9G', { format: 'jpeg' })})`,
backgroundSize: 'cover',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
}}
px="4"
py="9"
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background:
'linear-gradient(var(--color-panel-translucent), var(--color-background))',
}}
/>
<Flex
direction="column"
gap="6"
align="center"
justify="center"
position="relative"
width="100%"
>
<Flex direction="column" align="center" gap="1">
<Heading size="9" weight="bold" align="center">
<Flex
align="center"
gap={{ initial: '1', xs: '4' }}
direction={{ initial: 'column', xs: 'row' }}
>
BlitzKit
{isBranchNamed && (
<Code size="8" color="gray" highContrast variant="outline">
{BRANCH_NAMES[process.env.NEXT_PUBLIC_ASSET_BRANCH!]}
</Code>
)}
</Flex>
</Heading>
{!isBranchNamed && (
<Text color="gray" align="center" size="4">
Everything World of Tanks Blitz
</Text>
)}
</Flex>
</Flex>
</Flex>
);
}
@@ -0,0 +1,9 @@
import { Flex, Spinner } from '@radix-ui/themes';
export function PageLoader() {
return (
<Flex align="center" justify="center" flexGrow="1">
<Spinner />
</Flex>
);
}
@@ -0,0 +1,88 @@
'use client';
import { assertSecret, patreonLoginUrl } from '@blitzkit/core';
import { ArrowRightIcon } from '@radix-ui/react-icons';
import {
Badge,
Button,
Code,
Flex,
Link,
Separator,
Text,
} from '@radix-ui/themes';
import { useLayoutEffect, useState } from 'react';
import { PatreonIcon } from '../../components/PatreonIcon';
import { useAdExempt } from '../../hooks/useAdExempt';
import * as App from '../../stores/app';
export function Plugs() {
const [hasPatreon, setHasPatreon] = useState(false);
const exempt = useAdExempt();
const patreon = App.use((state) => state.logins.patreon);
useLayoutEffect(() => {
setHasPatreon(patreon !== undefined);
}, [patreon]);
return (
<Flex
align="center"
direction="column"
justify="center"
px="4"
py="8"
gap="6"
>
{assertSecret(process.env.NEXT_PUBLIC_PROMOTE_OPENTEST) === 'true' &&
assertSecret(process.env.NEXT_PUBLIC_ASSET_BRANCH) !== 'opentest' && (
<>
<Flex direction="column" gap="3" align="center">
<Flex align="center" gap="2">
<Badge color="green">NEW</Badge>
<Text size="5">
BlitzKit{' '}
<Code variant="outline" size="4" color="gray" highContrast>
opentest
</Code>{' '}
is available now{' '}
</Text>
</Flex>
<Link href="https://opentest.blitzkit.app/" target="_blank">
<Button color="green">
BlitzKit opentest <ArrowRightIcon />
</Button>
</Link>
</Flex>
<Separator size="3" />
</>
)}
<Flex direction="column" gap="3" align="center">
<Flex gap="1" align="center">
<Text size="4" align="center">
{exempt
? 'You are supporting BlitzKit, thank you!'
: 'Active Patreon supports go ad-free'}
</Text>
</Flex>
<Flex gap="2" wrap="wrap" justify="center">
<Link href="https://www.patreon.com/tresabhi" target="_blank">
<Button>
<PatreonIcon width={15} height={15} />{' '}
{exempt ? 'Manage membership' : 'Support on Patreon'}
</Button>
</Link>
{!hasPatreon && !exempt && (
<Link href={patreonLoginUrl()}>
<Button variant="outline">Log in with Patreon</Button>
</Link>
)}
</Flex>
</Flex>
</Flex>
);
}
@@ -0,0 +1,128 @@
import { assertSecret, imgur, ImgurSize } from '@blitzkit/core';
import { CaretRightIcon } from '@radix-ui/react-icons';
import {
Box,
Button,
Code,
Flex,
FlexProps,
Heading,
Text,
} from '@radix-ui/themes';
import { Link } from '../../components/Link';
import { Tool } from '../../constants/tools';
type ToolCardProps = FlexProps & {
tool: Tool;
};
export function ToolCard({ tool, style, ...props }: ToolCardProps) {
const size = tool.significant
? ImgurSize.HugeThumbnail
: ImgurSize.LargeThumbnail;
const unavailableOnBranch = tool.branches?.every(
(branch) => branch !== assertSecret(process.env.NEXT_PUBLIC_ASSET_BRANCH),
);
return (
<Flex
style={{
opacity: unavailableOnBranch ? 0.25 : 1,
position: 'relative',
borderRadius: 'var(--radius-2)',
overflow: 'hidden',
backgroundImage: `url(${imgur(tool.image, { format: 'jpeg', size })})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
height: tool.significant ? 'min(320px, 50vh)' : '256px',
...style,
}}
flexGrow={tool.significant ? undefined : '1'}
flexBasis={tool.significant ? undefined : { initial: undefined, sm: '0' }}
{...props}
>
<Link
disabled={unavailableOnBranch}
href={tool.href ?? `/tools/${tool.id}`}
target={tool.href ? '_blank' : undefined}
style={{
display: 'flex',
width: '100%',
height: '100%',
flexDirection: 'column',
textDecoration: 'none',
color: 'inherit',
}}
>
<Box
flexGrow="1"
style={{
backgroundImage: `url(${imgur(tool.image, { format: 'jpeg', size })})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<Flex
px={{ initial: '6', md: tool.significant ? '8' : '6' }}
py={{ initial: '4', sm: tool.significant ? '6' : '4' }}
gap="4"
align="center"
justify="between"
width="100%"
direction={{ initial: 'column', xs: 'row' }}
style={{
backgroundColor: 'var(--color-panel-translucent)',
backdropFilter: 'blur(64px)',
WebkitBackdropFilter: 'blur(64px)',
}}
>
<Flex
direction="column"
justify="center"
align={{ initial: 'center', xs: 'start' }}
>
<Heading
align={{ initial: 'center', sm: 'left' }}
size={{ initial: '6', sm: tool.significant ? '7' : '5' }}
weight="medium"
>
{tool.title}
</Heading>
<Text
align={{ initial: 'center', sm: 'left' }}
size={{ initial: '3', sm: tool.significant ? '4' : '3' }}
color="gray"
>
{unavailableOnBranch ? (
<>
Unavailable on{' '}
<Code>
{assertSecret(process.env.NEXT_PUBLIC_ASSET_BRANCH)}
</Code>
</>
) : (
tool.description
)}
</Text>
</Flex>
{!unavailableOnBranch && (
<Button
highContrast={tool.button.highContrast}
size={{
initial: undefined,
sm: tool.significant ? '3' : undefined,
}}
color={tool.button.color}
style={{ cursor: 'inherit' }}
>
{tool.button.text}
<CaretRightIcon />
</Button>
)}
</Flex>
</Link>
</Flex>
);
}
@@ -0,0 +1,45 @@
import { Table } from '@radix-ui/themes';
import Markdown from 'markdown-to-jsx';
import { use } from 'react';
import { Link } from '../../../../components/Link';
import PageWrapper from '../../../../components/PageWrapper';
import { assertSecret } from '../../../../core/blitzkit/secret';
export default function Page({
params,
}: {
params: { document: string; directory: string };
}) {
const markdown = use(
fetch(
`https://raw.githubusercontent.com/tresabhi/blitzkit/${assertSecret(
process.env.NEXT_PUBLIC_ASSET_BRANCH,
)}/docs/${params.directory}/${params.document}.md`,
).then((response) => response.text()),
);
const title = markdown.split('\n')[0].replaceAll(/[^a-zA-Z0-9 \.&]/g, '');
return (
<>
<title>{title}</title>
<PageWrapper>
<Markdown
options={{
overrides: {
a: { component: Link },
table: { component: Table.Root },
thead: { component: Table.Header },
tbody: { component: Table.Body },
tr: { component: Table.Row },
th: { component: Table.ColumnHeaderCell },
td: { component: Table.Cell },
},
}}
>
{markdown}
</Markdown>
</PageWrapper>
</>
);
}
@@ -0,0 +1,29 @@
import { Flex, Heading } from '@radix-ui/themes';
import { compareVersions } from 'compare-versions';
import versions from '../../../../public/assets/versions.json';
import { Link } from '../../../components/Link';
import PageWrapper from '../../../components/PageWrapper';
export default async function Page() {
return (
<>
<title>BlitzKit - Patch Notes</title>
<PageWrapper>
<Heading>BlitzKit patch notes</Heading>
<Flex wrap="wrap" gap="4">
{versions
.sort(compareVersions)
.reverse()
.map((version) => {
return (
<Link key={version} href={`/docs/changelogs/${version}`}>
{version}
</Link>
);
})}
</Flex>
</PageWrapper>{' '}
</>
);
}
+8
View File
@@ -0,0 +1,8 @@
*,
html {
scroll-behavior: smooth;
}
.radix-themes {
--default-font-family: var(--font-inter);
}
+80
View File
@@ -0,0 +1,80 @@
'use client';
import { Flex, Theme } from '@radix-ui/themes';
import '@radix-ui/themes/styles.css';
import { Roboto_Flex } from 'next/font/google';
import { usePathname } from 'next/navigation';
import { ReactNode, Suspense } from 'react';
import { Footer } from '../components/Footer';
import { Navbar } from '../components/Navbar';
import { Party3 } from '../components/Party3';
import * as App from '../stores/app';
import { Checks } from './components/Checks';
import { PageLoader } from './components/PageLoader';
import './layout.css';
const robotoFlex = Roboto_Flex({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-flex',
});
export default function Layout({ children }: { children: ReactNode }) {
const pathname = usePathname();
const hideNav = false;
const isRoot = pathname === '/';
return (
<App.Provider>
<html lang="en" className={robotoFlex.className}>
<head>
{isRoot && (
<>
<title>BlitzKit</title>
<meta
name="description"
content="🎉 Tools for everything World of Tanks Blitz"
/>
</>
)}
<meta property="og:site_name" content="BlitzKit" />
<link rel="icon" type="image/x-icon" href="/favicon.png" />
<Party3 />
</head>
<body
style={{
margin: 0,
backgroundColor: hideNav ? 'transparent' : undefined,
}}
>
<Theme
appearance="dark"
panelBackground="solid"
radius="full"
accentColor="amber"
suppressHydrationWarning
suppressContentEditableWarning
>
<Flex
direction="column"
style={{
minHeight: '100vh',
// paddingTop: isEmbed ? undefined : NAVBAR_HEIGHT,
}}
>
{!hideNav && <Navbar />}
<Checks />
<Suspense fallback={<PageLoader />}>{children}</Suspense>
{!hideNav && <Footer />}
</Flex>
</Theme>
</body>
</html>
</App.Provider>
);
}
+44
View File
@@ -0,0 +1,44 @@
'use client';
import { DiscordLogoIcon, HomeIcon } from '@radix-ui/react-icons';
import { Button, Flex, Heading, Text } from '@radix-ui/themes';
import Link from 'next/link';
import { redirect, usePathname } from 'next/navigation';
import PageWrapper from '../components/PageWrapper';
const redirects = new Map([
['/tools/ratings', '/tools/rating'],
['/tools/tank-performance', '/tools/performance'],
]);
export default function Page() {
const pathname = usePathname();
redirects.forEach((to, from) => {
if (pathname.startsWith(from)) {
redirect(to);
}
});
return (
<PageWrapper>
<Flex direction="column" justify="center" align="center" flexGrow="1">
<Heading>404: page not found</Heading>
<Text color="gray">Where would you like to go?</Text>
<Flex mt="4" gap="2">
<Link href="/">
<Button>
<HomeIcon /> Home
</Button>
</Link>
<Link href="https://discord.gg/nDt7AjGJQH">
<Button variant="outline">
<DiscordLogoIcon /> Discord
</Button>
</Link>
</Flex>
</Flex>
</PageWrapper>
);
}
+106
View File
@@ -0,0 +1,106 @@
'use client';
import { Flex, Heading } from '@radix-ui/themes';
import { AdMidSectionResponsive } from '../components/AdMidSectionResponsive';
import {
compareTool,
discordTool,
moreTool,
ratingTool,
sessionTool,
tankopediaTool,
tankPerformanceTool,
} from '../constants/tools';
import { useAdExempt } from '../hooks/useAdExempt';
import { Hero } from './components/Hero';
import { Plugs } from './components/Plugs';
import { ToolCard } from './components/ToolCard';
export default function Page() {
const exempt = useAdExempt();
return (
<>
<Hero />
<Plugs />
<Flex p="4" justify="center" gap="4">
<Flex maxWidth="1024px" flexGrow="1" gap="4" direction="column">
<ToolCard tool={tankopediaTool} />
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
{/* <ToolCard tool={playerStatsTool} /> */}
<ToolCard tool={compareTool} />
<ToolCard tool={tankPerformanceTool} />
</Flex>
{!exempt && (
<Flex justify="center">
<AdMidSectionResponsive />
</Flex>
)}
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
<ToolCard tool={sessionTool} />
{/* <ToolCard tool={embedTool} /> */}
<ToolCard tool={ratingTool} />
</Flex>
{!exempt && (
<Flex
justify="center"
display={{
initial: 'flex',
md: 'none',
}}
>
<AdMidSectionResponsive />
</Flex>
)}
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
<ToolCard tool={discordTool} />
<ToolCard tool={moreTool} />
</Flex>
{!exempt && <AdMidSectionResponsive />}
<Heading>Other cool projects</Heading>
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
<ToolCard
tool={{
button: {
text: 'Analyze',
color: 'blue',
},
description: 'Rich historic average stats',
id: 'blitz-analysis',
image: 'j76YXGl',
title: 'BlitzAnalysis[]',
href: 'https://blitzanalysiz.com/',
}}
/>
<ToolCard
tool={{
button: {
color: 'purple',
text: 'Install',
highContrast: true,
},
image: 'zYrrYbR',
description: 'In-game session stats',
id: 'blitz-insider',
title: 'Blitz Insider',
href: 'https://discord.gg/UjWf9eGgtR',
}}
/>
</Flex>
</Flex>
</Flex>
<div style={{ flex: 1 }} />
</>
);
}
@@ -0,0 +1,30 @@
'use client';
import { Flex, Heading, Switch, Text } from '@radix-ui/themes';
import PageWrapper from '../../components/PageWrapper';
import * as App from '../../stores/app';
export default function Page() {
const developerMode = App.useDeferred(false, (state) => state.developerMode);
const appStore = App.useStore();
return (
<PageWrapper justify="center" align="center">
<Flex direction="column" gap="4">
<Flex gap="3" direction="column">
<Heading size="5">Advanced</Heading>
<Flex align="center" gap="2">
<Text>Developer mode</Text>
<Switch
checked={developerMode}
onCheckedChange={(checked) =>
appStore.setState({ developerMode: checked })
}
/>
</Flex>
</Flex>
</Flex>
</PageWrapper>
);
}
@@ -0,0 +1,420 @@
import { Flex, Heading, IconButton, Table } from '@radix-ui/themes';
import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
import { asset } from '../../../../core/blitzkit/asset';
import { TankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
import { CompareRow } from './CompareRow';
import { CompareSectionTitle } from './CompareSectionTitle';
interface BodyProps {
stats: TankCharacteristics[];
}
export function Body({ stats }: BodyProps) {
const members = CompareEphemeral.use((state) => state.members);
const hasNonRegularGun = members.some(({ gun }) => gun.type !== 'regular');
const mutateCompareEphemeral = CompareEphemeral.useMutation();
return (
<>
<Table.Header>
<Table.Row>
<StickyColumnHeaderCell top={137} style={{ left: 0, zIndex: 2 }}>
<Flex height="100%" align="center">
<Heading size="4">Firepower</Heading>
</Flex>
</StickyColumnHeaderCell>
{members.map(({ gun, shell, key }, index) => (
<StickyColumnHeaderCell key={key} top={137}>
<Flex justify="center">
<Flex>
{gun.shells.map((thisShell, shellIndex) => (
<IconButton
color={thisShell.id === shell.id ? undefined : 'gray'}
variant="soft"
key={thisShell.id}
style={{
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
borderBottomLeftRadius:
shellIndex === 0 ? undefined : 0,
borderTopRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0,
borderBottomRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0,
marginLeft: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
mutateCompareEphemeral((draft) => {
draft.members[index].shell = thisShell;
});
}}
>
<img
alt={thisShell.name}
width={16}
height={16}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
/>
</IconButton>
))}
</Flex>
</Flex>
</StickyColumnHeaderCell>
))}
</Table.Row>
</Table.Header>
<Table.Body>
<CompareRow stats={stats} name="DPM" value="dpm" decimals={0} />
<CompareRow
stats={stats}
name="Reload"
deltaType="lowerIsBetter"
value={(stats) =>
stats.shellReload ??
stats.shellReloads!.reduce((a, b) => a + b, 0) /
stats.shellReloads!.length
}
display={(stats) =>
stats.shellReload?.toFixed(2) ??
stats.shellReloads!.map((reload) => reload.toFixed(2)).join(', ')
}
deltaNominalDisplay={(delta) => delta.toFixed(2)}
/>
{hasNonRegularGun && (
<>
<CompareRow
stats={stats}
name="Intra-clip"
deltaType="lowerIsBetter"
value="intraClip"
decimals={2}
/>
<CompareRow
stats={stats}
name="Shells"
value="shells"
decimals={0}
/>
</>
)}
<CompareRow
stats={stats}
name="Penetration"
value="penetration"
decimals={0}
/>
<CompareRow stats={stats} name="Damage" value="damage" decimals={0} />
{hasNonRegularGun && (
<CompareRow
stats={stats}
name="clipping potential"
indent
value="clipDamage"
decimals={0}
/>
)}
<CompareRow
stats={stats}
name="Module damage"
value="moduleDamage"
decimals={0}
/>
<CompareRow stats={stats} name="Caliber" value="caliber" decimals={0} />
<CompareRow
stats={stats}
name="Normalization"
value="shellNormalization"
decimals={0}
/>
<CompareRow
stats={stats}
name="Ricochet"
value="shellRicochet"
deltaType="lowerIsBetter"
decimals={0}
/>
<CompareRow
stats={stats}
name="Shell velocity"
value="shellVelocity"
decimals={0}
/>
<CompareRow
stats={stats}
name="Aim time"
value="aimTime"
deltaType="lowerIsBetter"
decimals={2}
/>
<CompareRow
stats={stats}
name="Dispersion"
value="dispersion"
deltaType="lowerIsBetter"
decimals={3}
/>
<CompareRow
stats={stats}
name="moving"
indent
value="dispersionMoving"
deltaType="lowerIsBetter"
decimals={3}
/>
<CompareRow
stats={stats}
name="hull traversing"
indent
value="dispersionHullTraversing"
deltaType="lowerIsBetter"
decimals={3}
/>
<CompareRow
stats={stats}
name="turret traversing"
indent
value="dispersionTurretTraversing"
deltaType="lowerIsBetter"
decimals={3}
/>
<CompareRow
stats={stats}
name="shooting"
indent
value="dispersionShooting"
deltaType="lowerIsBetter"
decimals={3}
/>
<CompareRow
stats={stats}
name="gun damaged"
indent
value="dispersionGunDamaged"
deltaType="lowerIsBetter"
decimals={3}
/>
<CompareRow
stats={stats}
name="maximum"
indent
value="maxDispersion"
deltaType="lowerIsBetter"
decimals={3}
/>
<CompareRow
stats={stats}
name="Gun depression"
value="gunDepression"
decimals={1}
/>
<CompareRow
stats={stats}
name="elevation"
indent
value="gunElevation"
decimals={1}
/>
</Table.Body>
<CompareSectionTitle>Maneuverability</CompareSectionTitle>
<Table.Body>
<CompareRow
stats={stats}
name="Speed forwards"
value="speedForwards"
decimals={0}
/>
<CompareRow
stats={stats}
name="backwards"
indent
value="speedBackwards"
decimals={0}
/>
<CompareRow
stats={stats}
name="Engine power"
value="enginePower"
decimals={0}
/>
<CompareRow stats={stats} name="Weight" value="weight" decimals={1} />
<CompareRow
stats={stats}
name="Terrain coefficient"
value="hardTerrainCoefficient"
decimals={0}
/>
<CompareRow
stats={stats}
name="medium terrain"
indent
value="mediumTerrainCoefficient"
decimals={0}
/>
<CompareRow
stats={stats}
name="soft terrain"
indent
value="softTerrainCoefficient"
decimals={0}
/>
<CompareRow
stats={stats}
name="Raw terrain coefficient"
value="hardTerrainCoefficientRaw"
decimals={2}
/>
<CompareRow
stats={stats}
name="medium terrain"
indent
value="mediumTerrainCoefficientRaw"
decimals={2}
/>
<CompareRow
stats={stats}
name="soft terrain"
indent
value="softTerrainCoefficientRaw"
decimals={2}
/>
<CompareRow
stats={stats}
name="Power to weight ratio"
value="powerToWeightRatioHardTerrain"
decimals={1}
/>
<CompareRow
stats={stats}
name="medium terrain"
indent
value="powerToWeightRatioMediumTerrain"
decimals={1}
/>
<CompareRow
stats={stats}
name="soft terrain"
indent
value="powerToWeightRatioSoftTerrain"
decimals={1}
/>
<CompareRow
stats={stats}
name="Turret traverse speed"
value="turretTraverseSpeed"
decimals={1}
/>
<CompareRow
stats={stats}
name="Traverse speed"
value="hullTraverseHardTerrain"
decimals={1}
/>
<CompareRow
stats={stats}
name="medium terrain"
indent
value="hullTraverseMediumTerrain"
decimals={1}
/>
<CompareRow
stats={stats}
name="soft terrain"
indent
value="hullTraverseSoftTerrain"
decimals={1}
/>
</Table.Body>
<CompareSectionTitle>Survivability</CompareSectionTitle>
<Table.Body>
<CompareRow stats={stats} name="Health" value="health" decimals={0} />
<CompareRow
stats={stats}
name="Fire chance"
value="fireChance"
deltaType="lowerIsBetter"
display={(stats) => (stats.fireChance * 100).toFixed(0)}
deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
/>
<CompareRow
stats={stats}
name="View range"
value="viewRange"
decimals={0}
/>
<CompareRow
stats={stats}
name="Camouflage"
value="camouflageStill"
display={(stats) => (stats.camouflageStill * 100).toFixed(0)}
deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
/>
<CompareRow
stats={stats}
name="moving"
indent
value="camouflageMoving"
display={(stats) => (stats.camouflageMoving * 100).toFixed(0)}
deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
/>
<CompareRow
stats={stats}
name="shooting"
indent
value="camouflageShootingStill"
display={(stats) => (stats.camouflageShootingStill * 100).toFixed(0)}
deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
/>
<CompareRow
stats={stats}
name="shooting & moving"
indent
value="camouflageShootingMoving"
display={(stats) => (stats.camouflageShootingMoving * 100).toFixed(0)}
deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
/>
<CompareRow
stats={stats}
name="caught on fire"
indent
value="camouflageCaughtOnFire"
display={(stats) => (stats.camouflageCaughtOnFire * 100).toFixed(0)}
deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
/>
<CompareRow
stats={stats}
name="Volume"
value="volume"
deltaType="lowerIsBetter"
decimals={1}
/>
<CompareRow
stats={stats}
name="width"
indent
value="width"
deltaType="lowerIsBetter"
decimals={1}
/>
<CompareRow
stats={stats}
name="height"
indent
value="height"
deltaType="lowerIsBetter"
decimals={1}
/>
<CompareRow
stats={stats}
name="length"
indent
value="length"
deltaType="lowerIsBetter"
decimals={1}
/>
</Table.Body>
</>
);
}
@@ -0,0 +1,176 @@
import {
CaretLeftIcon,
CaretRightIcon,
CaretSortIcon,
} from '@radix-ui/react-icons';
import { Flex, IconButton, Table, Text } from '@radix-ui/themes';
import { StickyRowHeaderCell } from '../../../../components/StickyRowHeaderCell';
import { TankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
import { theme } from '../../../../stitches.config';
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
import * as ComparePersistent from '../../../../stores/comparePersistent';
interface CompareRowProps {
name: string;
value:
| keyof Awaited<TankCharacteristics>
| ((member: Awaited<TankCharacteristics>) => number | undefined);
display?: (
member: Awaited<TankCharacteristics>,
) => number | string | undefined;
deltaType?: 'higherIsBetter' | 'lowerIsBetter';
decimals?: number;
deltaNominalDisplay?: (delta: number) => string | number;
indent?: boolean;
stats: TankCharacteristics[];
}
export function CompareRow({
value,
name,
display,
deltaType = 'higherIsBetter',
decimals,
deltaNominalDisplay,
indent,
stats,
}: CompareRowProps) {
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const sorting = CompareEphemeral.use((state) => state.sorting);
const deltaMode = ComparePersistent.use((state) => state.deltaMode);
const values = stats.map((stat) =>
typeof value === 'function' ? value(stat)! : (stat[value] as number),
);
return (
<Table.Row>
<StickyRowHeaderCell>
<Flex
align="center"
style={{ whiteSpace: 'nowrap' }}
gap={indent ? { initial: '4', sm: '6' } : { initial: '1', sm: '2' }}
>
<IconButton
color={sorting?.by === name ? undefined : 'gray'}
variant="ghost"
onClick={() => {
mutateCompareEphemeral((draft) => {
draft.members.sort((memberA, memberB) => {
const indexA = draft.members.indexOf(memberA);
const indexB = draft.members.indexOf(memberB);
const valueA = values[indexA];
const valueB = values[indexB];
return draft.sorting?.direction === 'ascending' &&
draft.sorting.by === name
? valueA - valueB
: valueB - valueA;
});
draft.sorting = {
by: name,
direction:
draft.sorting?.direction === 'ascending' &&
draft.sorting.by === name
? 'descending'
: 'ascending',
};
});
}}
>
{(sorting === undefined || sorting.by !== name) && (
<CaretSortIcon style={{ transform: 'rotate(90deg)' }} />
)}
{sorting?.by === name && (
<>
{sorting.direction === 'ascending' && <CaretRightIcon />}
{sorting.direction === 'descending' && <CaretLeftIcon />}
</>
)}
</IconButton>
<Text size={{ initial: '1', sm: '2' }}>{name}</Text>
</Flex>
</StickyRowHeaderCell>
{values.map((value, index) => {
const delta = value - values[0];
const deltaPercentage = value / values[0] - 1;
const normalizedDeltaPercentage = Math.round(
Math.min(100, Math.abs(deltaPercentage) * 2 * 100 + 25),
);
const resolvedDisplayValue = display
? display(stats[index])
: decimals === undefined
? value
: value?.toFixed(decimals);
return (
<Table.Cell
key={index}
style={{
backgroundColor:
index === 0 ||
value === undefined ||
values[0] === undefined ||
value === values[0]
? undefined
: (
deltaType === 'higherIsBetter'
? value > values[0]
: value < values[0]
)
? `color-mix(in srgb, ${theme.colors.componentCallToActionInteractive_green} ${normalizedDeltaPercentage}%, ${theme.colors.componentCallToActionInteractive_green}00)`
: `color-mix(in srgb, ${theme.colors.componentCallToActionInteractive_red} ${normalizedDeltaPercentage}%, ${theme.colors.componentCallToActionInteractive_red}00)`,
}}
>
<Flex
align="center"
justify="center"
gap="2"
style={{
width: '100%',
height: '100%',
}}
>
<Text style={{ textAlign: 'center' }} wrap="nowrap">
{resolvedDisplayValue}
</Text>
{delta !== 0 &&
resolvedDisplayValue !== undefined &&
values[0] !== undefined && (
<>
{deltaMode === 'absolute' && (
<Text color="gray">
(
{`${delta > 0 ? '+' : ''}${
deltaNominalDisplay
? deltaNominalDisplay(delta)
: decimals === undefined
? delta
: delta.toFixed(decimals)
}`}
)
</Text>
)}
{deltaMode === 'percentage' && (
<Text color="gray">
(
{`${deltaPercentage > 0 ? '+' : ''}${Math.round(
deltaPercentage * 100,
)}%`}
)
</Text>
)}
</>
)}
</Flex>
</Table.Cell>
);
})}
</Table.Row>
);
}
@@ -0,0 +1,22 @@
import { Heading, Table } from '@radix-ui/themes';
import { times } from 'lodash';
import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
export function CompareSectionTitle({ children }: { children: string }) {
const memberCount = CompareEphemeral.use((state) => state.members.length);
return (
<Table.Header style={{ height: 48 }}>
<Table.Row align="center">
<StickyColumnHeaderCell top={137} style={{ left: 0, zIndex: 2 }}>
<Heading size="4">{children}</Heading>
</StickyColumnHeaderCell>
{times(memberCount, (index) => (
<StickyColumnHeaderCell key={index} top={137} />
))}
</Table.Row>
</Table.Header>
);
}
@@ -0,0 +1,564 @@
import { ComponentPlaceholderIcon } from '@radix-ui/react-icons';
import { Button, Flex, Popover, Slider, Table, Text } from '@radix-ui/themes';
import { debounce, times } from 'lodash';
import { use } from 'react';
import { ConsumablesManager } from '../../../../components/ConsumablesManager';
import { CrewSkillManager } from '../../../../components/CrewSkillManager';
import { EquipmentManager } from '../../../../components/EquipmentManager';
import { ModuleManager } from '../../../../components/ModuleManager';
import { ProvisionsManager } from '../../../../components/ProvisionsManager';
import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
import { StickyTableRoot } from '../../../../components/StickyTableRoot';
import { asset } from '../../../../core/blitzkit/asset';
import { availableProvisions } from '../../../../core/blitzkit/availableProvisions';
import { checkConsumableProvisionInclusivity } from '../../../../core/blitzkit/checkConsumableProvisionInclusivity';
import { consumableDefinitions } from '../../../../core/blitzkit/consumableDefinitions';
import { equipmentDefinitions } from '../../../../core/blitzkit/equipmentDefinitions';
import { provisionDefinitions } from '../../../../core/blitzkit/provisionDefinitions';
import { skillDefinitions } from '../../../../core/blitzkit/skillDefinitions';
import { TankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
import { BlitzkitButtonGray } from '../../../../icons/BlitzkitButtonGray';
import { theme } from '../../../../stitches.config';
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
import { EquipmentMatrix } from '../../../../stores/duel';
import { permanentSkills } from '../../tankopedia/[id]/components/Characteristics/components/Skills/constants';
import { Body } from './Body';
import { InsertionMarker } from './IntersectionMarker';
import { TankCard } from './TankCard';
interface CompareTableProps {
stats: TankCharacteristics[];
}
export function CompareTable({ stats }: CompareTableProps) {
const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
const members = CompareEphemeral.use((state) => state.members);
const awaitedSkillDefinitions = use(skillDefinitions);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
const awaitedConsumableDefinitions = use(consumableDefinitions);
const awaitedProvisionDefinitions = use(provisionDefinitions);
return (
<StickyTableRoot
size="1"
variant="surface"
style={{ maxWidth: '100%', height: 'calc(100vh - 128px)' }}
>
<Table.Header>
<Table.Row>
<StickyColumnHeaderCell width="0" style={{ height: '100%' }}>
<Flex
style={{
width: '100%',
height: '100%',
}}
align="center"
justify="center"
>
<BlitzkitButtonGray opacity="0.25" width={64} height={64} />
<InsertionMarker index={0} />
</Flex>
</StickyColumnHeaderCell>
{members.map(({ tank, key }, index) => {
return <TankCard index={index} key={key} tank={tank} />;
})}
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell style={{ height: '100%' }}>
<Flex
style={{
width: '100%',
height: '100%',
}}
align="center"
justify="center"
>
<Popover.Root>
<Popover.Trigger>
<Button variant="ghost" radius="large">
<Flex
direction="column"
style={{
gap: 2,
}}
>
{Object.entries(awaitedSkillDefinitions.classes).map(
([tankClass, skills]) => (
<Flex key={tankClass} style={{ gap: 2 }}>
{skills.map((skill) => (
<div
key={skill}
style={{
width: 6,
height: 6,
borderRadius: 2,
backgroundColor:
crewSkills[skill] === 0
? theme.colors.textLowContrast
: permanentSkills.includes(skill)
? theme.colors.textLowContrast_crimson
: theme.colors.textLowContrast_amber,
}}
/>
))}
</Flex>
),
)}
</Flex>
</Button>
</Popover.Trigger>
<Popover.Content>
<CrewSkillManager
skillLevels={crewSkills}
onChange={(skills) => {
mutateCompareEphemeral((draft) => {
draft.crewSkills = skills;
});
}}
/>
<Flex justify="end" mt="4" gap="4">
<Button
variant="ghost"
onClick={() => {
mutateCompareEphemeral((draft) => {
Object.keys(draft.crewSkills).forEach((skill) => {
draft.crewSkills[skill] = 0;
});
});
}}
>
Clear
</Button>
<Button
variant="ghost"
onClick={() => {
mutateCompareEphemeral((draft) => {
Object.keys(draft.crewSkills).forEach((skill) => {
draft.crewSkills[skill] = 7;
});
});
}}
>
Maximize
</Button>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
</Table.Cell>
{members.map(
(
{
equipmentMatrix,
tank,
key,
provisions,
gun,
shell,
turret,
engine,
track,
consumables,
crewMastery,
},
index,
) => {
const equipmentPreset =
awaitedEquipmentDefinitions.presets[tank.equipment];
const provisionsList = availableProvisions(
tank,
gun,
awaitedProvisionDefinitions,
);
const consumablesList = Object.values(
awaitedConsumableDefinitions,
).filter((consumable) =>
checkConsumableProvisionInclusivity(consumable, tank, gun),
);
return (
<Table.Cell key={key}>
<Flex
align="center"
justify="center"
direction="column"
gap="2"
>
<Popover.Root>
<Popover.Trigger>
<Button variant="ghost">
<Flex align="center">
{(
['turret', 'gun', 'engine', 'chassis'] as const
).map((module, index) => (
<img
alt={module}
width={24}
height={24}
src={asset(`icons/modules/${module}.webp`)}
style={{
marginLeft: index > 0 ? -8 : 0,
objectFit: 'contain',
}}
/>
))}
</Flex>
</Button>
</Popover.Trigger>
<Popover.Content>
<ModuleManager
tank={tank}
turret={turret}
gun={gun}
shell={shell}
engine={engine}
track={track}
onChange={(modules) => {
mutateCompareEphemeral((draft) => {
draft.members[index] = {
...draft.members[index],
...modules,
};
});
}}
/>
<Flex justify="end" mt="4" gap="4">
<Button
variant="ghost"
onClick={() => {
mutateCompareEphemeral((draft) => {
const member = draft.members[index];
member.turret = member.tank.turrets[0];
member.gun = member.turret.guns[0];
member.shell = member.gun.shells[0];
member.engine = member.tank.engines[0];
member.track = member.tank.tracks[0];
});
}}
>
Stock
</Button>
<Button
variant="ghost"
onClick={() => {
mutateCompareEphemeral((draft) => {
const member = draft.members[index];
member.turret = member.tank.turrets.at(-1)!;
member.gun = member.turret.guns.at(-1)!;
member.shell = member.gun.shells[0];
member.engine = member.tank.engines.at(-1)!;
member.track = member.tank.tracks.at(-1)!;
});
}}
>
Upgrade
</Button>
</Flex>
</Popover.Content>
</Popover.Root>
<Flex align="center" justify="center" gap="3">
<Popover.Root>
<Popover.Trigger>
<Button
variant="ghost"
radius="large"
style={{
height: '100%',
width: 12,
position: 'relative',
}}
>
<Flex direction="column" justify="center">
{provisions.map((provision, index) => (
<img
alt={
awaitedProvisionDefinitions[provision].name
}
key={provision}
src={asset(
`/icons/provisions/${provision}.webp`,
)}
style={{
left: '50%',
/**
* max = 100% - 16px - 4px
* min = 4px
* diff = max - min
* = 100% - 16px - 4px - 4px
* = 100% - 24px
* coefficient = index / length - 1
* position = min + coefficient * diff
*/
top: `calc(4px + ${
provisions.length === 1
? 0.5
: index / (provisions.length - 1)
} * (100% - 24px))`,
transform: 'translateX(-50%)',
position: 'absolute',
width: 16,
height: 16,
objectFit: 'contain',
}}
/>
))}
{provisions.length === 0 && (
<ComponentPlaceholderIcon />
)}
</Flex>
</Button>
</Popover.Trigger>
<Popover.Content>
<ProvisionsManager
provisions={provisionsList.map(
(provision) => provision.id,
)}
selected={provisions}
disabled={tank.provisions === provisions.length}
onChange={(provisions) => {
mutateCompareEphemeral((draft) => {
draft.members[index].provisions = provisions;
});
}}
/>
<Flex justify="end" mt="4">
<Button
variant="ghost"
onClick={() => {
mutateCompareEphemeral((draft) => {
draft.members[index].provisions = [];
});
}}
>
Clear
</Button>
</Flex>
</Popover.Content>
</Popover.Root>
<Popover.Root>
<Popover.Trigger>
<Button variant="ghost" radius="large">
<Flex gap="1" direction="column">
{times(3, (y) => (
<Flex
key={y}
style={{
gap: 6,
}}
>
{times(3, (x) => {
const equipment = equipmentMatrix[y][x];
return (
<Flex
key={x}
style={{
gap: 2,
}}
>
<div
style={{
width: 4,
height: 8,
backgroundColor:
equipment === -1
? theme.colors
.textLowContrast_crimson
: theme.colors.textLowContrast,
borderRadius: 2,
}}
/>
<div
style={{
width: 4,
height: 8,
backgroundColor:
equipment === 1
? theme.colors
.textLowContrast_crimson
: theme.colors.textLowContrast,
borderRadius: 2,
}}
/>
</Flex>
);
})}
</Flex>
))}
</Flex>
</Button>
</Popover.Trigger>
<Popover.Content>
<EquipmentManager
matrix={equipmentMatrix}
preset={equipmentPreset}
onChange={(matrix) => {
mutateCompareEphemeral((draft) => {
draft.members[index].equipmentMatrix = matrix;
draft.sorting = undefined;
});
}}
/>
<Flex justify="end" gap="4" mt="4">
<Button
variant="ghost"
color="red"
onClick={() => {
mutateCompareEphemeral((draft) => {
draft.members[index].equipmentMatrix = times(
3,
() => times(3, () => 0),
) as EquipmentMatrix;
draft.sorting = undefined;
});
}}
>
Clear
</Button>
<Button
variant="ghost"
onClick={() => {
mutateCompareEphemeral((draft) => {
draft.members.forEach((member) => {
member.equipmentMatrix =
draft.members[index].equipmentMatrix;
draft.sorting = undefined;
});
});
}}
>
Apply to all
</Button>
</Flex>
</Popover.Content>
</Popover.Root>
<Popover.Root>
<Popover.Trigger>
<Button
variant="ghost"
radius="large"
style={{
height: '100%',
width: 12,
position: 'relative',
}}
>
<Flex direction="column" justify="center">
{consumables.map((consumable, index) => (
<img
alt={
awaitedConsumableDefinitions[consumable]
.name
}
key={consumable}
src={asset(
`/icons/consumables/${consumable}.webp`,
)}
style={{
left: '50%',
/**
* max = 100% - 16px - 4px
* min = 4px
* diff = max - min
* = 100% - 16px - 4px - 4px
* = 100% - 24px
* coefficient = index / length - 1
* position = min + coefficient * diff
*/
top: `calc(4px + ${
consumables.length === 1
? 0.5
: index / (consumables.length - 1)
} * (100% - 24px))`,
transform: 'translateX(-50%)',
position: 'absolute',
width: 16,
height: 16,
objectFit: 'contain',
}}
/>
))}
{consumables.length === 0 && (
<ComponentPlaceholderIcon />
)}
</Flex>
</Button>
</Popover.Trigger>
<Popover.Content>
<ConsumablesManager
consumables={consumablesList}
selected={consumables}
onConsumablesChange={(consumables) => {
mutateCompareEphemeral((draft) => {
draft.members[index].consumables = consumables;
});
}}
disabled={tank.consumables === consumables.length}
/>
<Flex justify="end" mt="4">
<Button
variant="ghost"
onClick={() => {
mutateCompareEphemeral((draft) => {
draft.members[index].consumables = [];
});
}}
>
Clear
</Button>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
<Flex gap="2" width="100%" align="center">
<Text>{Math.round(crewMastery * 100)}%</Text>
<Flex flexGrow="1">
<Slider
defaultValue={[crewMastery]}
min={0.5}
max={1}
step={1 / 100}
onValueChange={debounce(([value]: number[]) => {
mutateCompareEphemeral((draft) => {
draft.members[index].crewMastery = value;
});
}, 500)}
/>
</Flex>
</Flex>
</Flex>
</Table.Cell>
);
},
)}
</Table.Row>
</Table.Body>
<Body stats={stats} />
</StickyTableRoot>
);
}
@@ -0,0 +1,113 @@
import { PlusIcon, TrashIcon } from '@radix-ui/react-icons';
import { Button, Dialog, Flex, SegmentedControl } from '@radix-ui/themes';
import { use } from 'react';
import { provisionDefinitions } from '../../../../core/blitzkit/provisionDefinitions';
import { tankToCompareMember } from '../../../../core/blitzkit/tankToCompareMember';
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
import * as ComparePersistent from '../../../../stores/comparePersistent';
import { TankSearch } from '../../tankopedia/components/TankSearch';
interface ControlsProps {
addTankDialogOpen: boolean;
onAddTankDialogOpenChange: (open: boolean) => void;
}
export function Controls({
addTankDialogOpen,
onAddTankDialogOpenChange,
}: ControlsProps) {
const deltaMode = ComparePersistent.use((state) => state.deltaMode);
const awaitedProvisionDefinitions = use(provisionDefinitions);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const mutateComparePersistent = ComparePersistent.useMutation();
return (
<Flex gap="2" wrap="wrap" justify="center">
<Dialog.Root
open={addTankDialogOpen}
onOpenChange={onAddTankDialogOpenChange}
>
<Dialog.Trigger>
<Button variant="soft">
<PlusIcon /> Add
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title align="center">Add tanks</Dialog.Title>
<Flex gap="4" direction="column">
<Flex
direction="column"
gap="4"
style={{ flex: 1 }}
justify="center"
>
<TankSearch
compact
onSelect={(tank) => {
mutateCompareEphemeral((draft) => {
draft.members.push(
tankToCompareMember(tank, awaitedProvisionDefinitions),
);
draft.sorting = undefined;
});
onAddTankDialogOpenChange(false);
}}
onSelectAll={(tanks) => {
mutateCompareEphemeral((draft) => {
draft.members.push(
...tanks.map((tank) =>
tankToCompareMember(tank, awaitedProvisionDefinitions),
),
);
draft.sorting = undefined;
});
onAddTankDialogOpenChange(false);
}}
/>
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
<Button
variant="soft"
color="red"
onClick={() => {
mutateCompareEphemeral((draft) => {
draft.members = [];
draft.sorting = undefined;
});
}}
>
<TrashIcon /> Clear
</Button>
<SegmentedControl.Root
value={deltaMode}
onValueChange={(value) => {
mutateComparePersistent((draft) => {
draft.deltaMode = value as ComparePersistent.DeltaMode;
});
}}
>
<SegmentedControl.Item
value={'none' satisfies ComparePersistent.DeltaMode}
>
No deltas
</SegmentedControl.Item>
<SegmentedControl.Item
value={'percentage' satisfies ComparePersistent.DeltaMode}
>
Percentage
</SegmentedControl.Item>
<SegmentedControl.Item
value={'absolute' satisfies ComparePersistent.DeltaMode}
>
Absolute
</SegmentedControl.Item>
</SegmentedControl.Root>
</Flex>
);
}
@@ -0,0 +1,28 @@
import { InfoCircledIcon } from '@radix-ui/react-icons';
import { Callout } from '@radix-ui/themes';
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
export function DamageWarning() {
const members = CompareEphemeral.use((state) => state.members);
const hasReactive = members.some((member) => member.consumables.includes(33));
const haDynamicArmor = members.some((member) =>
member.consumables.includes(73),
);
const hasSpallLiner = members.some((member) =>
member.provisions.includes(101),
);
if (!hasReactive && !haDynamicArmor && !hasSpallLiner) return null;
{
<Callout.Root>
<Callout.Icon>
<InfoCircledIcon />
</Callout.Icon>
<Callout.Text>
Consumables like Reactive Armor and Dynamic Armor System and provisions
like Spall Liner may reduce other tanks' damage.
</Callout.Text>
</Callout.Root>;
}
}
@@ -0,0 +1,2 @@
export const insertionMarkers: { element: HTMLDivElement; index: number }[] =
[];
@@ -0,0 +1,40 @@
import { useEffect, useRef } from 'react';
import { theme } from '../../../../../stitches.config';
import { insertionMarkers } from './constants';
export function InsertionMarker({ index }: { index: number }) {
const marker = useRef<HTMLDivElement>(null);
useEffect(() => {
insertionMarkers.push({
element: marker.current!,
index,
});
return () => {
insertionMarkers.splice(
insertionMarkers.findIndex(
(insertionMarker) => insertionMarker.element === marker.current!,
),
1,
);
};
});
return (
<div
ref={marker}
style={{
height: '75%',
width: 2,
borderRadius: 1,
backgroundColor: theme.colors.textLowContrast_crimson,
position: 'absolute',
right: 0,
top: '50%',
transform: 'translateY(-50%)',
opacity: 0,
}}
/>
);
}
@@ -0,0 +1,148 @@
import { Flex, Text } from '@radix-ui/themes';
import { useEffect, useRef } from 'react';
import { Vector2 } from 'three';
import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
import { TankDefinition } from '../../../../core/blitzkit/tankDefinitions';
import { tankIcon } from '../../../../core/blitzkit/tankIcon';
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
import { InsertionMarker } from './IntersectionMarker';
import { insertionMarkers } from './IntersectionMarker/constants';
import { TankControl } from './TankControl';
interface TankCardProps {
index: number;
tank: TankDefinition;
}
export function TankCard({ index, tank }: TankCardProps) {
const draggable = useRef<HTMLDivElement>(null);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
useEffect(() => {
const initial = new Vector2();
const current = new Vector2();
const delta = new Vector2();
const marker = new Vector2();
let initialRect = draggable.current!.getBoundingClientRect();
let dropIndex = -1;
function handlePointerDown(event: PointerEvent) {
event.preventDefault();
initialRect = draggable.current!.getBoundingClientRect();
delta.set(0, 0);
current.copy(initial.set(event.clientX, event.clientY));
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
event.preventDefault();
current.set(event.clientX, event.clientY);
delta.copy(current).sub(initial);
insertionMarkers.forEach(({ element }) => {
element.style.opacity = '0';
});
const distances = insertionMarkers.map((insertionMarker) => {
const markerBounds = insertionMarker.element.getBoundingClientRect();
marker.set(markerBounds.left, markerBounds.top);
return {
...insertionMarker,
distance: marker.distanceTo(current),
};
});
const closest = distances.reduce((a, b) =>
a.distance < b.distance ? a : b,
);
dropIndex = closest.index;
closest.element.style.opacity = '1';
draggable.current!.style.position = 'fixed';
draggable.current!.style.cursor = 'grabbing';
draggable.current!.style.zIndex = '1';
draggable.current!.style.left = `${initialRect.left + delta.x}px`;
draggable.current!.style.top = `${initialRect.top + delta.y}px`;
}
function handlePointerUp(event: PointerEvent) {
event.preventDefault();
draggable.current!.style.position = 'static';
draggable.current!.style.zIndex = 'unset';
draggable.current!.style.cursor = 'grab';
insertionMarkers.forEach(({ element }) => {
element.style.opacity = '0';
});
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
if (dropIndex === -1) return;
mutateCompareEphemeral((draft) => {
const fromIndex = index;
const toIndex = dropIndex > index ? dropIndex - 1 : dropIndex;
const member = draft.members[fromIndex];
draft.members.splice(fromIndex, 1);
draft.members.splice(toIndex, 0, member);
});
dropIndex = -1;
}
draggable.current?.addEventListener('pointerdown', handlePointerDown);
return () => {
draggable.current?.removeEventListener('pointerdown', handlePointerDown);
};
}, []);
return (
<StickyColumnHeaderCell>
<Flex direction="column" align="center" justify="between" gap="2">
<TankControl index={index} key={tank.id} id={tank.id} />
<Flex
ref={draggable}
direction="column"
align="center"
justify="between"
gap="2"
style={{
userSelect: 'none',
touchAction: 'none',
cursor: 'grab',
}}
>
<img
alt={tank.name}
src={tankIcon(tank.id)}
width={64}
height={64}
draggable={false}
style={{
objectFit: 'contain',
}}
/>
<Text
style={{
whiteSpace: 'nowrap',
maxWidth: 128,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{tank.name}
</Text>
</Flex>
</Flex>
<InsertionMarker index={index + 1} />
</StickyColumnHeaderCell>
);
}
@@ -0,0 +1,79 @@
import { ExternalLinkIcon, LoopIcon, TrashIcon } from '@radix-ui/react-icons';
import { Dialog, Flex, IconButton } from '@radix-ui/themes';
import Link from 'next/link';
import { use, useState } from 'react';
import { provisionDefinitions } from '../../../../core/blitzkit/provisionDefinitions';
import { tankToCompareMember } from '../../../../core/blitzkit/tankToCompareMember';
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
import { TankSearch } from '../../tankopedia/components/TankSearch';
interface TankControlProps {
index: number;
id: number;
}
export function TankControl({ index, id }: TankControlProps) {
const awaitedProvisionDefinitions = use(provisionDefinitions);
const [switchTankDialogOpen, setSwitchTankDialogOpen] = useState(false);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
return (
<Flex gap="3" justify="center" style={{ width: '100%' }}>
<IconButton
variant="ghost"
onClick={() => {
mutateCompareEphemeral((draft) => {
draft.members.splice(index, 1);
draft.sorting = undefined;
});
}}
>
<TrashIcon />
</IconButton>
<Dialog.Root
open={switchTankDialogOpen}
onOpenChange={setSwitchTankDialogOpen}
>
<Dialog.Trigger>
<IconButton variant="ghost">
<LoopIcon />
</IconButton>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title align="center">Switch tanks</Dialog.Title>
<Flex gap="4" direction="column">
<Flex
direction="column"
gap="4"
style={{ flex: 1 }}
justify="center"
>
<TankSearch
compact
onSelect={(tank) => {
mutateCompareEphemeral((draft) => {
draft.members[index] = tankToCompareMember(
tank,
awaitedProvisionDefinitions,
);
draft.sorting = undefined;
});
setSwitchTankDialogOpen(false);
}}
/>
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
<Link href={`/tools/tankopedia/${id}`} target="_blank" prefetch={false}>
<IconButton variant="ghost">
<ExternalLinkIcon />
</IconButton>
</Link>
</Flex>
);
}
@@ -0,0 +1,17 @@
import { ReactNode } from 'react';
import * as CompareEphemeral from '../../../stores/compareEphemeral';
import * as ComparePersistent from '../../../stores/comparePersistent';
import * as TankFilters from '../../../stores/tankFilters';
import * as TankopediaSort from '../../../stores/tankopediaSort';
export default function Layout({ children }: { children: ReactNode }) {
return (
<TankopediaSort.Provider>
<TankFilters.Provider>
<ComparePersistent.Provider>
<CompareEphemeral.Provider>{children}</CompareEphemeral.Provider>
</ComparePersistent.Provider>
</TankFilters.Provider>
</TankopediaSort.Provider>
);
}
@@ -0,0 +1,161 @@
'use client';
import { PlusIcon } from '@radix-ui/react-icons';
import { Box, Flex, Heading, IconButton, Text } from '@radix-ui/themes';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { use, useCallback, useEffect, useMemo, useState } from 'react';
import PageWrapper from '../../../components/PageWrapper';
import { createDefaultSkills } from '../../../core/blitzkit/createDefaultSkills';
import { equipmentDefinitions } from '../../../core/blitzkit/equipmentDefinitions';
import { modelDefinitions } from '../../../core/blitzkit/modelDefinitions';
import { provisionDefinitions } from '../../../core/blitzkit/provisionDefinitions';
import { skillDefinitions } from '../../../core/blitzkit/skillDefinitions';
import { tankCharacteristics } from '../../../core/blitzkit/tankCharacteristics';
import { tankDefinitions } from '../../../core/blitzkit/tankDefinitions';
import { tankToCompareMember } from '../../../core/blitzkit/tankToCompareMember';
import * as CompareEphemeral from '../../../stores/compareEphemeral';
import { CompareTable } from './components/CompareTable';
import { Controls } from './components/Controls';
import { DamageWarning } from './components/DamageWarning';
export default function Page() {
const pathname = usePathname();
const router = useRouter();
const awaitedTankDefinitions = use(tankDefinitions);
const awaitedSkillDefinitions = use(skillDefinitions);
const searchParams = useSearchParams();
const members = CompareEphemeral.use((state) => state.members);
const [addTankDialogOpen, setAddTankDialogOpen] = useState(false);
const awaitedModelDefinitions = use(modelDefinitions);
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
const awaitedProvisionDefinitions = use(provisionDefinitions);
const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const stats = useMemo<ReturnType<typeof tankCharacteristics>[]>(
() =>
members.map((thisMember) =>
tankCharacteristics(
{
...thisMember,
crewSkills,
stockEngine: thisMember.tank.engines[0],
stockGun: thisMember.tank.turrets[0].guns[0],
stockTrack: thisMember.tank.tracks[0],
stockTurret: thisMember.tank.turrets[0],
applyReactiveArmor: members.some(
(member) =>
member.key !== thisMember.key &&
member.consumables.includes(33),
),
applyDynamicArmor: members.some(
(member) =>
member.key !== thisMember.key &&
member.consumables.includes(73),
),
applySpallLiner: members.some(
(member) =>
member.key !== thisMember.key &&
member.provisions.includes(101),
),
},
{
equipmentDefinitions: awaitedEquipmentDefinitions,
provisionDefinitions: awaitedProvisionDefinitions,
tankModelDefinition: awaitedModelDefinitions[thisMember.tank.id],
},
),
),
[members, crewSkills],
);
const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(name, value);
return params.toString();
},
[searchParams],
);
useEffect(() => {
router.push(
pathname +
'?' +
createQueryString(
'tanks',
members.map(({ tank }) => tank.id).join(','),
),
);
}, [members]);
useEffect(() => {
const tanksParam = searchParams.get('tanks');
if (tanksParam) {
mutateCompareEphemeral((draft) => {
draft.members = tanksParam
.split(',')
.map(Number)
.map((id) =>
tankToCompareMember(
awaitedTankDefinitions[id],
awaitedProvisionDefinitions,
),
);
});
}
if (Object.keys(crewSkills).length === 0) {
mutateCompareEphemeral((draft) => {
draft.crewSkills = createDefaultSkills(awaitedSkillDefinitions);
});
}
}, []);
return (
<PageWrapper color="crimson" size="100%">
<Flex justify="center" gap="4" align="center" direction="column">
<DamageWarning />
<Controls
addTankDialogOpen={addTankDialogOpen}
onAddTankDialogOpenChange={setAddTankDialogOpen}
/>
</Flex>
{members.length > 0 && (
<Flex justify="center">
<Box maxWidth="100%" position="relative">
<CompareTable stats={stats} />
<IconButton
style={{
position: 'absolute',
right: 0,
top: 73,
transform: 'translate(50%, -50%)',
zIndex: 1,
}}
onClick={() => setAddTankDialogOpen(true)}
>
<PlusIcon />
</IconButton>
</Box>
</Flex>
)}
{members.length === 0 && (
<Flex
align="center"
justify="center"
direction="column"
style={{ flex: 1 }}
>
<Heading color="gray">No tanks selected</Heading>
<Text color="gray">
Press the <PlusIcon /> Add button to get started
</Text>
</Flex>
)}
</PageWrapper>
);
}
@@ -0,0 +1,19 @@
import { Switch } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import { EmbedPreviewControllerProps } from '../page';
export function Boolean({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return (
<Switch
variant="soft"
checked={EmbedState.use((state) => state[configKey] as boolean)}
onCheckedChange={() => {
mutateEmbedState((draft) => {
draft[configKey] = !draft[configKey];
});
}}
/>
);
}
@@ -0,0 +1,38 @@
import { Slider } from '@radix-ui/themes';
interface ColorControllerRawProps {
value: string;
onValueChange: (value: string) => void;
}
export function ColorControllerRaw({
value,
onValueChange,
}: ColorControllerRawProps) {
const tintString = value.slice(0, 7);
const alphaString = value.slice(7, 9);
const alpha = alphaString.length === 0 ? 255 : parseInt(alphaString, 16);
return (
<>
<input
type="color"
value={value.slice(0, 7)}
onChange={(event) => {
let alpha = value.slice(7, 9);
if (alpha.length === 0) alpha = 'ff';
onValueChange(`${event.target.value}${alpha}`);
}}
/>
<Slider
variant="soft"
min={0}
max={255}
value={[alpha]}
onValueChange={([value]) => {
onValueChange(`${tintString}${value.toString(16)}`);
}}
/>
</>
);
}
@@ -0,0 +1,19 @@
import * as EmbedState from '../../../../../../stores/embedState';
import { EmbedPreviewControllerProps } from '../../page';
import { ColorControllerRaw } from './components/ColorControllerRaw';
export function Color({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
const color = EmbedState.use((state) => state[configKey] as string);
return (
<ColorControllerRaw
value={color}
onValueChange={(value) => {
mutateEmbedState((draft) => {
draft[configKey] = value;
});
}}
/>
);
}
@@ -0,0 +1,36 @@
import { Select } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import {
EmbedConfigItemType,
EmbedItemType,
} from '../../../../../stores/embedState/constants';
import { EmbedPreviewControllerProps } from '../page';
export function Enum({
configKey,
config,
}: EmbedPreviewControllerProps & {
config: EmbedConfigItemType<EmbedItemType.Enum>;
}) {
const mutateEmbedState = EmbedState.useMutation();
return (
<Select.Root
value={EmbedState.use((state) => state[configKey] as string)}
onValueChange={(value) => {
mutateEmbedState((draft) => {
draft[configKey] = value;
});
}}
>
<Select.Trigger />
<Select.Content>
{config.options.map((option) => (
<Select.Item key={option.value} value={option.value}>
{option.label}
</Select.Item>
))}
</Select.Content>
</Select.Root>
);
}
@@ -0,0 +1,80 @@
import {
AlertDialog,
Button,
Flex,
Heading,
Text,
TextField,
Tooltip,
} from '@radix-ui/themes';
import { useRef, useState } from 'react';
import * as EmbedState from '../../../../../stores/embedState';
export function Import() {
const importInput = useRef<HTMLInputElement>(null);
const [showInvalidURLPrompt, setShowInvalidURLPrompt] = useState(false);
const [showConfirmation, setConfirmation] = useState(false);
const mutateEmbedState = EmbedState.useMutation();
return (
<>
<Heading>Import</Heading>
<Text size="2" color="gray" mb="2">
Paste your current URL if you have one already to import your settings
</Text>
<AlertDialog.Root
open={showInvalidURLPrompt}
onOpenChange={setShowInvalidURLPrompt}
>
<AlertDialog.Content>
<AlertDialog.Title>Invalid URL</AlertDialog.Title>
<AlertDialog.Description>
Try pasting the URL that BlitzKit previously generated, you silly
goose!
</AlertDialog.Description>
<Flex gap="3" mt="4" justify="end">
<AlertDialog.Cancel>
<Button>Okay, my bad</Button>
</AlertDialog.Cancel>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
<Flex gap="2" mb="6">
<TextField.Root
ref={importInput}
placeholder="Paste URL..."
style={{ flex: 1 }}
/>
<Tooltip content="Applied!" open={showConfirmation}>
<Button
onClick={() => {
if (!importInput.current) return;
try {
const url = new URL(importInput.current.value);
const state = url.searchParams.get('state');
if (state === null) throw undefined;
const json = JSON.parse(state);
mutateEmbedState((draft) => {
Object.assign(draft, json);
});
importInput.current.value = '';
setConfirmation(true);
setTimeout(() => setConfirmation(false), 2000);
} catch {
setShowInvalidURLPrompt(true);
}
}}
>
Apply
</Button>
</Tooltip>
</Flex>
</>
);
}
@@ -0,0 +1,26 @@
import { keyframes, style } from '@vanilla-extract/css';
const fadeOutBorder = keyframes({
from: {
outline: '1rem solid #ff0000ff',
},
to: {
outline: '1rem solid #ff000000',
},
});
export const wrapper = style({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
overflow: 'hidden',
});
export const animated = style({
animation: `${fadeOutBorder} 0.5s linear`,
':hover': {
outline: '1rem solid #ff000080',
},
});
@@ -0,0 +1,36 @@
import { Box } from '@radix-ui/themes';
import { useEffect, useRef } from 'react';
import { useEmbedStateCurry } from '../../../../../../stores/embedState/utilities';
import { previews } from '../../../configurations';
import * as styles from './index.css';
interface PreviewWrapperProps {
name: keyof typeof previews;
}
export function PreviewWrapper({ name }: PreviewWrapperProps) {
const { useState } = useEmbedStateCurry();
const width = useState('width');
const height = useState('height');
const Preview = previews[name];
const wrapper = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!wrapper.current) return;
wrapper.current.classList.remove(styles.animated);
void wrapper.current.offsetWidth;
wrapper.current.classList.add(styles.animated);
}, [width, height]);
return (
<Box
ref={wrapper}
className={styles.wrapper}
width={`${width}px`}
height={`${height}px`}
>
<Preview />
</Box>
);
}
@@ -0,0 +1,26 @@
import { Slider } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import { EmbedPreviewControllerProps } from '../page';
export function Radius({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return (
<Slider
variant="soft"
min={0}
max={5}
value={[
EmbedState.use((state) => {
const radius = state[configKey] as EmbedState.RadixRadius;
return radius === 'full' ? 5 : parseInt(radius);
}),
]}
onValueChange={([value]) => {
mutateEmbedState((draft) => {
draft[configKey] = value === 5 ? 'full' : `${value}`;
});
}}
/>
);
}
@@ -0,0 +1,32 @@
import * as EmbedState from '../../../../../stores/embedState';
import {
EmbedConfigItemType,
EmbedItemType,
} from '../../../../../stores/embedState/constants';
import { EmbedPreviewControllerProps } from '../page';
import { ColorControllerRaw } from './Color/components/ColorControllerRaw';
export function RichText({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
const state = EmbedState.use(
(state) =>
state[
configKey
] as EmbedConfigItemType<EmbedItemType.RichText>['default'],
);
return (
<ColorControllerRaw
value={state.color}
onValueChange={(value) => {
mutateEmbedState((draft) => {
(
draft[
configKey
] as EmbedConfigItemType<EmbedItemType.RichText>['default']
).color = value;
});
}}
/>
);
}
@@ -0,0 +1,25 @@
import { Slider } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import { EmbedPreviewControllerProps } from '../page';
export function Size({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return (
<Slider
variant="soft"
min={0}
max={9}
value={[
EmbedState.use((state) =>
parseInt(state[configKey] as EmbedState.RadixSize),
),
]}
onValueChange={([value]) => {
mutateEmbedState((draft) => {
draft[configKey] = `${value}`;
});
}}
/>
);
}
@@ -0,0 +1,25 @@
import { Slider } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import { EmbedPreviewControllerProps } from '../page';
export function SizeWithout0({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return (
<Slider
variant="soft"
min={1}
max={9}
value={[
EmbedState.use((state) =>
parseInt(state[configKey] as EmbedState.RadixSizeWithout0),
),
]}
onValueChange={([value]) => {
mutateEmbedState((draft) => {
draft[configKey] = `${value}`;
});
}}
/>
);
}
@@ -0,0 +1,34 @@
import { Slider as RadixSlider } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import {
EmbedConfigItemType,
EmbedItemType,
} from '../../../../../stores/embedState/constants';
import { EmbedPreviewControllerProps } from '../page';
export function Slider({
configKey,
config,
}: EmbedPreviewControllerProps & {
config: EmbedConfigItemType<EmbedItemType.Slider>;
}) {
const mutateEmbedState = EmbedState.useMutation();
return (
<RadixSlider
variant="soft"
min={config.min}
max={config.max}
value={[
EmbedState.use((state) =>
parseInt(state[configKey] as EmbedState.RadixSize),
),
]}
onValueChange={([value]) => {
mutateEmbedState((draft) => {
draft[configKey] = value;
});
}}
/>
);
}
@@ -0,0 +1,19 @@
import { TextField } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import { EmbedPreviewControllerProps } from '../page';
export function Text({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return (
<TextField.Root
placeholder="Empty"
value={EmbedState.use((state) => state[configKey] as string)}
onChange={(event) => {
mutateEmbedState((draft) => {
draft[configKey] = event.target.value;
});
}}
/>
);
}
@@ -0,0 +1,43 @@
'use client';
import { Code, Heading } from '@radix-ui/themes';
import { useMemo } from 'react';
import PageWrapper from '../../../../../components/PageWrapper';
import * as EmbedState from '../../../../../stores/embedState';
import { configurations } from '../../configurations';
import { extractEmbedConfigDefaults } from '../../utilities';
export interface EmbedPreviewControllerProps {
configKey: string;
}
export default function Page({
params,
searchParams,
}: {
params: { embed: keyof typeof configurations };
searchParams: { state: string };
}) {
const config = configurations[params.embed];
const initialState = useMemo(
() => ({
...extractEmbedConfigDefaults(config),
...(JSON.parse(searchParams.state) as EmbedState.EmbedState),
}),
[params.embed],
);
return (
<EmbedState.Provider data={initialState}>
<PageWrapper>
<Heading>
<Code>{params.embed}</Code>
</Heading>
<Code color="gray">
<pre>{JSON.stringify(initialState, null, 2)}</pre>
</Code>
</PageWrapper>
</EmbedState.Provider>
);
}
@@ -0,0 +1,22 @@
import { useMemo } from 'react';
import * as EmbedState from '../../../../stores/embedState';
import { configurations } from '../configurations';
import { extractEmbedConfigDefaults } from '../utilities';
export default function Layout({
params,
children,
}: {
params: { embed: keyof typeof configurations };
children: React.ReactNode;
}) {
const config = configurations[params.embed];
const initialState = useMemo(
() => extractEmbedConfigDefaults(config),
[params.embed],
);
return (
<EmbedState.Provider data={initialState}>{children}</EmbedState.Provider>
);
}
@@ -0,0 +1,287 @@
'use client';
import {
CodeIcon,
HeightIcon,
ImageIcon,
Link2Icon,
ResetIcon,
TimerIcon,
WidthIcon,
} from '@radix-ui/react-icons';
import { Box, Button, Flex, Heading, ScrollArea, Text } from '@radix-ui/themes';
import { capitalize, startCase } from 'lodash';
import { ReactNode, useEffect, useRef, useState } from 'react';
import { stringify } from 'urlon';
import { CopyButton } from '../../../../components/CopyButton';
import PageWrapper from '../../../../components/PageWrapper';
import { imgur } from '../../../../core/blitzkit/imgur';
import * as App from '../../../../stores/app';
import * as EmbedState from '../../../../stores/embedState';
import { EmbedConfig } from '../../../../stores/embedState';
import { EmbedItemType } from '../../../../stores/embedState/constants';
import { configurations } from '../configurations';
import { Boolean } from './components/Boolean';
import { Color } from './components/Color';
import { Enum } from './components/Enum';
import { Import } from './components/Import';
import { PreviewWrapper } from './components/PreviewWrapper';
import { Radius } from './components/Radius';
import { RichText } from './components/RichText';
import { Size } from './components/Size';
import { SizeWithout0 } from './components/SizeWithout0';
import { Slider } from './components/Slider';
import { Text as TextController } from './components/Text';
export interface EmbedPreviewControllerProps {
configKey: string;
}
export default function Page({
params,
}: {
params: { embed: keyof typeof configurations };
}) {
const embedStateStore = EmbedState.useStore();
const appStore = App.useStore();
const config = configurations[params.embed] as EmbedConfig;
const [backgroundImage, setBackgroundImage] = useState(imgur('SO13zur'));
const fileInput = useRef<HTMLInputElement>();
useEffect(() => {
fileInput.current = document.createElement('input');
fileInput.current.type = 'file';
fileInput.current.style.display = 'none';
fileInput.current.accept = 'image/*';
fileInput.current.onchange = (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
setBackgroundImage(URL.createObjectURL(file));
};
}, []);
return (
<PageWrapper padding="0" size={1028 + 640}>
<Flex>
<ScrollArea
scrollbars="vertical"
style={{ height: '100%', maxWidth: 320 }}
>
<Flex direction="column" gap="2" p="4">
<Import />
<Heading>Export</Heading>
<Text size="2" color="gray" mb="2">
Copy all properties for you streaming software
</Text>
<Flex mb="6" gap="2" wrap="wrap">
<CopyButton
copy={() => {
const { wargaming } = appStore.getState().logins;
if (!wargaming) return;
const state = embedStateStore.getState();
const initial = embedStateStore.getInitialState();
const shallowState: EmbedState.EmbedState = {};
Object.entries(state).forEach(([key, value]) => {
if (value !== initial[key]) {
shallowState[key] = value;
}
});
const searchParams = new URLSearchParams({
id: `${wargaming.id}`,
state: stringify(shallowState),
});
return `${location.origin}/tools/embed/${params.embed}/host?${searchParams.toString()}`;
}}
>
<Link2Icon />
Copy URL
</CopyButton>
<CopyButton
variant="outline"
copy={() =>
'body { margin: 0; background-color: transparent; overflow: hidden; }'
}
>
<CodeIcon />
Copy CSS
</CopyButton>
<CopyButton
variant="outline"
copy={() => `${embedStateStore.getState().width}`}
>
<WidthIcon />
Copy width
</CopyButton>
<CopyButton
variant="outline"
copy={() => `${embedStateStore.getState().height}`}
>
<HeightIcon />
Copy height
</CopyButton>
<CopyButton variant="outline" copy={() => '`'}>
<TimerIcon />
Copy framerate
</CopyButton>
</Flex>
<Flex justify="between" align="center">
<Heading>Customize</Heading>
<Button
color="red"
variant="ghost"
onClick={() => {
embedStateStore.setState(
embedStateStore.getInitialState(),
true,
);
}}
>
<ResetIcon /> Reset
</Button>
</Flex>
<Text size="2" color="gray" mb="2">
Bells and whistles of your embed
</Text>
{Object.keys(config).map((configKey) => {
const item = config[configKey];
let control: ReactNode;
let oneLiner = false;
switch (item.type) {
case EmbedItemType.Boolean: {
control = <Boolean configKey={configKey} />;
oneLiner = true;
break;
}
case EmbedItemType.Color: {
control = <Color configKey={configKey} />;
break;
}
case EmbedItemType.Radius: {
control = <Radius configKey={configKey} />;
oneLiner = true;
break;
}
case EmbedItemType.Size: {
control = <Size configKey={configKey} />;
oneLiner = true;
break;
}
case EmbedItemType.SizeWithout0: {
control = <SizeWithout0 configKey={configKey} />;
oneLiner = true;
break;
}
case EmbedItemType.Slider: {
control = <Slider configKey={configKey} config={item} />;
oneLiner = true;
break;
}
case EmbedItemType.String: {
control = <TextController configKey={configKey} />;
break;
}
case EmbedItemType.RichText: {
control = <RichText configKey={configKey} />;
break;
}
case EmbedItemType.Enum: {
control = <Enum configKey={configKey} config={item} />;
oneLiner = true;
break;
}
}
return (
<Flex
direction={oneLiner ? undefined : 'column'}
gap={oneLiner ? '2' : '1'}
justify={oneLiner ? 'between' : undefined}
mb={oneLiner ? '2' : '4'}
pb={item.pad ? '6' : undefined}
>
<Text>{capitalize(startCase(configKey as string))}</Text>
<Flex
gap="2"
align="center"
flexGrow="1"
justify={oneLiner ? 'end' : undefined}
>
{control}
</Flex>
</Flex>
);
})}
</Flex>
</ScrollArea>
<Box
p="4"
flexGrow="1"
position="sticky"
// TODO: re-implement this sticky preview
// top={`${NAVBAR_HEIGHT}px`}
// height={`calc(100vh - ${NAVBAR_HEIGHT}px)`}
>
<Flex
direction="column"
height="100%"
style={{
borderRadius: 'var(--radius-3)',
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
}}
>
<Flex
justify="between"
align="center"
p="2"
pl="3"
style={{
backgroundColor: 'var(--color-panel-solid)',
}}
>
<Text color="gray">Example preview</Text>
<Button
variant="outline"
color="gray"
onClick={() => fileInput.current?.click()}
>
<ImageIcon /> Upload test background
</Button>
</Flex>
<Box
flexGrow="1"
style={{
background: `url(${backgroundImage}) center / cover no-repeat`,
}}
position="relative"
overflow="hidden"
>
<PreviewWrapper name={params.embed} />
</Box>
</Flex>
</Box>
</Flex>
</PageWrapper>
);
}
@@ -0,0 +1,109 @@
import { grayA, grayDark, grayDarkA } from '@radix-ui/colors';
import { use } from 'react';
import {
BreakdownEmbedCard,
BreakdownEmbedWrapper,
} from '../../../../components/TanksEmbed';
import { tankDefinitionsArray } from '../../../../core/blitzkit/tankDefinitions';
import {
CompositeStatsKey,
compositeStatsKeys,
} from '../../../../core/statistics/compositeStats/constants';
import strings from '../../../../lang/en-US.json';
import { EmbedConfig } from '../../../../stores/embedState';
import { EmbedItemType } from '../../../../stores/embedState/constants';
import { useEmbedStateCurry } from '../../../../stores/embedState/utilities';
const compositeStatsKeysOptions = compositeStatsKeys.map((value) => ({
label: strings.common.composite_stats[value],
value,
}));
export const breakdownConfig = {
width: { type: EmbedItemType.Slider, default: 320, min: 128, max: 640 },
height: { type: EmbedItemType.Slider, default: 492, min: 128, max: 1024 },
showTotal: { type: EmbedItemType.Boolean, default: true, pad: true },
listGap: { type: EmbedItemType.Size, default: '2' },
listMaxTanks: {
type: EmbedItemType.Slider,
default: 4,
min: 0,
max: 8,
pad: true,
},
cardRadius: { type: EmbedItemType.Radius, default: '2' },
cardHeaderBackgroundColor: {
type: EmbedItemType.Color,
default: grayDarkA.grayA3,
},
cardHeaderPadding: { type: EmbedItemType.Size, default: '1' },
cardTitle: {
type: EmbedItemType.RichText,
default: { color: grayDark.gray12, weight: 'bold', size: '3' },
},
cardTitleTypeColor: { type: EmbedItemType.Boolean, default: true },
cardTitleClassIcon: { type: EmbedItemType.Boolean, default: true },
cardBodyBackgroundColor: {
type: EmbedItemType.Color,
default: grayA.grayA11,
},
cardBodyPadding: {
type: EmbedItemType.Size,
default: '2',
pad: true,
},
columnGap: { type: EmbedItemType.Size, default: '0' },
columnValue: {
type: EmbedItemType.RichText,
default: { color: grayDark.gray12, size: '3', weight: 'regular' },
},
columnLabel: {
type: EmbedItemType.RichText,
default: { color: grayDark.gray11, size: '2', weight: 'regular' },
pad: true,
},
column1: {
type: EmbedItemType.Enum,
default: 'cumulative_battles' satisfies CompositeStatsKey,
options: compositeStatsKeysOptions,
},
column2: {
type: EmbedItemType.Enum,
default: 'normalized_wins' satisfies CompositeStatsKey,
options: compositeStatsKeysOptions,
},
column3: {
type: EmbedItemType.Enum,
default: 'cumulative_wn8' satisfies CompositeStatsKey,
options: compositeStatsKeysOptions,
},
column4: {
type: EmbedItemType.Enum,
default: 'normalized_damage_dealt' satisfies CompositeStatsKey,
options: compositeStatsKeysOptions,
},
column1CustomLabel: { type: EmbedItemType.String, default: '' },
column2CustomLabel: { type: EmbedItemType.String, default: '' },
column3CustomLabel: { type: EmbedItemType.String, default: '' },
column4CustomLabel: { type: EmbedItemType.String, default: '' },
} satisfies EmbedConfig;
export function BreakdownPreview() {
const tanks = use(tankDefinitionsArray);
const { useState } = useEmbedStateCurry<typeof breakdownConfig>();
return (
<BreakdownEmbedWrapper>
{useState('showTotal') && <BreakdownEmbedCard tank={null} />}
{tanks.slice(0, useState('listMaxTanks')).map((tank) => (
<BreakdownEmbedCard key={tank.id} tank={tank} />
))}
</BreakdownEmbedWrapper>
);
}
@@ -0,0 +1,11 @@
import { ReactNode } from 'react';
import { EmbedConfig } from '../../../../stores/embedState';
import { BreakdownPreview, breakdownConfig } from './breakdown';
export const configurations = {
breakdown: breakdownConfig,
} satisfies Record<string, EmbedConfig>;
export const previews = {
breakdown: BreakdownPreview,
} satisfies Record<string, () => ReactNode>;
@@ -0,0 +1,5 @@
import PageWrapper from '../../../components/PageWrapper';
export default function Page() {
return <PageWrapper></PageWrapper>;
}
@@ -0,0 +1,26 @@
import {
EmbedConfig,
ExtractEmbedConfigTypes,
RadixColorCompound,
RadixRadius,
} from '../../../stores/embedState';
export function extractEmbedConfigDefaults<Config extends EmbedConfig>(
config: Config,
) {
return Object.fromEntries(
Object.entries(config).map(([key, value]) => [key, value.default]),
) as ExtractEmbedConfigTypes<Config>;
}
export function toWidthVar(state: number) {
return `${state}px`;
}
export function toRadiusVar(state: RadixRadius) {
return `var(--radius-${state})`;
}
export function toColorVar(state: RadixColorCompound) {
return `var(--${state.base}-${state.variant})`;
}
+101
View File
@@ -0,0 +1,101 @@
'use client';
import { imgur, ImgurSize } from '@blitzkit/core';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import { TOOLS } from '../../constants/tools';
interface ToolsLayoutProps {
children: ReactNode;
}
export default function ToolsLayout({ children }: ToolsLayoutProps) {
const pathname = usePathname();
const pathSplit = pathname.split('/');
const toolId = pathSplit.at(2);
const isToolRoot = pathname === `/tools/${toolId}`;
const tool = TOOLS.find((tool) => tool.id === toolId);
return (
<>
{tool && (
<>
{isToolRoot && (
<>
<title>{tool.title}</title>
<meta property="og:image" content={imgur(tool.image)} />
{tool.pageDescription !== undefined && (
<>
<meta name="description" content={tool.pageDescription} />
<meta
property="og:description"
content={tool.pageDescription}
/>
</>
)}
</>
)}
</>
)}
{pathSplit.length === 3 && (
<div
style={{
height: 128,
boxShadow: `inset 0 -192px 128px -128px red`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
backgroundImage: `url(${imgur(tool!.image, {
format: 'jpeg',
size: ImgurSize.HugeThumbnail,
})})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
width: '100%',
height: '100%',
top: 0,
left: 0,
zIndex: -1,
// filter: 'blur(4px)',
transform: 'scale(calc((128 + 8) / 128))',
}}
/>
<Link
href={`/tools/${toolId}`}
style={{
textDecoration: 'none',
fontWeight: 900,
color: 'red',
fontSize: 32,
letterSpacing: -1,
}}
>
{tool?.title}
</Link>
<span
style={{
color: 'red',
fontSize: 16,
}}
>
{tool?.description}
</span>
</div>
)}
{children}
</>
);
}
@@ -0,0 +1,13 @@
import { Table } from '@radix-ui/themes';
import { times } from 'lodash';
import { RowLoader } from './RowLoader';
export function BodyLoader() {
return (
<Table.Body>
{times(10, (index) => (
<RowLoader key={index} />
))}
</Table.Body>
);
}
@@ -0,0 +1,11 @@
import { FilterControl } from '../../tankopedia/components/FilterControl';
import { PlayerCountControl } from './PlayerCountControl';
export function Controls() {
return (
<>
<FilterControl />
<PlayerCountControl />
</>
);
}
@@ -0,0 +1,65 @@
'use client';
import {
CaretDownIcon,
CaretSortIcon,
CaretUpIcon,
} from '@radix-ui/react-icons';
import { Flex, IconButton, Table } from '@radix-ui/themes';
import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
import * as TankPerformanceSort from '../../../../stores/tankPerformanceSort';
import {
TankPerformanceSortTypeNames,
TankPerformanceSortTypeNamesArray,
} from '../../../../stores/tankPerformanceSort/constants';
export function Header() {
const sort = TankPerformanceSort.use();
const store = TankPerformanceSort.useStore();
return (
<Table.Header>
<Table.Row align="center">
<StickyColumnHeaderCell>Tank</StickyColumnHeaderCell>
{TankPerformanceSortTypeNamesArray.map((type) => {
const isSelected = sort.type === type;
return (
<StickyColumnHeaderCell
px="2"
key={type}
width="0"
justify="center"
>
<Flex align="center" gap="1">
{TankPerformanceSortTypeNames[type]}
<IconButton
size={isSelected ? '1' : '2'}
variant={isSelected ? 'soft' : 'ghost'}
color={isSelected ? undefined : 'gray'}
highContrast={!isSelected}
onClick={() => {
if (isSelected) {
store.setState({ direction: -sort.direction as 1 | -1 });
} else {
store.setState({ type, direction: -1 });
}
}}
>
{!isSelected && <CaretSortIcon />}
{isSelected && (
<>
{sort.direction === 1 && <CaretUpIcon />}
{sort.direction === -1 && <CaretDownIcon />}
</>
)}
</IconButton>
</Flex>
</StickyColumnHeaderCell>
);
})}
</Table.Row>
</Table.Header>
);
}
@@ -0,0 +1,33 @@
'use client';
import { InfoCircledIcon } from '@radix-ui/react-icons';
import { Callout, Flex } from '@radix-ui/themes';
import { use } from 'react';
import { averageDefinitions } from '../../../../core/blitzkit/averageDefinitions';
import { discoveredIdsDefinitions } from '../../../../core/blitzkit/discoveredIdDefinitions';
import { formatCompact } from '../../../../core/math/formatCompact';
export function Info() {
const awaitedDiscoveredIdsDefinitions = use(discoveredIdsDefinitions);
const awaitedAverageDefinitions = use(averageDefinitions);
const samples = awaitedDiscoveredIdsDefinitions.count;
const minutesAgo = Math.floor(
(Date.now() - awaitedAverageDefinitions.time) / (1000 * 60),
);
const hoursAgo = Math.floor(minutesAgo / 60);
return (
<Flex justify="center">
<Callout.Root style={{ width: 'fit-content' }}>
<Callout.Icon>
<InfoCircledIcon />
</Callout.Icon>
<Callout.Text>
Career stats based on {formatCompact(Math.round(samples))} players.
Updated {hoursAgo === 0 ? minutesAgo : hoursAgo}{' '}
{hoursAgo === 0 ? 'minutes' : 'hours'} ago.
</Callout.Text>
</Callout.Root>
</Flex>
);
}
@@ -0,0 +1,48 @@
'use client';
import { Flex, Select, Text } from '@radix-ui/themes';
import * as TankPerformancePersistent from '../../../../stores/tankPerformancePersistent';
import { PlayerCountPeriod } from '../../../../stores/tankPerformancePersistent/constants';
export function PlayerCountControl() {
const playerCountPeriod = TankPerformancePersistent.use(
(state) => state.playerCountPeriod,
);
const mutateTankPerformanceSort = TankPerformancePersistent.useMutation();
return (
<Flex justify="center" align="center" gap="2">
<Text>Player count</Text>
<Select.Root
value={playerCountPeriod}
onValueChange={(period) => {
mutateTankPerformanceSort((draft) => {
draft.playerCountPeriod = period as PlayerCountPeriod;
});
}}
>
<Select.Trigger />
<Select.Content>
<Select.Item value={PlayerCountPeriod.Past120Days}>
Past 120 days
</Select.Item>
<Select.Item value={PlayerCountPeriod.Past90Days}>
Past 90 days
</Select.Item>
<Select.Item value={PlayerCountPeriod.Past60Days}>
Past 60 days
</Select.Item>
<Select.Item value={PlayerCountPeriod.ThisMonth}>
This month
</Select.Item>
<Select.Item value={PlayerCountPeriod.ThisWeek}>
This week
</Select.Item>
<Select.Item value={PlayerCountPeriod.Yesterday}>
Yesterday
</Select.Item>
</Select.Content>
</Select.Root>
</Flex>
);
}
@@ -0,0 +1,31 @@
import { Skeleton, Table } from '@radix-ui/themes';
import { times } from 'lodash';
import { memo, useRef } from 'react';
import { useIntersection } from '../../../../hooks/useIntersection';
import { TankPerformanceSortTypeNamesArray } from '../../../../stores/tankPerformanceSort/constants';
interface RowLoaderProps {
onIntersection?: () => void;
}
export const RowLoader = memo<RowLoaderProps>(({ onIntersection }) => {
const row = useRef<HTMLTableRowElement>(null);
useIntersection(() => onIntersection?.(), row, {
disabled: onIntersection === undefined,
});
return (
<Table.Row ref={row}>
<Table.RowHeaderCell align="center">
<Skeleton height="100%" width="128px" />
</Table.RowHeaderCell>
{times(TankPerformanceSortTypeNamesArray.length, (index) => (
<Table.Cell key={index} align="center">
<Skeleton height="1em" width="32px" />
</Table.Cell>
))}
</Table.Row>
);
});
@@ -0,0 +1,25 @@
'use client';
import { Flex } from '@radix-ui/themes';
import { Suspense } from 'react';
import { StickyTableRoot } from '../../../../components/StickyTableRoot';
import { BodyLoader } from './BodyLoader';
import { Header } from './Header';
import { Tanks } from './Tanks';
export function TankTable() {
return (
<Flex justify="center">
<StickyTableRoot
size={{ initial: '1', sm: '2' }}
variant="surface"
style={{ maxWidth: '100%', maxHeight: 'calc(100vh - 256px)' }}
>
<Header />
<Suspense fallback={<BodyLoader />}>
<Tanks />
</Suspense>
</StickyTableRoot>
</Flex>
);
}
@@ -0,0 +1,79 @@
import { Table } from '@radix-ui/themes';
import { memo, use } from 'react';
import { TankRowHeaderCell } from '../../../../components/TankRowHeaderCell';
import { averageDefinitions } from '../../../../core/blitzkit/averageDefinitions';
import { TankDefinition } from '../../../../core/blitzkit/tankDefinitions';
import { formatCompact } from '../../../../core/math/formatCompact';
import { useAveragesExclusionRatio } from '../../../../hooks/useAveragesExclusionRatio';
import * as TankPerformancePersistent from '../../../../stores/tankPerformancePersistent';
interface TankRowProps {
tank: TankDefinition;
}
export const TankRow = memo<TankRowProps>(
({ tank }) => {
const awaitedAverageDefinitions = use(averageDefinitions);
const averages = awaitedAverageDefinitions.averages[tank.id];
const ratio = useAveragesExclusionRatio();
const playerCountPeriod = TankPerformancePersistent.use(
(state) => state.playerCountPeriod,
);
return (
<Table.Row>
<TankRowHeaderCell tank={tank} />
<Table.Cell align="center">
{((averages.mu.wins / averages.mu.battles) * 100).toFixed(1)}%
</Table.Cell>
<Table.Cell align="center">
{formatCompact(
Math.round(ratio * averages.samples[playerCountPeriod]),
)}
</Table.Cell>
<Table.Cell align="center">
{Math.round(
averages.mu.damage_dealt / averages.mu.battles,
).toLocaleString()}
</Table.Cell>
<Table.Cell align="center">
{Math.round(
(averages.mu.survived_battles / averages.mu.battles) * 100,
)}
%
</Table.Cell>
<Table.Cell align="center">
{Math.round(averages.mu.xp / averages.mu.battles).toLocaleString()}
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.frags / averages.mu.battles).toFixed(2)}
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.spotted / averages.mu.battles).toFixed(2)}
</Table.Cell>
<Table.Cell align="center">
{Math.round((averages.mu.hits / averages.mu.shots) * 100)}%
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.shots / averages.mu.battles).toFixed(1)}
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.hits / averages.mu.battles).toFixed(1)}
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.damage_dealt / averages.mu.damage_received).toFixed(2)}
</Table.Cell>
<Table.Cell align="center">
{Math.round(
averages.mu.damage_received / averages.mu.battles,
).toLocaleString()}
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.capture_points / averages.mu.battles).toFixed(2)}
</Table.Cell>
</Table.Row>
);
},
(prev, next) => prev.tank.id === next.tank.id,
);
@@ -0,0 +1,151 @@
'use client';
import { Table } from '@radix-ui/themes';
import { times } from 'lodash';
import { Suspense, use, useEffect, useMemo, useState } from 'react';
import { averageDefinitionsArray } from '../../../../core/blitzkit/averageDefinitions';
import { filterTank } from '../../../../core/blitzkit/filterTank';
import { tankDefinitions } from '../../../../core/blitzkit/tankDefinitions';
import * as TankFilters from '../../../../stores/tankFilters';
import * as TankPerformancePersistent from '../../../../stores/tankPerformancePersistent';
import * as TankPerformanceSort from '../../../../stores/tankPerformanceSort';
import { RowLoader } from './RowLoader';
import { TankRow } from './TankRow';
import { Total } from './Total';
const PREVIEW_COUNT = 10;
const DEFAULT_LOADED_ROWS = 25;
export function Tanks() {
const awaitedTankDefinitions = use(tankDefinitions);
const awaitedAverageDefinitionsArray = use(averageDefinitionsArray);
const filters = TankFilters.use();
const sort = TankPerformanceSort.use();
const tankPerformancePersistentStore = TankPerformancePersistent.useStore();
const tanksSorted = useMemo(() => {
const { playerCountPeriod } = tankPerformancePersistentStore.getState();
switch (sort.type) {
case 'accuracy':
return awaitedAverageDefinitionsArray.sort(
(a, b) =>
sort.direction * (a.mu.hits / a.mu.shots - b.mu.hits / b.mu.shots),
);
case 'capturePoints':
return awaitedAverageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.capture_points / a.mu.battles -
b.mu.capture_points / b.mu.battles),
);
case 'damage':
return awaitedAverageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.damage_dealt / a.mu.battles -
b.mu.damage_dealt / b.mu.battles),
);
case 'damageRatio':
return awaitedAverageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.damage_dealt / a.mu.damage_received -
b.mu.damage_dealt / b.mu.damage_received),
);
case 'damageTaken':
return awaitedAverageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.damage_received / a.mu.battles -
b.mu.damage_received / b.mu.battles),
);
case 'hits':
return awaitedAverageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.hits / a.mu.battles - b.mu.hits / b.mu.battles),
);
case 'kills':
return awaitedAverageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.frags / a.mu.battles - b.mu.frags / b.mu.battles),
);
case 'shots':
return awaitedAverageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.shots / a.mu.battles - b.mu.shots / b.mu.battles),
);
case 'players':
return awaitedAverageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.samples[playerCountPeriod] - b.samples[playerCountPeriod]),
);
case 'spots':
return awaitedAverageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.spotted / a.mu.battles - b.mu.spotted / b.mu.battles),
);
case 'survival':
return awaitedAverageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.survived_battles / a.mu.battles -
b.mu.survived_battles / b.mu.battles),
);
case 'winrate':
return awaitedAverageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.wins / a.mu.battles - b.mu.wins / b.mu.battles),
);
case 'xp':
return awaitedAverageDefinitionsArray.sort(
(a, b) =>
sort.direction * (a.mu.xp / a.mu.battles - b.mu.xp / b.mu.battles),
);
}
}, [sort]);
const tanks = useMemo(() => {
return tanksSorted.filter((tank) =>
filterTank(filters, awaitedTankDefinitions[tank.id]),
);
}, [filters, sort]);
const [loadedRows, setLoadedRows] = useState(DEFAULT_LOADED_ROWS);
useEffect(() => {
setLoadedRows(DEFAULT_LOADED_ROWS);
}, [filters, sort]);
return (
<Table.Body>
<Total tanks={tanks} />
{tanks.slice(0, loadedRows).map((averages) => {
const tank = awaitedTankDefinitions[averages.id];
if (tank === undefined) return null;
return (
<Suspense key={tank.id} fallback={<RowLoader />}>
<TankRow tank={tank} />
</Suspense>
);
})}
{times(Math.min(PREVIEW_COUNT, tanks.length - loadedRows), (index) => {
return (
<RowLoader
key={index}
onIntersection={() => {
setLoadedRows((state) => Math.min(state + 2, tanks.length));
}}
/>
);
})}
</Table.Body>
);
}
@@ -0,0 +1,86 @@
'use client';
import { ResponsiveBar } from '@nivo/bar';
import { Box, Flex, Heading } from '@radix-ui/themes';
import { times } from 'lodash';
import { use } from 'react';
import { TankClass } from '../../../../components/Tanks';
import { TANK_CLASSES } from '../../../../components/Tanks/components/Item/constants';
import { averageDefinitionsArray } from '../../../../core/blitzkit/averageDefinitions';
import {
tankDefinitions,
Tier,
} from '../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/constants';
import { formatCompact } from '../../../../core/math/formatCompact';
import { nivoTheme } from '../../../../core/nivo/theme';
import strings from '../../../../lang/en-US.json';
export function TierBreakdown() {
const awaitedAverageDefinitionsArray = use(averageDefinitionsArray);
const awaitedTankDefinitions = use(tankDefinitions);
return (
<Flex justify="center" py="6">
<Flex direction="column" flexGrow="1" gap="4" maxWidth="640px">
<Heading ml="2" size="4">
Players (30d) per tier
</Heading>
<Box height="320px" width="100%">
<ResponsiveBar
{...nivoTheme}
data={times(10, (index) => {
const tier = (index + 1) as Tier;
const tierTanks = awaitedAverageDefinitionsArray.filter(
({ id }) => awaitedTankDefinitions[id]?.tier === tier,
);
const populations: Record<TankClass, number> = {
'AT-SPG': 0,
heavyTank: 0,
lightTank: 0,
mediumTank: 0,
};
tierTanks.forEach((stats) => {
const tank = awaitedTankDefinitions[stats.id];
populations[tank.class] +=
stats.mu.battles * stats.samples.d_30;
});
TANK_CLASSES.forEach((tankClass) => {
populations[tankClass] = Math.round(populations[tankClass]);
});
return { tier: TIER_ROMAN_NUMERALS[tier], ...populations };
})}
keys={[...TANK_CLASSES].reverse()}
indexBy="tier"
colors={{ scheme: 'dark2' }}
label={(d) => formatCompact(d.value as number)}
margin={{ right: 96, bottom: 20, left: 40 }}
legendLabel={(d) =>
strings.common.tank_class_short[d.id as TankClass]
}
axisLeft={{
tickSize: 0,
format: (value) => formatCompact(value as number),
}}
axisBottom={{ tickSize: 0 }}
legends={[
{
dataFrom: 'keys',
anchor: 'bottom-right',
direction: 'column',
translateX: 120,
itemsSpacing: 4,
itemWidth: 100,
itemHeight: 20,
},
]}
/>
</Box>
</Flex>
</Flex>
);
}
@@ -0,0 +1,83 @@
import { Table } from '@radix-ui/themes';
import { memo, use, useCallback } from 'react';
import { StickyRowHeaderCell } from '../../../../components/StickyRowHeaderCell';
import {
averageDefinitions,
AverageDefinitionsEntryWithId,
} from '../../../../core/blitzkit/averageDefinitions';
import { formatCompact } from '../../../../core/math/formatCompact';
import { BlitzkitStats } from '../../../../core/statistics/compositeStats/constants';
import { useAveragesExclusionRatio } from '../../../../hooks/useAveragesExclusionRatio';
import * as TankPerformancePersistent from '../../../../stores/tankPerformancePersistent';
interface TotalProps {
tanks: AverageDefinitionsEntryWithId[];
}
export const Total = memo<TotalProps>(
({ tanks }) => {
const awaitedAverageDefinitions = use(averageDefinitions);
const playerCountPeriod = TankPerformancePersistent.use(
(state) => state.playerCountPeriod,
);
const ratio = useAveragesExclusionRatio();
const sum = useCallback(
(slice: (tank: BlitzkitStats) => number) => {
return tanks.reduce(
(acc, tank) => acc + tank.samples.total * slice(tank.mu),
0,
);
},
[tanks],
);
const battles = sum(({ battles }) => battles);
const winrate = sum(({ wins }) => wins) / battles;
const damage = sum(({ damage_dealt }) => damage_dealt) / battles;
const survival = sum(({ survived_battles }) => survived_battles) / battles;
const xp = sum(({ xp }) => xp) / battles;
const kills = sum(({ frags }) => frags) / battles;
const spots = sum(({ spotted }) => spotted) / battles;
const hits = sum(({ hits }) => hits) / battles;
const shots = sum(({ shots }) => shots) / battles;
const accuracy = hits / shots;
const damageReceived =
sum(({ damage_received }) => damage_received) / battles;
const damageRatio = damage / damageReceived;
const capturePoints = sum(({ capture_points }) => capture_points) / battles;
return (
<Table.Row>
<StickyRowHeaderCell style={{ overflow: 'hidden', display: 'flex' }}>
Total
</StickyRowHeaderCell>
<Table.Cell align="center">{(winrate * 100).toFixed(1)}%</Table.Cell>
<Table.Cell align="center">
{formatCompact(
ratio * awaitedAverageDefinitions.samples[playerCountPeriod],
)}
</Table.Cell>
<Table.Cell align="center">
{Math.round(damage).toLocaleString()}
</Table.Cell>
<Table.Cell align="center">
{Math.round(survival * 100).toFixed(1)}%
</Table.Cell>
<Table.Cell align="center">
{Math.round(xp).toLocaleString()}
</Table.Cell>
<Table.Cell align="center">{kills.toFixed(2)}</Table.Cell>
<Table.Cell align="center">{spots.toFixed(2)}</Table.Cell>
<Table.Cell align="center">{(accuracy * 100).toFixed(0)}%</Table.Cell>
<Table.Cell align="center">{shots.toFixed(1)}</Table.Cell>
<Table.Cell align="center">{hits.toFixed(1)}</Table.Cell>
<Table.Cell align="center">{damageRatio.toFixed(2)}</Table.Cell>
<Table.Cell align="center">
{Math.round(damageReceived).toLocaleString()}
</Table.Cell>
<Table.Cell align="center">{capturePoints.toFixed(2)}</Table.Cell>
</Table.Row>
);
},
(prev, next) => prev.tanks === next.tanks,
);
@@ -0,0 +1,14 @@
import { ReactNode } from 'react';
import * as TankFilters from '../../../stores/tankFilters';
import * as TankPerformancePersistent from '../../../stores/tankPerformancePersistent';
import * as TankPerformanceSort from '../../../stores/tankPerformanceSort';
export default function Layout({ children }: { children: ReactNode }) {
return (
<TankPerformancePersistent.Provider>
<TankPerformanceSort.Provider>
<TankFilters.Provider>{children}</TankFilters.Provider>
</TankPerformanceSort.Provider>
</TankPerformancePersistent.Provider>
);
}
@@ -0,0 +1,15 @@
import PageWrapper from '../../../components/PageWrapper';
import { Controls } from './components/Controls';
import { Info } from './components/Info';
import { TankTable } from './components/Table';
export default function Page() {
return (
<PageWrapper color="jade" noMaxWidth>
<Info />
{/* <TierBreakdown /> */}
<Controls />
<TankTable />
</PageWrapper>
);
}
@@ -0,0 +1,13 @@
import { keyframes, style } from '@vanilla-extract/css';
const spinKeyframes = keyframes({
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' },
});
export const infiniteSpin = style({
animationName: spinKeyframes,
animationIterationCount: 'infinite',
animationTimingFunction: 'linear',
animationDuration: '1s',
});
@@ -0,0 +1,94 @@
import {
CaretLeftIcon,
CaretRightIcon,
SymbolIcon,
} from '@radix-ui/react-icons';
import { Flex, IconButton, TextField } from '@radix-ui/themes';
import { range } from 'lodash';
import { useEffect, useRef } from 'react';
import { infiniteSpin } from './index.css';
interface PageTurnerProps {
leaderboard: Record<number, number>;
page: number;
rowsPerPage: number;
pages: number;
onPageChange: (page: number, isButtonClick: boolean) => void;
totalPlayers: number;
}
export function PageTurner({
page,
rowsPerPage,
onPageChange,
pages,
leaderboard,
totalPlayers,
}: PageTurnerProps) {
const pageInput = useRef<HTMLInputElement>(null);
const rightLoading = range(
page * rowsPerPage,
Math.min(totalPlayers - 1, page * rowsPerPage + rowsPerPage + 1),
).some((index) => !(index in leaderboard));
const leftLoading = range(
Math.max(0, page * rowsPerPage - 1),
Math.max(0, page * rowsPerPage - rowsPerPage - 1) + 1,
).some((index) => !(index in leaderboard));
useEffect(() => {
if (pageInput.current) pageInput.current.value = `${page + 1}`;
}, [page]);
return (
<Flex justify="center" wrap="wrap" gap="2">
<Flex gap="2">
<IconButton
variant="soft"
onClick={() => onPageChange(Math.max(page - 1, 0), true)}
disabled={page === 0 || leftLoading}
>
{leftLoading ? (
<SymbolIcon className={infiniteSpin} />
) : (
<CaretLeftIcon />
)}
</IconButton>
<TextField.Root
type="number"
ref={pageInput}
style={{ textAlign: 'center' }}
min={1}
max={pages}
onBlur={(event) => {
onPageChange(
Math.max(0, Math.min(pages - 1, event.target.valueAsNumber - 1)),
false,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
>
<TextField.Slot>Page</TextField.Slot>
<TextField.Slot>out of {pages}</TextField.Slot>
</TextField.Root>
<IconButton
variant="soft"
onClick={() => {
onPageChange(Math.min(page + 1, pages - 1), true);
}}
disabled={page === pages - 1 || rightLoading}
>
{rightLoading ? (
<SymbolIcon className={infiniteSpin} />
) : (
<CaretRightIcon />
)}
</IconButton>
</Flex>
</Flex>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,6 @@
import { ReactNode } from 'react';
import * as Session from '../../../stores/session';
export default function Layout({ children }: { children: ReactNode }) {
return <Session.Provider>{children}</Session.Provider>;
}
@@ -0,0 +1,500 @@
'use client';
import {
ArrowDownIcon,
MagnifyingGlassIcon,
PlusIcon,
} from '@radix-ui/react-icons';
import {
AlertDialog,
Button,
DropdownMenu,
Flex,
Heading,
IconButton,
Select,
Spinner,
Table,
Text,
TextField,
} from '@radix-ui/themes';
import { debounce } from 'lodash';
import { use, useEffect, useMemo, useRef, useState } from 'react';
import PageWrapper from '../../../components/PageWrapper';
import { TankRowHeaderCell } from '../../../components/TankRowHeaderCell';
import { UNLOCALIZED_REGION_NAMES_SHORT } from '../../../constants/regions';
import {
STAT_KEYS,
STAT_NAMES,
Stat,
generateStats,
prettifyStats,
sumStats,
} from '../../../core/blitz/generateStats';
import {
IndividualAccountInfo,
getAccountInfo,
} from '../../../core/blitz/getAccountInfo';
import getTankStats from '../../../core/blitz/getTankStats';
import { idToRegion } from '../../../core/blitz/idToRegion';
import searchPlayersAcrossRegions, {
AccountListWithServer,
} from '../../../core/blitz/searchPlayersAcrossRegions';
import { tankDefinitions } from '../../../core/blitzkit/tankDefinitions';
import { tankAverages } from '../../../core/blitzstars/tankAverages';
import { deltaTankStats } from '../../../core/statistics/deltaTankStats';
import * as Session from '../../../stores/session';
import { IndividualTankStats } from '../../../types/tanksStats';
export default function Page({
searchParams,
}: {
searchParams: { id?: string };
}) {
const [loaded, setLoaded] = useState(false);
const awaitedTankDefinitions = use(tankDefinitions);
const awaitedTankAvearges = use(tankAverages);
const [showSearch, setShowSearch] = useState(false);
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<AccountListWithServer>([]);
const [showCCInaccessibilityPrompt, setShowCCInaccessibilityPrompt] =
useState(false);
const input = useRef<HTMLInputElement>(null);
const session = Session.use();
const mutateSession = Session.useMutation();
const [tankStatsB, setTankStatsB] = useState<IndividualTankStats[] | null>(
null,
);
const accountInfoPromise = useMemo(
() =>
new Promise<IndividualAccountInfo | null>(async (resolve) => {
resolve(
session.tracking
? await getAccountInfo(session.player.region, session.player.id)
: null,
);
}),
[session.tracking && session.player.id],
);
const accountInfo = use(accountInfoPromise);
const delta = useMemo(
() =>
session.tracking && tankStatsB
? deltaTankStats(session.player.stats, tankStatsB)
.sort((a, b) => b.last_battle_time - a.last_battle_time)
.map((entry) => {
const tank = awaitedTankDefinitions[entry.tank_id];
const average = awaitedTankAvearges[tank.id];
const stats = generateStats(entry.all, average?.all);
const statsPretty = prettifyStats(stats);
return {
tank,
stats,
statsPretty,
};
})
: undefined,
[session.tracking && session.player, tankStatsB],
);
const total = useMemo(
() =>
delta
? prettifyStats(sumStats(delta.map(({ stats }) => stats)))
: undefined,
[delta],
);
const [showLinkOverridePrompt, setShowLinkOverridePrompt] = useState(true);
const search = debounce(async () => {
if (!input.current) return;
setSearchResults(await searchPlayersAcrossRegions(input.current.value, 15));
setSearching(false);
}, 500);
async function track(id: number) {
const region = idToRegion(id);
const player = await getAccountInfo(region, id);
const stats = await getTankStats(region, id);
if (stats === null) {
setShowCCInaccessibilityPrompt(true);
return;
}
setShowSearch(false);
mutateSession((draft) => {
if (!input.current) return;
draft.tracking = true;
(draft as Session.SessionTracking).player = {
id,
region,
since: Date.now(),
stats,
};
input.current.value = player.nickname;
});
}
useEffect(() => {
async function update() {
if (!session.tracking) return;
const tankStats = await getTankStats(
idToRegion(session.player.id),
session.player.id,
);
setTankStatsB(tankStats);
}
update();
const interval = setInterval(update, 5 * 1000);
return () => clearInterval(interval);
}, [session.tracking && session.player.id]);
useEffect(() => {
setLoaded(true);
if (searchParams.id && !session.tracking) {
track(Number(searchParams.id));
}
}, []);
if (!loaded) {
return (
<PageWrapper align="center" justify="center">
<Spinner />
</PageWrapper>
);
}
return (
<PageWrapper color="blue">
<AlertDialog.Root
open={showCCInaccessibilityPrompt}
onOpenChange={setShowCCInaccessibilityPrompt}
>
<AlertDialog.Content>
<AlertDialog.Title>
You're attempting to track a CC account
</AlertDialog.Title>
<AlertDialog.Description>
CC (community contributor) accounts do not have public tank
statistics. We are in the process of adding embeds for CC accounts,
stay tuned.
</AlertDialog.Description>
<Flex mt="4" justify="end">
<AlertDialog.Action>
<Button variant="solid">Alright</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
{searchParams.id &&
session.tracking &&
Number(searchParams.id) !== session.player.id && (
<AlertDialog.Root
open={showLinkOverridePrompt}
onOpenChange={setShowLinkOverridePrompt}
>
<AlertDialog.Content>
<AlertDialog.Title>
You are currently tracking {accountInfo?.nickname}
</AlertDialog.Title>
<AlertDialog.Description>
Would you like to stop tracking {accountInfo?.nickname} and
start tracking a new player?
</AlertDialog.Description>
<Flex mt="4" justify="end">
<AlertDialog.Action>
<Button
variant="solid"
onClick={() => {
setShowLinkOverridePrompt(false);
track(Number(searchParams.id));
}}
>
Proceed
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
)}
{!session.tracking && (
<Flex
style={{ flex: 1 }}
align="center"
justify="center"
direction="column"
gap="3"
>
<Flex gap="2" align="center">
<Text color="gray">Look up a player to get started</Text>
<ArrowDownIcon />
</Flex>
<TextField.Root
size="3"
style={{
position: 'relative',
width: '75vw',
maxWidth: 480,
}}
ref={input}
placeholder="Search players..."
onChange={() => {
if (!input.current) return;
const sanitized = input.current.value.trim();
if (sanitized.length > 0) {
setShowSearch(true);
setSearching(true);
search();
} else {
setShowSearch(false);
}
}}
>
<TextField.Slot>
<MagnifyingGlassIcon />
</TextField.Slot>
</TextField.Root>
{showSearch && (
<Flex mt="2">
{searching && (
<Flex justify="center">
<Text color="gray">Searching...</Text>
</Flex>
)}
{!searching && searchResults.length === 0 && (
<Flex justify="center">
<Text color="gray">No players found</Text>
</Flex>
)}
{!searching && searchResults.length > 0 && (
<Flex direction="column" gap="2">
{searchResults.map((player) => (
<Button
key={player.account_id}
variant="ghost"
onClick={() => track(player.account_id)}
>
{player.nickname} (
{UNLOCALIZED_REGION_NAMES_SHORT[player.region]})
</Button>
))}
</Flex>
)}
</Flex>
)}
</Flex>
)}
{session.tracking && (
<Flex
mt="2"
mb="2"
direction={{ initial: 'column', sm: 'row' }}
align="center"
justify="between"
gap="2"
>
<Flex mt="2" mb="2" direction="column" gap="2">
<Heading size="5">Tracking {accountInfo?.nickname}</Heading>
<Text color="gray">
Since {new Date(session.player.since).toLocaleString()}
</Text>
</Flex>
<Flex gap="2" align="center" justify="center">
<Button
color="red"
onClick={async () => {
const stats = await getTankStats(
session.player.region,
session.player.id,
);
if (stats === null) {
setShowCCInaccessibilityPrompt(true);
return;
}
mutateSession((draft) => {
(draft as Session.SessionTracking).player.stats = stats;
});
}}
>
Reset
</Button>
<Button
onClick={() => {
mutateSession((draft) => {
draft.tracking = false;
});
}}
>
Change
</Button>
</Flex>
</Flex>
)}
{session.tracking && delta && delta.length > 0 && (
<Table.Root variant="surface">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>Tank</Table.ColumnHeaderCell>
{session.columns.map((column, index) => (
<Table.ColumnHeaderCell
width="0"
key={column}
style={{
position: 'relative',
}}
>
{index === 0 && session.columns.length < 4 && (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton
size="1"
variant="surface"
style={{
position: 'absolute',
left: 0,
top: '50%',
transform: 'translate(-100%, -50%)',
zIndex: 1,
}}
>
<PlusIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{STAT_KEYS.filter(
(item) => !session.columns.includes(item),
).map((key) => (
<DropdownMenu.Item
key={key}
onClick={() => {
mutateSession((draft) => {
(
draft as Session.SessionTracking
).columns.unshift(key as Stat);
});
}}
>
{STAT_NAMES[key]}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
)}
<Select.Root
value={column}
onValueChange={(value) => {
mutateSession((draft) => {
if (value === 'remove') {
(draft as Session.SessionTracking).columns.splice(
index,
1,
);
} else {
(draft as Session.SessionTracking).columns[index] =
value as Stat;
}
});
}}
>
<Select.Trigger variant="ghost" />
<Select.Content>
{STAT_KEYS.filter(
(item) =>
!session.columns.includes(item) || item === column,
).map((key) => (
<Select.Item key={key} value={key}>
{STAT_NAMES[key]}
</Select.Item>
))}
<Select.Separator />
<Select.Item value="remove">Remove</Select.Item>
</Select.Content>
</Select.Root>
</Table.ColumnHeaderCell>
))}
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row
style={{
overflow: 'hidden',
}}
>
<Table.RowHeaderCell
style={{
paddingLeft: 32,
position: 'relative',
overflowY: 'hidden',
}}
>
Total
</Table.RowHeaderCell>
{session.columns.map((column) => (
<Table.Cell key={column}>{total![column]}</Table.Cell>
))}
</Table.Row>
{delta.map(({ statsPretty, tank }) => {
return (
<Table.Row
key={tank.id}
style={{
overflow: 'hidden',
}}
>
<TankRowHeaderCell tank={tank} />
{session.columns.map((column) => (
<Table.Cell key={column}>{statsPretty[column]}</Table.Cell>
))}
</Table.Row>
);
})}
</Table.Body>
</Table.Root>
)}
{session.tracking && delta && delta.length === 0 && (
<Flex
align="center"
justify="center"
direction="column"
style={{
flex: 1,
}}
>
<Heading color="gray">No battles</Heading>
<Text color="gray">Go ahead and play a game</Text>
</Flex>
)}
</PageWrapper>
);
}
@@ -0,0 +1,37 @@
import { MinusCircledIcon } from '@radix-ui/react-icons';
import { Callout, Flex } from '@radix-ui/themes';
import { ExperimentIcon } from '../../../../../components/ExperimentIcon';
import * as Duel from '../../../../../stores/duel';
export function CalloutsSection() {
const tank = Duel.use((state) => state.protagonist.tank);
if (!tank.testing && !tank.deprecated) return null;
return (
<Flex direction="column" align="center" gap="2" mb="4">
{tank.deprecated && (
<Callout.Root color="red">
<Callout.Icon>
<MinusCircledIcon />
</Callout.Icon>
<Callout.Text>
This tank is deprecated and may display undefined behavior.
</Callout.Text>
</Callout.Root>
)}
{tank.testing && (
<Callout.Root color="red">
<Callout.Icon>
<ExperimentIcon style={{ width: '1em', height: '1em' }} />
</Callout.Icon>
<Callout.Text>
Tanks in testing are subject to change and may not represent the
final product.
</Callout.Text>
</Callout.Root>
)}
</Flex>
);
}
@@ -0,0 +1,22 @@
import { Flex, FlexProps } from '@radix-ui/themes';
import { ReactNode } from 'react';
type ConfigurationChildWrapperProps = FlexProps & {
children: ReactNode;
};
export function ConfigurationChildWrapper({
children,
...props
}: ConfigurationChildWrapperProps) {
return (
<Flex
gap="2"
direction="column"
align={{ initial: 'center', sm: 'start' }}
{...props}
>
{children}
</Flex>
);
}
@@ -0,0 +1,89 @@
import { InfoCircledIcon } from '@radix-ui/react-icons';
import {
Button,
Flex,
Heading,
IconButton,
Popover,
Text,
} from '@radix-ui/themes';
import { use } from 'react';
import { ConsumablesManager } from '../../../../../../../components/ConsumablesManager';
import { checkConsumableProvisionInclusivity } from '../../../../../../../core/blitzkit/checkConsumableProvisionInclusivity';
import { consumableDefinitions } from '../../../../../../../core/blitzkit/consumableDefinitions';
import { useEquipment } from '../../../../../../../hooks/useEquipment';
import * as Duel from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Consumables() {
const mutateDuel = Duel.useMutation();
const protagonist = Duel.use((state) => state.protagonist);
const awaitedConsumableDefinitions = use(consumableDefinitions);
const consumables = Duel.use((state) => state.protagonist.consumables);
const consumablesList = Object.values(awaitedConsumableDefinitions).filter(
(consumable) =>
checkConsumableProvisionInclusivity(
consumable,
protagonist.tank,
protagonist.gun,
),
);
const cooldownBooster = Duel.use(
(state) => state.protagonist.cooldownBooster,
);
const hasConsumableDeliverySystem = useEquipment(118);
const hasHighEndConsumables = useEquipment(101);
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Flex gap="2" align="center">
<Heading size="4">Consumables</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Text>
<Text color="amber">Yellow consumables</Text> do not affect the
statistics.
</Text>
</Popover.Content>
</Popover.Root>
</Flex>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist.consumables = [];
});
}}
>
Clear
</Button>
</Flex>
<ConsumablesManager
consumables={consumablesList}
selected={consumables}
cooldownBooster={cooldownBooster}
disabled={protagonist.tank.consumables === consumables.length}
hasConsumableDeliverySystem={hasConsumableDeliverySystem}
hasHighEndConsumables={hasHighEndConsumables}
timers
onConsumablesChange={(consumables) => {
mutateDuel((draft) => {
draft.protagonist.consumables = consumables;
});
}}
justify={{ initial: 'center', sm: 'start' }}
/>
</ConfigurationChildWrapper>
);
}
@@ -0,0 +1,50 @@
import { Button, Flex, Heading } from '@radix-ui/themes';
import { use } from 'react';
import { EquipmentManager } from '../../../../../../../components/EquipmentManager';
import { equipmentDefinitions } from '../../../../../../../core/blitzkit/equipmentDefinitions';
import * as Duel from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Equipment() {
const mutateDuel = Duel.useMutation();
const protagonist = Duel.use((state) => state.protagonist);
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
const equipmentPreset =
awaitedEquipmentDefinitions.presets[protagonist.tank.equipment];
const equipmentMatrix = Duel.use(
(state) => state.protagonist.equipmentMatrix,
);
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Heading size="4">Equipment</Heading>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist.equipmentMatrix.forEach((row) => {
row.forEach((_, index) => {
row[index] = 0;
});
});
});
}}
>
Clear
</Button>
</Flex>
<EquipmentManager
matrix={equipmentMatrix}
preset={equipmentPreset}
onChange={(matrix) => {
mutateDuel((draft) => {
draft.protagonist.equipmentMatrix = matrix;
});
}}
/>
</ConfigurationChildWrapper>
);
}
@@ -0,0 +1,95 @@
import { CaretDownIcon, CaretUpIcon } from '@radix-ui/react-icons';
import { Flex, Text } from '@radix-ui/themes';
import { ReactNode } from 'react';
import { theme } from '../../../../../../../stitches.config';
export interface InfoProps {
name: ReactNode;
children?: ReactNode;
unit?: string;
indent?: boolean;
highlight?: boolean;
delta?: number;
decimals?: number;
prefix?: string;
deltaType?: 'higherIsBetter' | 'lowerIsBetter';
}
export function Info({
name,
children,
unit,
indent = false,
highlight,
delta,
decimals,
prefix,
deltaType = 'higherIsBetter',
}: InfoProps) {
return (
<Flex
align="center"
style={{ width: '100%', paddingLeft: indent ? 24 : 0 }}
gap="4"
>
<Text color={highlight ? 'amber' : 'gray'}>
{name}
{unit != undefined && (
<>
{' '}
<Text color="gray" size="1">
{unit}
</Text>
</>
)}
</Text>
{children !== undefined && (
<div
style={{
flex: 1,
height: 1,
backgroundColor: highlight
? theme.colors.componentCallToActionInteractive_amberAlpha
: theme.colors.componentCallToActionInteractive_alpha,
}}
/>
)}
{children !== undefined && (
<Flex align="center" gap="1">
{delta !== undefined && delta !== 0 && !isNaN(delta) && (
<>
<Text
color={
delta * (deltaType === 'lowerIsBetter' ? -1 : 1) > 0
? 'green'
: 'tomato'
}
>
{decimals !== undefined
? Math.abs(delta).toFixed(decimals)
: Math.abs(delta)}
</Text>
<Text
color={
delta * (deltaType === 'lowerIsBetter' ? -1 : 1) > 0
? 'green'
: 'tomato'
}
>
{delta > 0 ? <CaretUpIcon /> : <CaretDownIcon />}
</Text>
</>
)}
<Text color={highlight ? 'amber' : undefined}>
{prefix}
{decimals !== undefined && typeof children === 'number'
? children.toFixed(decimals)
: children}
</Text>
</Flex>
)}
</Flex>
);
}
@@ -0,0 +1,16 @@
import { useDelta } from '../../../../../../../hooks/useDelta';
import { Info, InfoProps } from './Info';
interface InfoWithDeltaProps extends InfoProps {
children: number;
}
export function InfoWithDelta({ children, ...props }: InfoWithDeltaProps) {
const delta = useDelta(children);
return (
<Info {...props} delta={delta}>
{children}
</Info>
);
}
@@ -0,0 +1,130 @@
import { Flex, Heading, Slider, TextField } from '@radix-ui/themes';
import { clamp, debounce } from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react';
import { GenericTankComponentButton } from '../../../../../../../components/ModuleButtons/GenericTankComponentButton';
import { asset } from '../../../../../../../core/blitzkit/asset';
import * as Duel from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Miscellaneous() {
const mutateDuel = Duel.useMutation();
const camouflage = Duel.use((state) => state.protagonist.camouflage);
const cooldownBooster = Duel.use(
(state) => state.protagonist.cooldownBooster,
);
const crewMastery = Duel.use((state) => state.protagonist.crewMastery);
const crewInput = useRef<HTMLInputElement>(null);
const [crewMasteryDraft, setCrewMasteryDraft] = useState(crewMastery);
const { tank } = Duel.use((state) => state.protagonist);
const debouncedApplyDraft = useMemo(
() =>
debounce((value) => {
mutateDuel((draft) => {
draft.protagonist.crewMastery = value;
});
}, 500),
[],
);
useEffect(() => {
setCrewMasteryDraft(crewMastery);
if (!crewInput.current) return;
crewInput.current.value = `${Math.round(crewMastery * 100)}`;
}, [crewMastery]);
return (
<ConfigurationChildWrapper>
<Heading size="4">Miscellaneous</Heading>
<Flex gap="2" align="center">
{!tank.fixedCamouflage && (
<GenericTankComponentButton
icon={asset('icons/camo.webp')}
selected={camouflage}
iconStyles={{
top: '50%',
left: '50%',
transform: 'translate(calc(-50% + 4px), calc(-50% + 4px))',
}}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist.camouflage = !camouflage;
});
}}
/>
)}
<GenericTankComponentButton
icon={asset('icons/boosters/equipment.webp')}
selected={cooldownBooster > 0}
banner={
cooldownBooster === 0
? undefined
: cooldownBooster === 1
? '#afb0abc0'
: cooldownBooster === 2
? '#5f72cbc0'
: '#9b3cc0c0'
}
iconStyles={{
transform: 'translate(-50%, -50%) scale(0.8)',
}}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist.cooldownBooster++;
if (draft.protagonist.cooldownBooster === 4)
draft.protagonist.cooldownBooster = 0;
});
}}
/>
{tank.treeType === 'researchable' && (
<Flex
align="center"
direction="column"
gap="1"
justify="end"
style={{ width: 120 }}
>
<TextField.Root
ref={crewInput}
defaultValue={Math.round(crewMastery * 100)}
style={{ textAlign: 'right' }}
onBlur={() => {
const newValue = Number(crewInput.current!.value) / 100;
if (isNaN(newValue)) {
crewInput.current!.value = `${Math.round(crewMastery * 100)}`;
return;
}
mutateDuel((draft) => {
draft.protagonist.crewMastery = clamp(newValue / 100, 0.5, 1);
});
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
crewInput.current?.blur();
}
}}
>
<TextField.Slot>Crew</TextField.Slot>
<TextField.Slot>%</TextField.Slot>
</TextField.Root>
<Slider
min={50}
max={100}
value={[crewMasteryDraft * 100]}
style={{ width: '100%' }}
onValueChange={([value]) => {
setCrewMasteryDraft(value / 100);
debouncedApplyDraft(value / 100);
}}
/>
</Flex>
)}
</Flex>
</ConfigurationChildWrapper>
);
}
@@ -0,0 +1,382 @@
import {
Button,
ChevronDownIcon,
Flex,
Heading,
IconButton,
Link,
Text,
} from '@radix-ui/themes';
import { use } from 'react';
import { asset } from '../../../../../../../core/blitzkit/asset';
import {
ModuleDefinition,
ModuleType,
TankDefinition,
tankDefinitions,
Tier,
Unlock,
} from '../../../../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkit/tankDefinitions/constants';
import { tankIcon } from '../../../../../../../core/blitzkit/tankIcon';
import { formatCompact } from '../../../../../../../core/math/formatCompact';
import * as Duel from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
function ModuleButton({
tier,
selected,
unlock,
top,
onClick,
}: {
unlock: Unlock;
tier: Tier;
selected: boolean;
top: boolean;
onClick: () => void;
}) {
const isTank = unlock.type === 'vehicle';
const button = (
<IconButton
size="4"
radius="small"
variant={selected ? 'surface' : 'soft'}
color={top ? 'amber' : selected ? undefined : 'gray'}
onClick={onClick}
style={{
position: 'relative',
width: isTank ? 72 : undefined,
height: isTank ? 72 : undefined,
}}
>
<Text
size="1"
color="gray"
style={{
position: 'absolute',
left: isTank ? 8 : 4,
top: isTank ? 8 : 4,
}}
>
{TIER_ROMAN_NUMERALS[tier]}
</Text>
{unlock.cost.value > 0 && (
<Flex
style={{
position: 'absolute',
left: '50%',
bottom: 4,
transform: 'translateX(-50%)',
}}
align="center"
gap={isTank ? '1' : undefined}
>
<Text size="1" color="gray">
{formatCompact(unlock.cost.value)}
</Text>
<img
alt={unlock.cost.type}
src={asset(`icons/currencies/${unlock.cost.type}.webp`)}
style={{
width: 12,
height: 12,
objectFit: 'contain',
}}
/>
</Flex>
)}
<img
alt={unlock.type}
src={
isTank
? tankIcon(unlock.id)
: asset(`icons/modules/${unlock.type}.webp`)
}
style={{
width: isTank ? 64 : 32,
height: isTank ? 64 : 32,
objectFit: 'contain',
}}
/>
</IconButton>
);
return isTank ? (
<Link href={`/tools/tankopedia/${unlock.id}`}>{button}</Link>
) : (
button
);
}
export function Modules() {
const mutateDuel = Duel.useMutation();
const awaitedTankDefinitions = use(tankDefinitions);
const tank = Duel.use((state) => state.protagonist.tank);
const hasUpgrades =
tank.turrets.length > 1 ||
tank.turrets[0].guns.length > 1 ||
tank.engines.length > 1 ||
tank.tracks.length > 1;
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const engine = Duel.use((state) => state.protagonist.engine);
const track = Duel.use((state) => state.protagonist.track);
const turret0 = tank.turrets[0];
const gun0 = turret0.guns[0];
const engine0 = tank.engines[0];
const track0 = tank.tracks[0];
const topTurret = tank.turrets.at(-1)!;
const topGun = topTurret.guns.at(-1)!;
const topEngine = tank.engines.at(-1)!;
const topTrack = tank.tracks.at(-1)!;
function setByUnlock(unlock: Unlock) {
mutateDuel((draft) => {
if (unlock.type === 'turret') {
draft.protagonist.turret = draft.protagonist.tank.turrets.find(
(turret) => turret.id === unlock.id,
)!;
if (
!draft.protagonist.turret.guns.some(
(gun) => gun.id === draft.protagonist.gun.id,
)
) {
draft.protagonist.gun = draft.protagonist.turret.guns.at(-1)!;
draft.protagonist.shell = draft.protagonist.gun.shells[0];
}
} else if (unlock.type === 'gun') {
const gunInTurret = draft.protagonist.turret.guns.find(
(gun) => gun.id === unlock.id,
);
if (gunInTurret) {
draft.protagonist.gun = gunInTurret;
draft.protagonist.shell = gunInTurret.shells[0];
} else {
// TODO: warn somehow?
const suitableTurret = draft.protagonist.tank.turrets.find((turret) =>
turret.guns.some((gun) => gun.id === unlock.id),
)!;
const gunInSuitableTurret = suitableTurret.guns.find(
(gun) => gun.id === unlock.id,
)!;
draft.protagonist.turret = suitableTurret;
draft.protagonist.gun = gunInSuitableTurret;
draft.protagonist.shell = gunInSuitableTurret.shells[0];
}
} else if (unlock.type === 'engine') {
draft.protagonist.engine = draft.protagonist.tank.engines.find(
(engine) => engine.id === unlock.id,
)!;
} else if (unlock.type === 'chassis') {
draft.protagonist.track = draft.protagonist.tank.tracks.find(
(track) => track.id === unlock.id,
)!;
}
});
}
function tree(type: ModuleType, unlocks: Unlock[]) {
return (
<Flex gap="2">
{unlocks.map((unlock, index) => {
const first = index === 0;
const last = index === unlocks.length - 1;
const central = !first && !last;
let module: ModuleDefinition | TankDefinition | undefined = undefined;
if (unlock.type === 'chassis') {
module = tank.tracks.find((track) => track.id === unlock.id)!;
} else if (unlock.type === 'engine') {
module = tank.engines.find((engine) => engine.id === unlock.id)!;
} else if (unlock.type === 'turret') {
module = tank.turrets.find((turret) => turret.id === unlock.id)!;
} else if (unlock.type === 'gun') {
module = tank.turrets
.find((turret) =>
turret.guns.some((gun) => gun.id === unlock.id),
)!
.guns.find((gun) => gun.id === unlock.id)!;
} else if (unlock.type === 'vehicle') {
module = awaitedTankDefinitions[unlock.id];
}
if (module === undefined) return null;
return (
<Flex
key={unlock.id}
align="center"
direction="column"
gap="2"
position="relative"
>
{unlock.cost.value > 0 && (
<>
{unlocks.length > 1 && (
<div
style={{
backgroundColor: 'currentcolor',
width: `calc(${central ? 100 : 50}% + ${central ? 8 : 4}px)`,
height: 1,
position: 'absolute',
right: first ? 0 : undefined,
left: last ? 0 : undefined,
transform: `translateX(${first ? 4 : last ? -4 : 0}px)`,
}}
/>
)}
<Flex
direction="column"
align="center"
style={{
marginBottom: -7,
}}
>
<div
style={{
backgroundColor: 'currentcolor',
width: 1,
height: 8,
}}
/>
<ChevronDownIcon
style={{
transform: 'translateY(-7px)',
}}
/>
</Flex>
</>
)}
<ModuleButton
unlock={unlock}
tier={module.tier}
selected={
(unlock.type === 'turret'
? turret.id
: unlock.type === 'gun'
? gun.id
: unlock.type === 'engine'
? engine.id
: unlock.type === 'chassis'
? track.id
: -1) === unlock.id
}
top={
unlock.type === 'turret'
? module.id == topTurret.id
: unlock.type === 'gun'
? module.id === topGun.id
: unlock.type === 'engine'
? module.id === topEngine.id
: unlock.type === 'chassis'
? module.id === topTrack.id
: false
}
onClick={() => setByUnlock(unlock)}
/>
{(module as ModuleDefinition).unlocks &&
(module as ModuleDefinition).unlocks!.length > 1 && (
<div
style={{
backgroundColor: 'currentcolor',
width: 1,
height: 8,
marginBottom: -8,
}}
/>
)}
{(module as ModuleDefinition).unlocks &&
tree(type, (module as ModuleDefinition).unlocks!)}
</Flex>
);
})}
</Flex>
);
}
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Heading size="4">Modules</Heading>
{hasUpgrades && (
<>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist.turret = draft.protagonist.tank.turrets[0];
draft.protagonist.gun = draft.protagonist.turret.guns[0];
draft.protagonist.shell = draft.protagonist.gun.shells[0];
draft.protagonist.engine = draft.protagonist.tank.engines[0];
draft.protagonist.track = draft.protagonist.tank.tracks[0];
});
}}
>
Stock
</Button>
<Button
variant="ghost"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist.turret =
draft.protagonist.tank.turrets.at(-1)!;
draft.protagonist.gun = draft.protagonist.turret.guns.at(-1)!;
draft.protagonist.shell = draft.protagonist.gun.shells[0];
draft.protagonist.engine =
draft.protagonist.tank.engines.at(-1)!;
draft.protagonist.track =
draft.protagonist.tank.tracks.at(-1)!;
});
}}
>
Upgrade
</Button>
</>
)}
</Flex>
<Flex gap="2" wrap="wrap" gapY="6">
{tree('engine', [
{
cost: { type: 'xp', value: 0 },
id: engine0.id,
type: 'engine',
},
])}
{tree('chassis', [
{
cost: { type: 'xp', value: 0 },
id: track0.id,
type: 'chassis',
},
])}
{tree('turret', [
{
cost: { type: 'xp', value: 0 },
id: turret0.id,
type: 'turret',
},
])}
{tree('gun', [
{
cost: { type: 'xp', value: 0 },
id: gun0.id,
type: 'gun',
},
])}
</Flex>
</ConfigurationChildWrapper>
);
}
@@ -0,0 +1,49 @@
import { Button, Flex, Heading } from '@radix-ui/themes';
import { use } from 'react';
import { ProvisionsManager } from '../../../../../../../components/ProvisionsManager';
import { availableProvisions } from '../../../../../../../core/blitzkit/availableProvisions';
import { provisionDefinitions } from '../../../../../../../core/blitzkit/provisionDefinitions';
import * as Duel from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Provisions() {
const mutateDuel = Duel.useMutation();
const { tank, gun } = Duel.use((state) => state.protagonist);
const provisions = Duel.use((state) => state.protagonist.provisions);
const awaitedProvisionDefinitions = use(provisionDefinitions);
const provisionsList = availableProvisions(
tank,
gun,
awaitedProvisionDefinitions,
);
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Heading size="4">Provisions</Heading>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist.provisions = [];
});
}}
>
Clear
</Button>
</Flex>
<ProvisionsManager
provisions={provisionsList.map((provision) => provision.id)}
selected={provisions}
disabled={tank.provisions === provisions.length}
onChange={(provisions) => {
mutateDuel((draft) => {
draft.protagonist.provisions = provisions;
});
}}
/>
</ConfigurationChildWrapper>
);
}
@@ -0,0 +1,8 @@
export const permanentSkills = [
'smooth_turn',
'soft_recoil',
'smooth_driving',
'smooth_turret',
'virtuoso',
'camouflage',
];
@@ -0,0 +1,96 @@
import { InfoCircledIcon } from '@radix-ui/react-icons';
import {
Button,
Flex,
Heading,
IconButton,
Kbd,
Popover,
Text,
} from '@radix-ui/themes';
import { use } from 'react';
import { CrewSkillManager } from '../../../../../../../../components/CrewSkillManager';
import { createDefaultSkills } from '../../../../../../../../core/blitzkit/createDefaultSkills';
import { skillDefinitions } from '../../../../../../../../core/blitzkit/skillDefinitions';
import * as TankopediaEphemeral from '../../../../../../../../stores/tankopediaEphemeral';
import { ConfigurationChildWrapper } from '../ConfigurationChildWrapper';
export function Skills() {
const awaitedSkillDefinitions = use(skillDefinitions);
const skillLevels = TankopediaEphemeral.use((state) => state.skills);
const mutateTankopediaTemporary = TankopediaEphemeral.useMutation();
if (Object.keys(skillLevels).length === 0) {
mutateTankopediaTemporary((draft) => {
draft.skills = createDefaultSkills(awaitedSkillDefinitions);
});
return null;
}
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Flex gap="2" align="center">
<Heading size="4">Crew skills</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex direction="column" gap="2">
<Text>
<Text color="amber">Yellow skills</Text> only apply under
special circumstances.
</Text>
<Text>
Hold <Kbd>Shift</Kbd> to quickly toggle between level 0 and 7.
</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateTankopediaTemporary((draft) => {
Object.keys(draft.skills).forEach((skill) => {
draft.skills[skill] = 0;
});
});
}}
>
Clear
</Button>
<Button
variant="ghost"
onClick={() => {
mutateTankopediaTemporary((draft) => {
Object.keys(draft.skills).forEach((skill) => {
draft.skills[skill] = 7;
});
});
}}
>
Maximize
</Button>
</Flex>
<CrewSkillManager
skillLevels={skillLevels}
onChange={(skills) => {
mutateTankopediaTemporary((draft) => {
draft.skills = skills;
});
}}
/>
</ConfigurationChildWrapper>
);
}
@@ -0,0 +1,628 @@
import { AccessibilityIcon, InfoCircledIcon } from '@radix-ui/react-icons';
import {
Flex,
Heading,
IconButton,
Popover,
Slider,
Text,
TextField,
Tooltip,
} from '@radix-ui/themes';
import { debounce } from 'lodash';
import { Fragment, use, useEffect, useRef, useState } from 'react';
import { lerp } from 'three/src/math/MathUtils';
import { Ad, AdType } from '../../../../../../components/Ad';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
import { asset } from '../../../../../../core/blitzkit/asset';
import { coefficient } from '../../../../../../core/blitzkit/coefficient';
import { equipmentDefinitions } from '../../../../../../core/blitzkit/equipmentDefinitions';
import { provisionDefinitions } from '../../../../../../core/blitzkit/provisionDefinitions';
import { tankCharacteristics } from '../../../../../../core/blitzkit/tankCharacteristics';
import {
CREW_MEMBER_NAMES,
GUN_TYPE_NAMES,
} from '../../../../../../core/blitzkit/tankDefinitions/constants';
import { useAdExempt } from '../../../../../../hooks/useAdExempt';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
import * as Duel from '../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../stores/tankopediaEphemeral';
import { Info } from './components/Info';
import { InfoWithDelta } from './components/InfoWithDelta';
export function Characteristics() {
const mutateDuel = Duel.useMutation();
const exempt = useAdExempt();
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
const awaitedProvisionDefinitions = use(provisionDefinitions);
const crewSkills = TankopediaEphemeral.use((state) => state.skills);
const penetrationDistanceInput = useRef<HTMLInputElement>(null);
const hasImprovedVentilation = useEquipment(102);
const crewMastery = Duel.use((state) => state.protagonist.crewMastery);
const [penetrationDistance, setPenetrationDistance] = useState(250);
const setPenetrationDistanceDebounced = debounce((value: number) => {
setPenetrationDistance(value);
}, 500);
const provisions = Duel.use((state) => state.protagonist.provisions);
const provisionCrewBonus =
provisions.reduce(
(total, provision) =>
awaitedProvisionDefinitions[provision].crew
? total + awaitedProvisionDefinitions[provision].crew! / 100
: total,
0,
) + (hasImprovedVentilation ? 0.08 : 0);
const commanderMastery = crewMastery + provisionCrewBonus;
const consumables = Duel.use((state) => state.protagonist.consumables);
const camouflage = Duel.use((state) => state.protagonist.camouflage);
const equipmentMatrix = Duel.use(
(state) => state.protagonist.equipmentMatrix,
);
const { tank, turret, gun, engine, track, shell } = Duel.use(
(state) => state.protagonist,
);
const stockEngine = tank.engines[0];
const stockTrack = tank.tracks[0];
const stockTurret = tank.turrets[0];
const stockGun = stockTurret.guns[0];
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
const stats = tankCharacteristics(
{
tank,
camouflage,
consumables,
crewMastery,
crewSkills,
engine,
equipmentMatrix,
gun,
provisions,
shell,
turret,
track,
stockEngine,
stockGun,
stockTrack,
stockTurret,
applyReactiveArmor: false,
applyDynamicArmor: false,
applySpallLiner: false,
},
{
tankModelDefinition,
equipmentDefinitions: awaitedEquipmentDefinitions,
provisionDefinitions: awaitedProvisionDefinitions,
},
);
const hasSupercharger = useEquipment(107);
const hasCalibratedShells = useEquipment(103);
const hasImprovedVerticalStabilizer = useEquipment(122);
const penetrationCoefficient = coefficient([
hasCalibratedShells,
resolvePenetrationCoefficient(true, shell.type) - 1,
]);
const penetrationLossOverDistanceCoefficient = coefficient([
hasSupercharger,
-0.6,
]);
useEffect(() => {
if (penetrationDistanceInput.current) {
penetrationDistanceInput.current.value = `${penetrationDistance}`;
}
}, [penetrationDistance]);
useEffect(() => {
mutateDuel((draft) => {
[draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits(
draft.protagonist.pitch,
draft.protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
});
}, [hasImprovedVerticalStabilizer]);
return (
<Flex
gap="8"
direction={{
initial: 'column',
md: 'row',
}}
>
<Flex direction="column" gap="8" style={{ flex: 1 }}>
<Flex direction="column" gap="2">
<Flex align="center" gap="4">
<Heading size="5">Firepower</Heading>
<Flex>
{gun.shells.map((thisShell, shellIndex) => (
<Tooltip content={thisShell.name} key={thisShell.id}>
<IconButton
color={thisShell.id === shell.id ? undefined : 'gray'}
variant="soft"
style={{
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
borderBottomLeftRadius: shellIndex === 0 ? undefined : 0,
borderTopRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0,
borderBottomRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0,
marginLeft: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist.shell = thisShell;
});
}}
>
<img
alt={thisShell.name}
width={16}
height={16}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
/>
</IconButton>
</Tooltip>
))}
</Flex>
</Flex>
<Info name="Gun type">{GUN_TYPE_NAMES[gun.type]}</Info>
<InfoWithDelta name="DPM" decimals={0} unit="hp / min">
{stats.dpm}
</InfoWithDelta>
{gun.type === 'autoReloader' && (
<InfoWithDelta
decimals={0}
indent
name="Effective at 60s"
unit="hp / min"
>
{stats.dpmEffective!}
</InfoWithDelta>
)}
{gun.type !== 'regular' && (
<InfoWithDelta name="Shells">{stats.shells}</InfoWithDelta>
)}
{gun.type === 'autoReloader' ? (
<>
<Info indent name="Most optimal shell index">
{stats.mostOptimalShellIndex! + 1}
</Info>
<Info name="Shell reloads" unit="s" />
{stats.shellReloads!.map((reload, index) => (
<InfoWithDelta
key={index}
indent
name={`Shell ${index + 1}`}
decimals={2}
deltaType="lowerIsBetter"
>
{reload}
</InfoWithDelta>
))}
</>
) : (
<InfoWithDelta
decimals={2}
name="Reload"
unit="s"
deltaType="lowerIsBetter"
>
{stats.shellReload!}
</InfoWithDelta>
)}
{(gun.type === 'autoLoader' || gun.type === 'autoReloader') && (
<InfoWithDelta
indent
decimals={2}
name="Intra-clip"
unit="s"
deltaType="lowerIsBetter"
>
{stats.intraClip!}
</InfoWithDelta>
)}
<InfoWithDelta decimals={0} name="Penetration" unit="mm">
{stats.penetration}
</InfoWithDelta>
{typeof shell.penetration !== 'number' && (
<>
<InfoWithDelta
indent
decimals={0}
name={`At ${penetrationDistance}m`}
>
{lerp(
shell.penetration[0],
shell.penetration[1],
(penetrationDistance *
penetrationLossOverDistanceCoefficient) /
500,
) * penetrationCoefficient}
</InfoWithDelta>
<Flex align="center" gap="2" style={{ paddingLeft: 24 }}>
<Text color="gray">Distance</Text>
<Slider
key={penetrationDistance}
min={0}
max={500}
style={{ flex: 1 }}
defaultValue={[penetrationDistance]}
onValueChange={([value]) => {
setPenetrationDistanceDebounced(value);
if (!penetrationDistanceInput.current) return;
penetrationDistanceInput.current.value = value.toString();
}}
/>
<TextField.Root
style={{ width: 64 }}
ref={penetrationDistanceInput}
defaultValue={penetrationDistance}
onBlur={() => {
setPenetrationDistance(
Math.min(
parseInt(penetrationDistanceInput.current!.value),
500,
),
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
>
<TextField.Slot side="right">m</TextField.Slot>
</TextField.Root>
</Flex>
</>
)}
<InfoWithDelta name="Damage" unit="hp" decimals={0}>
{stats.damage}
</InfoWithDelta>
{gun.type !== 'regular' && (
<InfoWithDelta name="Clipping potential" indent>
{stats.clipDamage!}
</InfoWithDelta>
)}
<InfoWithDelta name="Module damage" unit="hp" decimals={0}>
{stats.moduleDamage}
</InfoWithDelta>
{isExplosive(shell.type) && (
<InfoWithDelta name="Splash radius" unit="m" decimals={0}>
{stats.explosionRadius!}
</InfoWithDelta>
)}
<InfoWithDelta name="Caliber" decimals={0} unit="mm">
{stats.caliber}
</InfoWithDelta>
<InfoWithDelta name="Normalization" decimals={0} unit="°">
{stats.shellNormalization}
</InfoWithDelta>
{!isExplosive(shell.type) && (
<InfoWithDelta
name="Ricochet"
decimals={0}
deltaType="lowerIsBetter"
unit="°"
>
{stats.shellRicochet!}
</InfoWithDelta>
)}
<InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}>
{stats.shellVelocity}
</InfoWithDelta>
<InfoWithDelta
decimals={2}
name="Aim time"
unit="s"
deltaType="lowerIsBetter"
>
{stats.aimTime}
</InfoWithDelta>
<Info name="Dispersion at 100m" />
<InfoWithDelta
decimals={3}
indent
name="Still"
unit="m"
deltaType="lowerIsBetter"
>
{stats.dispersion}
</InfoWithDelta>
<InfoWithDelta
prefix="+"
decimals={3}
indent
name="Moving"
deltaType="lowerIsBetter"
>
{stats.dispersionMoving}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+"
indent
name="Hull traversing"
deltaType="lowerIsBetter"
>
{stats.dispersionHullTraversing}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+"
indent
name="Turret traversing"
deltaType="lowerIsBetter"
>
{stats.dispersionTurretTraversing}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+"
indent
name="After shooting"
deltaType="lowerIsBetter"
>
{stats.dispersionShooting}
</InfoWithDelta>
<InfoWithDelta
decimals={1}
prefix="x "
indent
name="Gun damaged"
unit="scalar"
deltaType="lowerIsBetter"
>
{stats.dispersionGunDamaged}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
indent
name="Maximum"
unit="m"
deltaType="lowerIsBetter"
>
{stats.maxDispersion}
</InfoWithDelta>
<Info name="Gun flexibility" unit="°" />
<InfoWithDelta decimals={1} indent name="Depression">
{stats.gunDepression}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Elevation">
{stats.gunElevation}
</InfoWithDelta>
{gunModelDefinition.pitch.front && (
<>
<InfoWithDelta decimals={1} indent name="Frontal depression">
{stats.gunFrontalDepression!}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Frontal elevation">
{stats.gunFrontalElevation!}
</InfoWithDelta>
</>
)}
{gunModelDefinition.pitch.back && (
<>
<InfoWithDelta decimals={1} indent name="Rear depression">
{stats.gunRearDepression!}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Rear elevation">
{stats.gunRearElevation!}
</InfoWithDelta>
</>
)}
{turretModelDefinition.yaw && (
<>
<InfoWithDelta decimals={1} indent name="Azimuth left">
{stats.azimuthLeft!}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Azimuth right">
{stats.azimuthRight!}
</InfoWithDelta>
</>
)}
</Flex>
<Flex direction="column" gap="2">
<Flex gap="2" align="center">
<Heading size="5">Crew training</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex gap="1" align="center">
<AccessibilityIcon />
<Text>
represents the other roles a crew member can take.
</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
{tank.crew.map((member) => {
const count = member.count ?? 1;
return (
<Fragment key={member.type}>
<InfoWithDelta
key={`${member.type}-root`}
name={`${CREW_MEMBER_NAMES[member.type]}${count > 1 ? ` x ${count}` : ''}`}
unit="%"
decimals={0}
>
{(member.type === 'commander'
? commanderMastery
: commanderMastery * 1.1) * 100}
</InfoWithDelta>
{member.substitute && (
<InfoWithDelta
key={`${member.type}-substitute`}
decimals={0}
unit="%"
indent
name={
<>
<Flex align="center" gap="1" display="inline-flex">
<AccessibilityIcon />
{member.substitute
.map((sub, index) =>
index === 0 ? CREW_MEMBER_NAMES[sub] : sub,
)
.join(', ')}
</Flex>
</>
}
>
{commanderMastery * 1.05 * 100}
</InfoWithDelta>
)}
</Fragment>
);
})}
</Flex>
</Flex>
{!exempt && (
<Flex
justify="center"
display={{
initial: 'flex',
xs: 'none',
}}
>
<Ad type={AdType.MediumRectangleHorizontalPurple} />
</Flex>
)}
<Flex direction="column" gap="8" style={{ flex: 1 }}>
<Flex direction="column" gap="2">
<Heading size="5">Maneuverability</Heading>
<Info name="Speed" unit="kph" />
<InfoWithDelta decimals={0} indent name="Forwards">
{stats.speedForwards}
</InfoWithDelta>
<InfoWithDelta decimals={0} indent name="Backwards">
{stats.speedBackwards}
</InfoWithDelta>
<InfoWithDelta decimals={0} name="Engine power" unit="hp">
{stats.enginePower}
</InfoWithDelta>
<InfoWithDelta
decimals={1}
name="Weight"
unit="tn"
deltaType="lowerIsBetter"
>
{stats.weight}
</InfoWithDelta>
<Info name="Terrain coefficients" />
<InfoWithDelta decimals={0} unit="%" indent name="Hard">
{stats.hardTerrainCoefficient}
</InfoWithDelta>
<InfoWithDelta decimals={0} unit="%" indent name="Medium">
{stats.mediumTerrainCoefficient}
</InfoWithDelta>
<InfoWithDelta decimals={0} unit="%" indent name="Soft">
{stats.softTerrainCoefficient}
</InfoWithDelta>
<Info name="Raw terrain coefficients" />
<InfoWithDelta decimals={2} unit="%" indent name="Hard">
{stats.hardTerrainCoefficientRaw}
</InfoWithDelta>
<InfoWithDelta decimals={2} unit="%" indent name="Medium">
{stats.mediumTerrainCoefficientRaw}
</InfoWithDelta>
<InfoWithDelta decimals={2} unit="%" indent name="Soft">
{stats.softTerrainCoefficientRaw}
</InfoWithDelta>
<Info name="Power to weight ratio" unit="hp/tn" />
<InfoWithDelta decimals={1} indent name="On hard terrain">
{stats.powerToWeightRatioHardTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain">
{stats.powerToWeightRatioMediumTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain">
{stats.powerToWeightRatioSoftTerrain}
</InfoWithDelta>
<InfoWithDelta name="Turret traverse speed" unit="°/s" decimals={1}>
{stats.turretTraverseSpeed}
</InfoWithDelta>
<Info name="Hull traverse speed" unit="°/s" />
<InfoWithDelta decimals={1} indent name="On hard terrain">
{stats.hullTraverseHardTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain">
{stats.hullTraverseMediumTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain">
{stats.hullTraverseSoftTerrain}
</InfoWithDelta>
</Flex>
<Flex direction="column" gap="2">
<Heading size="5">Survivability</Heading>
<InfoWithDelta name="Health" unit="hp" decimals={0}>
{stats.health}
</InfoWithDelta>
<InfoWithDelta
name="Fire chance"
unit="%"
deltaType="lowerIsBetter"
decimals={0}
>
{stats.fireChance * 100}
</InfoWithDelta>
<InfoWithDelta name="View range" unit="m" decimals={0}>
{stats.viewRange}
</InfoWithDelta>
<Info name="Camouflage" unit="%" />
<InfoWithDelta indent name="Still" decimals={0}>
{stats.camouflageStill * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Moving" decimals={0}>
{stats.camouflageMoving * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Shooting still" decimals={0}>
{stats.camouflageShootingStill * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Shooting moving" decimals={0}>
{stats.camouflageShootingMoving * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Caught on fire" decimals={0}>
{stats.camouflageCaughtOnFire * 100}
</InfoWithDelta>
<Info name="Width" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.width}
</Info>
<Info name="Height" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.height}
</Info>
<Info name="Length" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.length}
</Info>
<Info name="Volume" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.volume}
</Info>
</Flex>
</Flex>
</Flex>
);
}
@@ -0,0 +1,195 @@
import { OrbitControls } from '@react-three/drei';
import { useThree } from '@react-three/fiber';
import { useEffect, useRef, useState } from 'react';
import { Vector3 } from 'three';
import { OrbitControls as OrbitControlsClass } from 'three-stdlib';
import { applyPitchYawLimits } from '../../../../../core/blitz/applyPitchYawLimits';
import { hasEquipment } from '../../../../../core/blitzkit/hasEquipment';
import { modelDefinitions } from '../../../../../core/blitzkit/modelDefinitions';
import { Pose, poseEvent } from '../../../../../core/blitzkit/pose';
import { useAwait } from '../../../../../hooks/useAwait';
import * as Duel from '../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../stores/tankopediaEphemeral';
const initialPosition = [0, 4, -18] as const;
const poseDistances: Record<Pose, number> = {
[Pose.HullDown]: 15,
[Pose.FaceHug]: 5,
[Pose.Default]: -1,
};
export function Controls() {
const duelStore = Duel.useStore();
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const camera = useThree((state) => state.camera);
const canvas = useThree((state) => state.gl.domElement);
const orbitControls = useRef<OrbitControlsClass>(null);
const awaitedModelDefinitions = useAwait(modelDefinitions);
const protagonist = Duel.use((state) => state.protagonist);
const antagonist = Duel.use((state) => state.antagonist);
const protagonistModelDefinition =
awaitedModelDefinitions[protagonist.tank.id];
const protagonistTrackModelDefinition =
awaitedModelDefinitions[protagonist.tank.id].tracks[protagonist.track.id];
const antagonistModelDefinition = awaitedModelDefinitions[antagonist.tank.id];
const protagonistTurretModelDefinition =
protagonistModelDefinition.turrets[protagonist.turret.id];
const antagonistTurretModelDefinition =
antagonistModelDefinition.turrets[antagonist.turret.id];
const protagonistGunModelDefinition =
protagonistTurretModelDefinition.guns[protagonist.gun.id];
const protagonistHullOrigin = new Vector3(
protagonistTrackModelDefinition.origin[0],
protagonistTrackModelDefinition.origin[1],
-protagonistTrackModelDefinition.origin[2],
);
const protagonistTurretOrigin = new Vector3(
protagonistModelDefinition.turretOrigin[0],
protagonistModelDefinition.turretOrigin[1],
-protagonistModelDefinition.turretOrigin[2],
);
const protagonistGunOrigin = new Vector3(
protagonistTurretModelDefinition.gunOrigin[0],
protagonistTurretModelDefinition.gunOrigin[1],
-protagonistTurretModelDefinition.gunOrigin[2],
);
const antagonistGunHeight =
protagonistTrackModelDefinition.origin[1] +
antagonistModelDefinition.turretOrigin[1] +
antagonistTurretModelDefinition.gunOrigin[1];
const [autoRotate, setAutoRotate] = useState(true);
useEffect(() => {
camera.position.set(...initialPosition);
orbitControls.current?.target.set(0, 1.25, 0);
}, [camera]);
useEffect(() => {
const unsubscribeTankopediaEphemeral = tankopediaEphemeralStore.subscribe(
(state) => state.controlsEnabled,
(enabled) => {
if (orbitControls.current) orbitControls.current.enabled = enabled;
},
);
async function handlePoseEvent(event: Pose) {
const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
122,
duel.protagonist.tank.equipment,
duel.protagonist.equipmentMatrix,
);
switch (event) {
case Pose.HullDown: {
const [pitch] = applyPitchYawLimits(
-Infinity,
0,
protagonistGunModelDefinition.pitch,
protagonistTurretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
camera.position
.set(0, 0, 0)
.add(protagonistHullOrigin)
.add(protagonistTurretOrigin)
.add(protagonistGunOrigin)
.add(
new Vector3(
0,
poseDistances[event] * Math.sin(pitch),
poseDistances[event] * -Math.cos(pitch),
),
);
camera.lookAt(
protagonistHullOrigin
.clone()
.add(protagonistTurretOrigin)
.add(protagonistGunOrigin),
);
orbitControls.current?.target.set(0, antagonistGunHeight, 0);
break;
}
case Pose.FaceHug: {
const [pitch] = applyPitchYawLimits(
0,
0,
protagonistGunModelDefinition.pitch,
protagonistTurretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
camera.position
.set(0, 0, 0)
.add(protagonistHullOrigin)
.add(protagonistTurretOrigin)
.add(protagonistGunOrigin)
.add(
new Vector3(
0,
poseDistances[event] * Math.sin(pitch),
poseDistances[event] * -Math.cos(pitch),
),
);
orbitControls.current?.target
.set(0, 0, 0)
.add(protagonistHullOrigin)
.add(protagonistTurretOrigin)
.add(protagonistGunOrigin)
.add(
new Vector3(
0,
0.5 * poseDistances[event] * Math.sin(pitch),
0.5 * poseDistances[event] * -Math.cos(pitch),
),
);
break;
}
case Pose.Default: {
camera.position.set(...initialPosition);
orbitControls.current?.target.set(0, 1.25, 0);
break;
}
}
}
poseEvent.on(handlePoseEvent);
return () => {
unsubscribeTankopediaEphemeral();
poseEvent.off(handlePoseEvent);
};
}, [camera, protagonist.tank.id, antagonist.tank.id]);
useEffect(() => {
function handleDisturbance() {
setAutoRotate(false);
}
poseEvent.on(handleDisturbance);
canvas.addEventListener('pointerdown', handleDisturbance);
return () => {
canvas.removeEventListener('pointerdown', handleDisturbance);
poseEvent.off(handleDisturbance);
};
}, []);
return (
<OrbitControls
maxDistance={20}
minDistance={5}
ref={orbitControls}
enabled={tankopediaEphemeralStore.getState().controlsEnabled}
rotateSpeed={0.25}
enableDamping={false}
autoRotate={autoRotate}
autoRotateSpeed={1 / 4}
/>
);
}
@@ -0,0 +1,16 @@
import { Heading, Text } from '@radix-ui/themes';
import PageWrapper from '../../../../../components/PageWrapper';
import * as Duel from '../../../../../stores/duel';
export function HistorySection() {
const tank = Duel.use((state) => state.protagonist.tank);
if (!tank.description) return null;
return (
<PageWrapper noFlex1 align="center">
<Heading>History of the {tank.name}</Heading>
<Text>{tank.description}</Text>
</PageWrapper>
);
}
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff. Show more