mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-23 03:29:51 +02:00
feat: start of monorepo
This commit is contained in:
1 parent
14f39d71ac
commit
73c7bdf0c0
260 files changed
+1236
-1235
No files matched your search
@@ -1,10 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tankAverages } from '../../../core/blitzstars/tankAverages';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await tankAverages);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Flex, Heading, Link, Text } from '@radix-ui/themes';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
|
||||
import { idToRegion } from '../../core/blitz/idToRegion';
|
||||
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=${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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export const BRANCH_NAMES: Record<string, string> = {
|
||||
dev: 'dev',
|
||||
opentest: 'opentest',
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Code, Flex, Heading, Text } from '@radix-ui/themes';
|
||||
import { imgur } from '../../../core/blitzkit/imgur';
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Flex, Spinner } from '@radix-ui/themes';
|
||||
|
||||
export function PageLoader() {
|
||||
return (
|
||||
<Flex align="center" justify="center" flexGrow="1">
|
||||
<Spinner />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
'use client';
|
||||
|
||||
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 { patreonLoginUrl } from '../../core/blitzkit/patreonLoginUrl';
|
||||
import { assertSecret } from '../../core/blitzkit/secret';
|
||||
import { useAdExempt } from '../../hooks/useAdExempt';
|
||||
import { PatreonIcon } from '../../icons/Patreon';
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
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';
|
||||
import { ImgurSize, imgur } from '../../core/blitzkit/imgur';
|
||||
import { assertSecret } from '../../core/blitzkit/secret';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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>{' '}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
*,
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.radix-themes {
|
||||
--default-font-family: var(--font-inter);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Flex, Theme } from '@radix-ui/themes';
|
||||
import '@radix-ui/themes/styles.css';
|
||||
import { config } from 'dotenv';
|
||||
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 { BRANCH_NAMES } from './components/Hero/constants';
|
||||
import { PageLoader } from './components/PageLoader';
|
||||
import './layout.css';
|
||||
|
||||
config();
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const robotoFlex = Roboto_Flex({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-roboto-flex',
|
||||
});
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const hideNav = false;
|
||||
const isRoot = pathname === '/';
|
||||
const isBranchNamed =
|
||||
process.env.NEXT_PUBLIC_ASSET_BRANCH &&
|
||||
BRANCH_NAMES[process.env.NEXT_PUBLIC_ASSET_BRANCH];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
'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 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,564 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const insertionMarkers: { element: HTMLDivElement; index: number }[] =
|
||||
[];
|
||||
@@ -1,40 +0,0 @@
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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)}`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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}`;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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}`;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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}`;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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>;
|
||||
@@ -1,5 +0,0 @@
|
||||
import PageWrapper from '../../../components/PageWrapper';
|
||||
|
||||
export default function Page() {
|
||||
return <PageWrapper></PageWrapper>;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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})`;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode } from 'react';
|
||||
import { TOOLS } from '../../constants/tools';
|
||||
import { ImgurSize, imgur } from '../../core/blitzkit/imgur';
|
||||
import { theme } from '../../stitches.config';
|
||||
|
||||
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 ${theme.colors.appBackground1}`,
|
||||
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: theme.colors.textHighContrast,
|
||||
fontSize: 32,
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
{tool?.title}
|
||||
</Link>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textLowContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{tool?.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { FilterControl } from '../../tankopedia/components/FilterControl';
|
||||
import { PlayerCountControl } from './PlayerCountControl';
|
||||
|
||||
export function Controls() {
|
||||
return (
|
||||
<>
|
||||
<FilterControl />
|
||||
<PlayerCountControl />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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,
|
||||
);
|
||||
@@ -1,151 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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,
|
||||
);
|
||||
@@ -1,14 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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',
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
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
@@ -1,6 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import * as Session from '../../../stores/session';
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return <Session.Provider>{children}</Session.Provider>;
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
export const permanentSkills = [
|
||||
'smooth_turn',
|
||||
'soft_recoil',
|
||||
'smooth_driving',
|
||||
'smooth_turret',
|
||||
'virtuoso',
|
||||
'camouflage',
|
||||
];
|
||||
@@ -1,96 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,628 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Environment } from '@react-three/drei';
|
||||
import { useModel } from '../../../../../hooks/useModel';
|
||||
import * as Duel from '../../../../../stores/duel';
|
||||
import * as TankopediaPersistent from '../../../../../stores/tankopediaPersistent';
|
||||
|
||||
export const ENVIRONMENTS = [
|
||||
'lobby',
|
||||
'apartment',
|
||||
'warehouse',
|
||||
'dawn',
|
||||
'sunset',
|
||||
] as const;
|
||||
|
||||
export function Lighting() {
|
||||
const protagonist = Duel.use((state) => state.protagonist);
|
||||
const { hasPbr } = useModel(protagonist.tank.id);
|
||||
const showEnvironment = TankopediaPersistent.use(
|
||||
(state) => state.model.visual.showEnvironment,
|
||||
);
|
||||
const environment = TankopediaPersistent.use(
|
||||
(state) => state.model.visual.environment,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Environment preset={environment} background={showEnvironment} blur={0} />
|
||||
|
||||
<directionalLight
|
||||
position={[1, 1, -1]}
|
||||
intensity={hasPbr ? 1 : 5}
|
||||
castShadow
|
||||
color={'rgb(225, 225, 255)'}
|
||||
/>
|
||||
<directionalLight
|
||||
position={[-1, 1, 1]}
|
||||
intensity={hasPbr ? 1 : 4}
|
||||
castShadow
|
||||
color={'rgb(240, 255, 240)'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
|
||||
export function AutoClear() {
|
||||
useFrame(({ gl }) => {
|
||||
gl.clear();
|
||||
}, 0);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Flex, Heading } from '@radix-ui/themes';
|
||||
import { Characteristics } from '../Characteristics';
|
||||
import { Consumables } from '../Characteristics/components/Consumables';
|
||||
import { Equipment } from '../Characteristics/components/Equipment';
|
||||
import { Miscellaneous } from '../Characteristics/components/Miscellaneous';
|
||||
import { Modules } from '../Characteristics/components/Modules';
|
||||
import { Provisions } from '../Characteristics/components/Provisions';
|
||||
import { Skills } from '../Characteristics/components/Skills';
|
||||
|
||||
export function CharacteristicsSection() {
|
||||
return (
|
||||
<Flex mt="4" px="4" style={{ boxSizing: 'border-box' }} gap="6">
|
||||
<Flex
|
||||
flexGrow="1"
|
||||
gap="9"
|
||||
justify="center"
|
||||
align={{ initial: 'center', sm: 'start' }}
|
||||
direction={{ initial: 'column', sm: 'row' }}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="6"
|
||||
maxWidth="21rem"
|
||||
align={{ initial: 'center', sm: 'start' }}
|
||||
>
|
||||
<Heading>Configuration</Heading>
|
||||
<Modules />
|
||||
<Equipment />
|
||||
<Provisions />
|
||||
<Consumables />
|
||||
<Skills />
|
||||
<Miscellaneous />
|
||||
</Flex>
|
||||
<Flex
|
||||
flexGrow="1"
|
||||
maxWidth={{ initial: '20rem', md: '40rem' }}
|
||||
direction="column"
|
||||
gap="6"
|
||||
width="100%"
|
||||
>
|
||||
<Heading align={{ initial: 'center', sm: 'left' }}>
|
||||
Characteristics
|
||||
</Heading>
|
||||
<Characteristics />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { asset } from '../../../../../../core/blitzkit/asset';
|
||||
import { consumableDefinitions } from '../../../../../../core/blitzkit/consumableDefinitions';
|
||||
import { gameDefinitions } from '../../../../../../core/blitzkit/gameDefinitions';
|
||||
import { provisionDefinitions } from '../../../../../../core/blitzkit/provisionDefinitions';
|
||||
import * as Duel from '../../../../../../stores/duel';
|
||||
|
||||
export function GameModeSection() {
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
const awaitedGameDefinitions = use(gameDefinitions);
|
||||
const awaitedConsumableDefinitions = use(consumableDefinitions);
|
||||
const awaitedProvisionDefinitions = use(provisionDefinitions);
|
||||
const roles = Object.entries(tank.roles);
|
||||
|
||||
if (roles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="4" align="center">
|
||||
<Heading size="6">Game mode abilities</Heading>
|
||||
|
||||
<Flex justify="center" gap="4" wrap="wrap" px="4">
|
||||
{roles.map(([key, value]) => {
|
||||
const id = Number(key);
|
||||
const gameMode = awaitedGameDefinitions.gameModes[id];
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={id}
|
||||
width="16rem"
|
||||
height="20rem"
|
||||
overflow="hidden"
|
||||
direction="column"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-3)',
|
||||
background: `url(${asset(`icons/game_mode_banners/${id}.webp`)})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
flexGrow="1"
|
||||
style={{
|
||||
background: `url(${asset(`icons/game_mode_banners/${id}.webp`)})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex
|
||||
p="3"
|
||||
direction="column"
|
||||
height="98px"
|
||||
flexShrink="0"
|
||||
style={{
|
||||
backdropFilter: 'blur(4rem)',
|
||||
WebkitBackdropFilter: 'blur(4rem)',
|
||||
}}
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text size="4" weight="bold">
|
||||
{gameMode.name}
|
||||
</Text>
|
||||
|
||||
<Flex direction="column">
|
||||
{awaitedGameDefinitions.roles[value].provisions.map((id) => {
|
||||
const provisions = awaitedProvisionDefinitions[id];
|
||||
|
||||
return (
|
||||
<Text color="gray" wrap="nowrap" key={id}>
|
||||
<Flex align="center" gap="1">
|
||||
<img
|
||||
style={{
|
||||
width: '1.5em',
|
||||
height: '1.5em',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
src={asset(`icons/provisions/${id}.webp`)}
|
||||
alt={provisions.name}
|
||||
/>
|
||||
{provisions.name}
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
{awaitedGameDefinitions.roles[value].consumables.map((id) => {
|
||||
const consumable = awaitedConsumableDefinitions[id];
|
||||
|
||||
return (
|
||||
<Text color="gray" wrap="nowrap" key={id}>
|
||||
<Flex align="center" gap="1">
|
||||
<img
|
||||
style={{
|
||||
width: '1.5em',
|
||||
height: '1.5em',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
src={asset(`icons/consumables/${id}.webp`)}
|
||||
alt={consumable.name}
|
||||
/>
|
||||
{consumable.name}
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
|
||||
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ThicknessRange } from '../../../../../../components/Armor/components/StaticArmor';
|
||||
import { classIcons } from '../../../../../../components/ClassIcon';
|
||||
import { NAVBAR_HEIGHT } from '../../../../../../components/Navbar/index.css';
|
||||
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
|
||||
import { tankDefinitions } from '../../../../../../core/blitzkit/tankDefinitions';
|
||||
import { Var } from '../../../../../../core/blitzkit/var';
|
||||
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
|
||||
import * as Duel from '../../../../../../stores/duel';
|
||||
import { TankSandbox } from './TankSandbox';
|
||||
import { TankSandboxLoader } from './TankSandboxLoader';
|
||||
import { Options } from './components/Options';
|
||||
|
||||
export function HeroSection() {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const isFullScreen = useFullScreen();
|
||||
const protagonist = Duel.use((state) => state.protagonist.tank);
|
||||
const Icon = classIcons[protagonist.class];
|
||||
const [dummyLoader, setDummyLoader] = useState(true);
|
||||
const treeColor =
|
||||
protagonist.treeType === 'collector'
|
||||
? 'blue'
|
||||
: protagonist.treeType === 'premium'
|
||||
? 'amber'
|
||||
: undefined;
|
||||
const awaitedTankDefinitions = use(tankDefinitions);
|
||||
const thicknessRange = useMemo(() => {
|
||||
const entries = Object.values(awaitedTankDefinitions);
|
||||
const filtered = entries.filter(
|
||||
(thisTank) => thisTank.tier === protagonist.tier,
|
||||
);
|
||||
const value =
|
||||
(filtered.reduce((accumulator, thisTank) => {
|
||||
return (
|
||||
accumulator +
|
||||
resolveNearPenetration(
|
||||
thisTank.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration,
|
||||
)
|
||||
);
|
||||
}, 0) /
|
||||
filtered.length) *
|
||||
(3 / 4);
|
||||
|
||||
return { value } satisfies ThicknessRange;
|
||||
}, [protagonist]);
|
||||
|
||||
useEffect(() => {
|
||||
setDummyLoader(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex justify="center" style={{ backgroundColor: Var('color-surface') }}>
|
||||
<Flex
|
||||
direction={{ initial: 'column', md: 'row' }}
|
||||
style={{
|
||||
backgroundColor: isFullScreen
|
||||
? Var('color-background')
|
||||
: Var('color-surface'),
|
||||
zIndex: isFullScreen ? 2 : undefined,
|
||||
}}
|
||||
height={
|
||||
isFullScreen ? '100vh' : `calc(100vh - ${NAVBAR_HEIGHT}px - 8rem)`
|
||||
}
|
||||
maxHeight={isFullScreen ? undefined : '40rem'}
|
||||
maxWidth={isFullScreen ? undefined : '120rem'}
|
||||
flexGrow="1"
|
||||
width={isFullScreen ? '100vw' : undefined}
|
||||
position={isFullScreen ? 'fixed' : 'relative'}
|
||||
top={isFullScreen ? '0' : undefined}
|
||||
left={isFullScreen ? '0' : undefined}
|
||||
>
|
||||
<Flex
|
||||
style={{ userSelect: 'none' }}
|
||||
direction="column"
|
||||
align={{ initial: 'center', md: 'start' }}
|
||||
justify="center"
|
||||
pl={{ initial: '0', md: '9' }}
|
||||
pt={{ initial: '5', md: '0' }}
|
||||
>
|
||||
<Heading
|
||||
weight="bold"
|
||||
size={{ initial: '8', lg: '9' }}
|
||||
wrap="nowrap"
|
||||
color={treeColor}
|
||||
>
|
||||
<Flex align="center" gap="3">
|
||||
<Icon width="0.8em" height="0.8em" />
|
||||
{protagonist.name}
|
||||
</Flex>
|
||||
</Heading>
|
||||
|
||||
<Text
|
||||
color="gray"
|
||||
size="3"
|
||||
weight="light"
|
||||
ml={{ initial: '0', md: '9' }}
|
||||
>
|
||||
BlitzKit Tankopedia
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Box
|
||||
className="tank-sandbox-container"
|
||||
flexGrow="1"
|
||||
flexBasis="0"
|
||||
flexShrink="0"
|
||||
position="relative"
|
||||
>
|
||||
<Box position="absolute" width="100%" height="100%">
|
||||
<Box width="100%" height="100%">
|
||||
<TankSandboxLoader
|
||||
id={protagonist.id}
|
||||
display={dummyLoader ? 'flex' : 'none'}
|
||||
/>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<TankSandboxLoader
|
||||
id={protagonist.id}
|
||||
display={dummyLoader ? 'none' : 'flex'}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TankSandbox ref={canvas} thicknessRange={thicknessRange} />
|
||||
</Suspense>
|
||||
</Box>
|
||||
|
||||
<Options canvas={canvas} thicknessRange={thicknessRange} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Flex, Text, TextProps } from '@radix-ui/themes';
|
||||
|
||||
type ListingProps = TextProps & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function Listing({ label, ...props }: ListingProps) {
|
||||
return (
|
||||
<Flex minWidth="15rem" justify="between" gap="4">
|
||||
<Text color="gray">{label}</Text>
|
||||
<Text align="right" {...props} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import { StarFilledIcon, StarIcon } from '@radix-ui/react-icons';
|
||||
import { Box, BoxProps } from '@radix-ui/themes';
|
||||
|
||||
type PartialStarProps = BoxProps & {
|
||||
fill: number;
|
||||
};
|
||||
|
||||
export function PartialStar({ fill, ...props }: PartialStarProps) {
|
||||
return (
|
||||
<Box height="1em" width="1em" position="relative" {...props}>
|
||||
<StarIcon width="1em" height="1em" />
|
||||
{fill > 0 && (
|
||||
<Box
|
||||
top="0"
|
||||
left="0"
|
||||
width={`${fill * 100}%`}
|
||||
position="absolute"
|
||||
overflow="hidden"
|
||||
>
|
||||
<StarFilledIcon width="1em" height="1em" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Flex, Text, TextProps } from '@radix-ui/themes';
|
||||
import { times } from 'lodash';
|
||||
import { PartialStar } from './components/PartialStar';
|
||||
|
||||
export type StarsInt = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
type StarsProps = TextProps & {
|
||||
stars: number | null;
|
||||
onCast?: (value: StarsInt) => void;
|
||||
};
|
||||
|
||||
export function Stars({ stars, onCast, ...props }: StarsProps) {
|
||||
let color: TextProps['color'] = 'green';
|
||||
|
||||
if (stars === null) color = 'gray';
|
||||
else if (stars < 1.5) color = 'tomato';
|
||||
else if (stars < 2.5) color = 'orange';
|
||||
else if (stars < 3.5) color = 'amber';
|
||||
else if (stars < 4.5) color = 'blue';
|
||||
|
||||
return (
|
||||
<Text color={color} {...props}>
|
||||
<Flex style={{ cursor: onCast ? 'pointer' : 'unset' }}>
|
||||
{stars !== null &&
|
||||
times(Math.floor(stars), (index) => (
|
||||
<PartialStar
|
||||
fill={1}
|
||||
key={index}
|
||||
onClick={() => onCast?.((index + 1) as StarsInt)}
|
||||
/>
|
||||
))}
|
||||
{stars !== null && stars % 1 !== 0 && <PartialStar fill={stars % 1} />}
|
||||
{times(5 - Math.ceil(stars ?? 0), (index) => (
|
||||
<PartialStar
|
||||
fill={0}
|
||||
key={index}
|
||||
onClick={() =>
|
||||
onCast?.((Math.floor(stars ?? 0) + index + 1) as StarsInt)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { Flex, Text } from '@radix-ui/themes';
|
||||
import { Stars } from '../../Stars';
|
||||
|
||||
interface StarRowProps {
|
||||
stars: number | null;
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function StarRow({ stars, children }: StarRowProps) {
|
||||
return (
|
||||
<Text color={stars === null ? 'gray' : undefined}>
|
||||
<Flex align="center" justify="between" width="15rem">
|
||||
{children}
|
||||
<Stars stars={stars} />
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
import { PaperPlaneIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
AlertDialog,
|
||||
Button,
|
||||
Dialog,
|
||||
Flex,
|
||||
Spinner,
|
||||
Text,
|
||||
} from '@radix-ui/themes';
|
||||
import { useState } from 'react';
|
||||
import { BlitzkitResponse } from '../../../../../../../../../../hooks/useTankVotes';
|
||||
import * as App from '../../../../../../../../../../stores/app';
|
||||
import * as Duel from '../../../../../../../../../../stores/duel';
|
||||
import { Stars, StarsInt } from '../../Stars';
|
||||
|
||||
export function VoteCaster() {
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
const [easiness, setEasiness] = useState<StarsInt | null>(null);
|
||||
const [firepower, setFirepower] = useState<StarsInt | null>(null);
|
||||
const [maneuverability, setManeuverability] = useState<StarsInt | null>(null);
|
||||
const [survivability, setSurvivability] = useState<StarsInt | null>(null);
|
||||
const appStore = App.useStore();
|
||||
// true = vote successful, false = vote failed, null = hide alert
|
||||
const [postVote, setPostVote] = useState<boolean | null>(null);
|
||||
// true = open, false = closed, null = casting
|
||||
const [showDialogue, setShowDialogue] = useState<boolean | null>(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog.Root open={showDialogue !== false} onOpenChange={setShowDialogue}>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="surface">
|
||||
<PaperPlaneIcon /> Vote
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content maxWidth="23rem">
|
||||
<Dialog.Title>Voting for {tank.name}</Dialog.Title>
|
||||
|
||||
<Flex direction="column" mt="4">
|
||||
<Flex align="center" justify="between">
|
||||
<Text>
|
||||
Easiness{' '}
|
||||
<Text color="gray" size="2">
|
||||
{easiness === null ? '(empty)' : `(${easiness})`}
|
||||
</Text>
|
||||
</Text>
|
||||
<Stars stars={easiness} size="5" onCast={setEasiness} />
|
||||
</Flex>
|
||||
<Flex align="center" justify="between">
|
||||
<Text>
|
||||
Firepower{' '}
|
||||
<Text color="gray" size="2">
|
||||
{firepower === null ? '(empty)' : `(${firepower})`}
|
||||
</Text>
|
||||
</Text>
|
||||
<Stars stars={firepower} size="5" onCast={setFirepower} />
|
||||
</Flex>
|
||||
<Flex align="center" justify="between">
|
||||
<Text>
|
||||
Maneuverability{' '}
|
||||
<Text color="gray" size="2">
|
||||
{maneuverability === null
|
||||
? '(empty)'
|
||||
: `(${maneuverability})`}
|
||||
</Text>
|
||||
</Text>
|
||||
<Stars
|
||||
stars={maneuverability}
|
||||
size="5"
|
||||
onCast={setManeuverability}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex align="center" justify="between">
|
||||
<Text>
|
||||
Survivability{' '}
|
||||
<Text color="gray" size="2">
|
||||
{survivability === null ? '(empty)' : `(${survivability})`}
|
||||
</Text>
|
||||
</Text>
|
||||
<Stars stars={survivability} size="5" onCast={setSurvivability} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="end" mt="4" gap="2">
|
||||
<Dialog.Close>
|
||||
<Button variant="outline" color="red">
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
|
||||
<Button
|
||||
disabled={
|
||||
showDialogue === null ||
|
||||
(easiness === null &&
|
||||
firepower === null &&
|
||||
maneuverability === null &&
|
||||
survivability === null)
|
||||
}
|
||||
onClick={() => {
|
||||
const wargaming = appStore.getState().logins.wargaming;
|
||||
|
||||
if (!wargaming) {
|
||||
return setShowDialogue(false);
|
||||
}
|
||||
|
||||
setShowDialogue(null);
|
||||
|
||||
fetch(
|
||||
`/api/tank-voting/${tank.id}/cast?${
|
||||
easiness === null ? '' : `easiness=${easiness}&`
|
||||
}${firepower === null ? '' : `firepower=${firepower}&`}${
|
||||
maneuverability === null
|
||||
? ''
|
||||
: `maneuverability=${maneuverability}&`
|
||||
}${
|
||||
survivability === null
|
||||
? ''
|
||||
: `survivability=${survivability}&`
|
||||
}player=${wargaming.id}&token=${wargaming.token}`,
|
||||
)
|
||||
.then(
|
||||
(response) => response.json() as Promise<BlitzkitResponse>,
|
||||
)
|
||||
.then((json) => {
|
||||
setShowDialogue(false);
|
||||
setPostVote(json.status === 'ok');
|
||||
|
||||
if (json.status === 'error') {
|
||||
console.error('vote failed', json);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{showDialogue === null ? <Spinner /> : <PaperPlaneIcon />} Vote
|
||||
</Button>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<AlertDialog.Root
|
||||
open={postVote !== null}
|
||||
onOpenChange={() => setPostVote(null)}
|
||||
>
|
||||
{postVote === true && (
|
||||
<AlertDialog.Content maxWidth="20rem">
|
||||
<AlertDialog.Title>Vote casted</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
It may take a few minutes for it to show up.
|
||||
</AlertDialog.Description>
|
||||
|
||||
<Flex justify="end" mt="4">
|
||||
<AlertDialog.Cancel>
|
||||
<Button>Okay</Button>
|
||||
</AlertDialog.Cancel>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
)}
|
||||
|
||||
{postVote === false && (
|
||||
<AlertDialog.Content maxWidth="20rem">
|
||||
<AlertDialog.Title>Voting failed</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
So sorry about this! If you're a nerd, feel free to check out the
|
||||
console.
|
||||
</AlertDialog.Description>
|
||||
|
||||
<Flex justify="end" mt="4">
|
||||
<AlertDialog.Cancel>
|
||||
<Button>Gotcha</Button>
|
||||
</AlertDialog.Cancel>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
)}
|
||||
</AlertDialog.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Flex, Heading, Skeleton, Text } from '@radix-ui/themes';
|
||||
import { memo } from 'react';
|
||||
import { WargamingLoginButton } from '../../../../../../../../../components/Navbar/components/WargamingLoginButton';
|
||||
import { formatCompact } from '../../../../../../../../../core/math/formatCompact';
|
||||
import { useTankVotes } from '../../../../../../../../../hooks/useTankVotes';
|
||||
import * as App from '../../../../../../../../../stores/app';
|
||||
import * as Duel from '../../../../../../../../../stores/duel';
|
||||
import { StarRow } from './components/StarRow';
|
||||
import { VoteCaster } from './components/VoteCaster';
|
||||
|
||||
export const Votes = memo(
|
||||
() => {
|
||||
const id = Duel.use((state) => state.protagonist.tank.id);
|
||||
const votes = useTankVotes(id);
|
||||
const wargaming = App.useDeferred(
|
||||
undefined,
|
||||
(state) => state.logins.wargaming,
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="3">
|
||||
<Flex justify="between" align="center">
|
||||
<Heading size="4">
|
||||
{!votes && <Skeleton height="1em" width="4rem" />}
|
||||
{votes && formatCompact(votes.votes)}{' '}
|
||||
{votes && (votes.votes > 1 ? 'votes' : 'vote')}
|
||||
</Heading>
|
||||
|
||||
{!wargaming && (
|
||||
<WargamingLoginButton variant="surface">
|
||||
Login to vote
|
||||
</WargamingLoginButton>
|
||||
)}
|
||||
{wargaming && <VoteCaster />}
|
||||
</Flex>
|
||||
|
||||
<Flex gap="0" direction="column">
|
||||
<Flex
|
||||
gap={{ initial: '0', sm: '6' }}
|
||||
direction={{ initial: 'column', sm: 'row' }}
|
||||
>
|
||||
<StarRow
|
||||
stars={votes?.categories.easiness ?? null}
|
||||
children="Easiness"
|
||||
/>
|
||||
<StarRow
|
||||
stars={votes?.categories.firepower ?? null}
|
||||
children="Firepower"
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
gap={{ initial: '0', sm: '6' }}
|
||||
direction={{ initial: 'column', sm: 'row' }}
|
||||
>
|
||||
<StarRow
|
||||
stars={votes?.categories.maneuverability ?? null}
|
||||
children="Maneuverability"
|
||||
/>
|
||||
<StarRow
|
||||
stars={votes?.categories.survivability ?? null}
|
||||
children="Survivability"
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="center">
|
||||
<Text size="2" color="gray" mt="2">
|
||||
<>
|
||||
{votes ? (
|
||||
wargaming ? (
|
||||
votes.last_updated ? (
|
||||
`You voted on ${new Date(votes.last_updated).toLocaleDateString()}`
|
||||
) : (
|
||||
`You haven't voted yet`
|
||||
)
|
||||
) : (
|
||||
'Login to vote'
|
||||
)
|
||||
) : (
|
||||
<Skeleton height="1em" width="5rem" />
|
||||
)}
|
||||
</>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
@@ -1,206 +0,0 @@
|
||||
import { ChevronLeftIcon, MixIcon, UpdateIcon } from '@radix-ui/react-icons';
|
||||
import { Button, Code, Dialog, Flex } from '@radix-ui/themes';
|
||||
import { use, useState } from 'react';
|
||||
import { classIcons } from '../../../../../../../components/ClassIcon';
|
||||
import { Link } from '../../../../../../../components/Link';
|
||||
import { ScienceIcon } from '../../../../../../../components/ScienceIcon';
|
||||
import { asset } from '../../../../../../../core/blitzkit/asset';
|
||||
import { modelDefinitions } from '../../../../../../../core/blitzkit/modelDefinitions';
|
||||
import { provisionDefinitions } from '../../../../../../../core/blitzkit/provisionDefinitions';
|
||||
import { assertSecret } from '../../../../../../../core/blitzkit/secret';
|
||||
import { tankToDuelMember } from '../../../../../../../core/blitzkit/tankToDuelMember';
|
||||
import strings from '../../../../../../../lang/en-US.json';
|
||||
import * as App from '../../../../../../../stores/app';
|
||||
import * as Duel from '../../../../../../../stores/duel';
|
||||
import * as TankopediaEphemeral from '../../../../../../../stores/tankopediaEphemeral';
|
||||
import { TankSearch } from '../../../../components/TankSearch';
|
||||
import { Listing } from './components/Listing';
|
||||
|
||||
export function MetaSection() {
|
||||
const developerMode = App.useDeferred(false, (state) => state.developerMode);
|
||||
const protagonist = Duel.use((state) => state.protagonist.tank);
|
||||
const ClassIcon = classIcons[protagonist.class];
|
||||
const antagonist = Duel.use((state) => state.antagonist.tank);
|
||||
const compareTanks =
|
||||
protagonist.id === antagonist.id
|
||||
? [protagonist.id]
|
||||
: [protagonist.id, antagonist.id];
|
||||
const mutateDuel = Duel.useMutation();
|
||||
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
|
||||
const awaitedProvisionDefinitions = use(provisionDefinitions);
|
||||
const [showSwapDialog, setShowSwapDialog] = useState(false);
|
||||
const awaitedModelDefinitions = use(modelDefinitions);
|
||||
|
||||
return (
|
||||
<Flex justify="center" align="center">
|
||||
<Flex direction="column" align="center" gap="6">
|
||||
<Flex gap="2" wrap="wrap" justify="center" align="center">
|
||||
<Link href="/tools/tankopedia">
|
||||
<Button variant="outline">
|
||||
<ChevronLeftIcon />
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={`/tools/compare?tanks=${compareTanks.join('%2C')}`}>
|
||||
<Button variant="outline">
|
||||
<MixIcon />
|
||||
Compare
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{assertSecret(process.env.NEXT_PUBLIC_PROMOTE_OPENTEST) ===
|
||||
'true' && (
|
||||
<Link
|
||||
href={`https://opentest.blitzkit.app/tools/tankopedia/${protagonist.id}`}
|
||||
>
|
||||
<Button variant="outline" color="green">
|
||||
<ScienceIcon height="1.25em" width="1.25em" />
|
||||
OpenTest
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Dialog.Root open={showSwapDialog} onOpenChange={setShowSwapDialog}>
|
||||
<Dialog.Trigger>
|
||||
<Button>
|
||||
<UpdateIcon />
|
||||
Swap
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content>
|
||||
<Dialog.Title>Swap tanks</Dialog.Title>
|
||||
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
setShowSwapDialog(false);
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.model = awaitedModelDefinitions[tank.id];
|
||||
});
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist = tankToDuelMember(
|
||||
tank,
|
||||
awaitedProvisionDefinitions,
|
||||
);
|
||||
});
|
||||
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`/tools/tankopedia/${tank.id}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
gap={{ initial: '0', sm: '6' }}
|
||||
direction={{ initial: 'column', sm: 'row' }}
|
||||
>
|
||||
<Flex direction="column" width="100%">
|
||||
{protagonist.nameFull && (
|
||||
<Listing label="Full-name">{protagonist.nameFull}</Listing>
|
||||
)}
|
||||
<Listing label="Nation">
|
||||
<Flex align="center" gap="1">
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt={protagonist.nation}
|
||||
src={asset(`flags/circle/${protagonist.nation}.webp`)}
|
||||
/>
|
||||
{
|
||||
strings.common.nations[
|
||||
protagonist.nation as keyof typeof strings.common.nations
|
||||
]
|
||||
}
|
||||
</Flex>
|
||||
</Listing>
|
||||
<Listing label="Class">
|
||||
<Flex align="center" gap="1">
|
||||
<ClassIcon width="1em" height="1em" />
|
||||
{strings.common.tank_class_short[protagonist.class]}
|
||||
</Flex>
|
||||
</Listing>
|
||||
<Listing label="Tier">{protagonist.tier}</Listing>
|
||||
<Listing
|
||||
label="Type"
|
||||
color={
|
||||
protagonist.treeType === 'collector'
|
||||
? 'blue'
|
||||
: protagonist.treeType === 'premium'
|
||||
? 'amber'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{strings.common.tree_type[protagonist.treeType]}
|
||||
</Listing>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" width="100%">
|
||||
{developerMode && (
|
||||
<Listing label="DEV: ID">
|
||||
<Code>{protagonist.id}</Code>
|
||||
</Listing>
|
||||
)}
|
||||
{protagonist.treeType === 'premium' && (
|
||||
<Listing label="Purchase price">
|
||||
<Flex align="center" gap="1">
|
||||
{protagonist.price.value / 400}
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt="gold"
|
||||
src={asset('icons/currencies/gold.webp')}
|
||||
/>
|
||||
</Flex>
|
||||
</Listing>
|
||||
)}
|
||||
<Listing
|
||||
label={`${protagonist.treeType === 'researchable' ? 'Purchase' : 'Restoration'} price`}
|
||||
>
|
||||
<Flex align="center" gap="1">
|
||||
{protagonist.price.value.toLocaleString()}
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt={protagonist.price.type}
|
||||
src={asset(
|
||||
`icons/currencies/${protagonist.price.type === 'gold' ? 'gold' : 'silver'}.webp`,
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
</Listing>
|
||||
<Listing label="Sale price">
|
||||
<Flex align="center" gap="1">
|
||||
{(protagonist.price.value / 2).toLocaleString()}
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt={protagonist.price.type}
|
||||
src={asset(
|
||||
`icons/currencies/${protagonist.price.type === 'gold' ? 'gold' : 'silver'}.webp`,
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
</Listing>
|
||||
{protagonist.xp && (
|
||||
<Listing label="Research XP">
|
||||
<Flex align="center" gap="1">
|
||||
{protagonist.xp.toLocaleString()}
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt="xp"
|
||||
src={asset('icons/currencies/xp.webp')}
|
||||
/>
|
||||
</Flex>
|
||||
</Listing>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* <Votes /> */}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user