Merge branch 'main' into preview

This commit is contained in:
TresAbhi committed 2025-04-04 20:26:22 +00:00
commit 2a4375c718
300 files changed
+15602 -7394

No files matched your search

@@ -16,6 +16,7 @@ import {
Path,
Quaternion,
} from 'three';
import { LocaleProvider, useLocale } from '../../../hooks/useLocale';
import {
TankopediaEphemeral,
type ShotLayerNonExternal,
@@ -45,6 +46,7 @@ export function ShotDisplay() {
const splashRadiusWrapper = useRef<Group>(null);
const splashRadiusMaterial = useRef<MeshBasicMaterial>(null);
let animationStartTime: number | null = null;
const { locale } = useLocale();
useEffect(() => {
if (shot?.splashRadius !== undefined) {
@@ -180,7 +182,9 @@ export function ShotDisplay() {
mt={tracerGoingUp ? '9' : '0'}
pt={tracerGoingUp ? '4' : '0'}
>
<ShotDisplayCard shot={shot} />
<LocaleProvider locale={locale}>
<ShotDisplayCard shot={shot} />
</LocaleProvider>
</Box>
</Html>
@@ -1,3 +1,4 @@
import { literals } from '@blitzkit/i18n/src/literals';
import {
Card,
Flex,
@@ -8,33 +9,17 @@ import {
} from '@radix-ui/themes';
import type { ComponentProps } from 'react';
import { radToDeg } from 'three/src/math/MathUtils.js';
import type {
Shot,
ShotLayer,
ShotStatus,
} from '../../../stores/tankopediaEphemeral';
import { useLocale } from '../../../hooks/useLocale';
import type { Shot, ShotLayer } from '../../../stores/tankopediaEphemeral';
import { shotStatusColors } from './ShotDisplay';
import { ArmorType } from './SpacedArmorScene';
import type { ExternalModuleVariant } from './SpacedArmorSceneComponent';
export const layerTypeNames: Record<ArmorType | 'null', string> = {
[ArmorType.Primary]: 'Primary',
[ArmorType.Spaced]: 'Spaced',
[ArmorType.External]: 'External',
null: 'Gap',
};
const externalLayerNames: Record<ExternalModuleVariant, string> = {
gun: 'Gun',
track: 'Tracks',
};
const shotStatusNames: Record<ShotStatus, string> = {
blocked: 'Blocked',
ricochet: 'Ricochet',
penetration: 'penetration',
splash: 'splash',
};
export const layerTypeNames = {
[ArmorType.Primary]: 'primary',
[ArmorType.Spaced]: 'spaced',
[ArmorType.External]: 'external',
null: 'gap',
} as const;
function LayerEntry({
layerIndex,
@@ -49,6 +34,8 @@ function LayerEntry({
layer: ShotLayer;
angle?: number;
}) {
const { locale, strings } = useLocale();
return (
<Flex align="center" gap="2">
<Text size="1" color="gray" style={{ width: 12 }}>
@@ -61,20 +48,26 @@ function LayerEntry({
{layer.type === null && (
<>
<Text size="2" color={shotStatusColor} style={{ width: 64 }}>
{Math.round(layer.distance * 1000).toLocaleString()}
<Text size="1">mm</Text>
{literals(strings.common.units.mm, [
Math.round(layer.distance * 1000).toLocaleString(locale),
])}
</Text>
<Text size="2" color="gray">
{Math.max(-100, Math.round(-50 * layer.distance))}% penetration
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
.ricochet_loss,
[Math.max(-100, -50 * layer.distance).toFixed(0)],
)}
</Text>
</>
)}
{layer.type === ArmorType.External && (
<Text size="2" color={shotStatusColor} style={{ width: 64 }}>
{Math.round(layer.thickness).toLocaleString()}
<Text size="1">mm</Text>
{literals(strings.common.units.mm, [
Math.round(layer.thickness).toLocaleString(locale),
])}
</Text>
)}
@@ -82,15 +75,24 @@ function LayerEntry({
layer.type === ArmorType.Spaced) && (
<>
<Text size="2" color={shotStatusColor} style={{ width: 80 }}>
{Math.round(layer.thicknessAngled).toLocaleString()}
<Text size="1">{`mm${
angle === undefined ? '' : ` ${Math.round(radToDeg(angle))}°`
}`}</Text>
{literals(
angle === undefined
? strings.common.units.mm
: strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.stats.thickness_and_angle,
[
Math.round(layer.thicknessAngled).toLocaleString(locale),
radToDeg(angle ?? 0).toFixed(0),
],
)}
</Text>
<Text size="2" color="gray">
{Math.round(layer.thickness).toLocaleString()}
<Text size="1">mm</Text> nominal
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
.nominal,
[Math.round(layer.thickness).toLocaleString(locale)],
)}
</Text>
</>
)}
@@ -103,6 +105,8 @@ interface ShotDisplayCardProps extends CardProps {
}
export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
const { locale, strings } = useLocale();
if (!shot) return null;
const outTitleColor = shot.out
@@ -115,21 +119,23 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
<Flex direction="column" gap="2">
<Flex direction="column" gap="1">
<Text color={inTitleColor} weight="bold">
{shot.in.status !== 'blocked' && shot.in.status !== 'ricochet' && (
<>
{Math.round(shot.damage).toLocaleString()}
<Text size="1">hp</Text>
</>
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.status[
shot.in.status
],
[Math.round(shot.damage).toLocaleString(locale)],
)}
{` ${shotStatusNames[shot.in.status]} `}
</Text>
<Flex direction="column">
{shot.in.layers.map((layer, index) => {
const layerName =
layer.type === ArmorType.External
? externalLayerNames[layer.variant]
: layerTypeNames[`${layer.type}`];
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.element[
layer.type === ArmorType.External
? layer.variant
: layerTypeNames[`${layer.type}`]
];
const layerIndex =
layer.type === null
? undefined
@@ -179,22 +185,22 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
</Inset>
<Text color={outTitleColor} weight="bold">
{shot.out.status !== 'blocked' &&
shot.out.status !== 'ricochet' && (
<>
{Math.round(shot.damage).toLocaleString()}
<Text size="1">hp</Text>
</>
)}
{` ${shotStatusNames[shot.out.status]} `}
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.status[shot.out.status],
[Math.round(shot.damage).toLocaleString(locale)],
)}
</Text>
<Flex direction="column">
{shot.out.layers.map((layer, index) => {
const layerName =
layer.type === ArmorType.External
? externalLayerNames[layer.variant]
: layerTypeNames[`${layer.type}`];
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.element[
layer.type === ArmorType.External
? layer.variant
: layerTypeNames[`${layer.type}`]
];
const shiftedIndex = index + shot.out!.layers.length;
const layerIndex =
layer.type === null
@@ -180,6 +180,11 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = await hasEquipment(
124,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
@@ -191,6 +196,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
}
@@ -275,6 +281,11 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = await hasEquipment(
124,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
@@ -285,6 +296,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
}
@@ -357,6 +369,11 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = await hasEquipment(
124,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
@@ -367,6 +384,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
}
@@ -1,32 +0,0 @@
import { Flex, Select, Text } from '@radix-ui/themes';
import { Charts, type ChartsPeriod } from '../../stores/charts';
export function ChartsControl() {
const period = Charts.use((state) => state.period);
const mutateCharts = Charts.useMutation();
return (
<Flex justify="center" align="center" gap="2">
<Text>Player count</Text>
<Select.Root
value={period}
onValueChange={(period) => {
mutateCharts((draft) => {
draft.period = period as ChartsPeriod;
});
}}
>
<Select.Trigger />
<Select.Content>
<Select.Item value="total">Total</Select.Item>
<Select.Item value="d_120">Past 120 days</Select.Item>
<Select.Item value="d_90">Past 90 days</Select.Item>
<Select.Item value="d_60">Past 60 days</Select.Item>
<Select.Item value="d_30">This month</Select.Item>
<Select.Item value="d_7">This week</Select.Item>
<Select.Item value="d_1">Yesterday</Select.Item>
</Select.Content>
</Select.Root>
</Flex>
);
}
@@ -1,99 +0,0 @@
import {
TANK_CLASSES,
TIERS,
TIER_ROMAN_NUMERALS,
TankClass,
formatCompact,
} from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en.json';
import { useStore } from '@nanostores/react';
import { Box, Flex, Heading } from '@radix-ui/themes';
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions';
import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions';
import { filterTank } from '../../core/blitzkit/filterTank';
import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio';
import { Charts } from '../../stores/charts';
import { $tankFilters } from '../../stores/tankFilters';
import { ThemedBar } from '../Nivo/ThemedBar';
const [tankDefinitions, averageDefinitions] = await Promise.all([
awaitableTankDefinitions,
awaitableAverageDefinitions,
]);
const averageDefinitionsArray = Object.entries(averageDefinitions.averages);
export function PlayersInPeriod() {
const period = Charts.use((state) => state.period);
const ratio = useAveragesExclusionRatio();
const filters = useStore($tankFilters);
const data = TIERS.map((tier) => {
const players: Record<TankClass, number> = {
0: 0,
1: 0,
2: 0,
3: 0,
};
averageDefinitionsArray.forEach(([idString, entry]) => {
const id = Number(idString);
const tank = tankDefinitions.tanks[id];
if (
tank === undefined ||
tank.tier !== tier ||
!filterTank(filters, tank)
) {
return;
}
players[tank.class] += entry.samples[period] * ratio;
});
const bar: Record<string, number | string> = {
tier: TIER_ROMAN_NUMERALS[tier],
};
TANK_CLASSES.forEach((c) => {
bar[strings.common.tank_class_short[c]] = players[c];
});
return bar;
});
return (
<Flex direction="column" gap="4">
<Heading align="center">Players by tier & class</Heading>
<Box height="25rem">
<ThemedBar
data={data}
keys={TANK_CLASSES.map((c) => strings.common.tank_class_short[c])}
indexBy="tier"
margin={{ right: 90, bottom: 50, left: 90 }}
axisBottom={{
legend: 'Tier',
legendPosition: 'middle',
legendOffset: 32,
}}
axisLeft={{
legend: 'Players',
legendPosition: 'middle',
legendOffset: -60,
format: formatCompact,
}}
legends={[
{
dataFrom: 'keys',
anchor: 'bottom-right',
direction: 'column',
translateX: 120,
itemWidth: 100,
itemHeight: 20,
},
]}
valueFormat={formatCompact}
/>
</Box>
</Flex>
);
}
@@ -1 +0,0 @@
export const FIRST_AVERAGES_ENTRY = 19925;
@@ -1,8 +1,14 @@
import { assertSecret, idToRegion } from '@blitzkit/core';
import { Button, Flex, Heading, Link, Text } from '@radix-ui/themes';
import { Button, Flex, Heading, Text } from '@radix-ui/themes';
import { useEffect, useState } from 'react';
import {
LocaleProvider,
useLocale,
type LocaleAcceptorProps,
} from '../../hooks/useLocale';
import { App } from '../../stores/app';
import { CURRENT_POLICIES_AGREEMENT_INDEX } from '../../stores/app/constants';
import { LinkI18n } from '../LinkI18n';
import './index.css';
type Extension =
@@ -18,11 +24,13 @@ type Extension =
status: 'error';
};
export function Checks() {
export function Checks({ locale }: LocaleAcceptorProps) {
return (
<App.Provider>
<Content />
</App.Provider>
<LocaleProvider locale={locale}>
<App.Provider>
<Content />
</App.Provider>
</LocaleProvider>
);
}
@@ -30,6 +38,7 @@ export function Checks() {
* Wargaming: 14 (refresh: 7)
*/
function Content() {
const { locale } = useLocale();
const logins = App.use((state) => state.logins);
const mutateApp = App.useMutation();
const appStore = App.useStore();
@@ -118,9 +127,14 @@ function Content() {
<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>.
By using BlitzKit, you also agree to our{' '}
<Link href="/docs/legal/terms-of-service">terms of service</Link>.
<LinkI18n locale={locale} href="/docs/legal/privacy-policy">
our privacy policy
</LinkI18n>
. By using BlitzKit, you also agree to our{' '}
<LinkI18n locale={locale} href="/docs/legal/terms-of-service">
terms of service
</LinkI18n>
.
</Text>
</Flex>
+25 -136
View File
@@ -1,6 +1,7 @@
import { asset } from '@blitzkit/core';
import { Flex, Heading, IconButton, Table } from '@radix-ui/themes';
import type { TankCharacteristics } from '../../core/blitzkit/tankCharacteristics';
import { useLocale } from '../../hooks/useLocale';
import { CompareEphemeral } from '../../stores/compareEphemeral';
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell';
import { CompareRow } from './CompareRow';
@@ -15,6 +16,7 @@ export function Body({ stats }: BodyProps) {
const hasNonRegularGun = members.some(
({ gun }) => gun.gun_type!.$case !== 'regular',
);
const { unwrap, strings } = useLocale();
const mutateCompareEphemeral = CompareEphemeral.useMutation();
return (
@@ -60,7 +62,7 @@ export function Body({ stats }: BodyProps) {
}}
>
<img
alt={thisShell.name}
alt={unwrap(thisShell.name)}
width={16}
height={16}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
@@ -76,10 +78,10 @@ export function Body({ stats }: BodyProps) {
</Table.Header>
<Table.Body>
<CompareRow stats={stats} name="DPM" value="dpm" decimals={0} />
<CompareRow stats={stats} value="dpm" decimals={0} />
<CompareRow
stats={stats}
name="Reload"
name={strings.website.tools.tankopedia.characteristics.values.reload}
deltaType="lowerIsBetter"
value={(stats) =>
stats.shellReload ??
@@ -96,90 +98,44 @@ export function Body({ stats }: BodyProps) {
<>
<CompareRow
stats={stats}
name="Intra-clip"
deltaType="lowerIsBetter"
value="intraClip"
decimals={2}
/>
<CompareRow
stats={stats}
name="Shells"
value="shells"
decimals={0}
/>
<CompareRow stats={stats} value="shells" decimals={0} />
</>
)}
<CompareRow
stats={stats}
name="Penetration"
value="penetration"
decimals={0}
/>
<CompareRow stats={stats} name="Damage" value="damage" decimals={0} />
<CompareRow stats={stats} value="penetration" decimals={0} />
<CompareRow stats={stats} value="damage" decimals={0} />
{hasNonRegularGun && (
<CompareRow
stats={stats}
name="clipping potential"
indent
value="clipDamage"
decimals={0}
/>
<CompareRow stats={stats} indent value="clipDamage" decimals={0} />
)}
<CompareRow stats={stats} value="moduleDamage" decimals={0} />
<CompareRow stats={stats} value="caliber" decimals={0} />
<CompareRow stats={stats} value="shellNormalization" 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} value="shellVelocity" decimals={0} />
<CompareRow stats={stats} value="shellRange" decimals={0} />
<CompareRow stats={stats} value="shellCapacity" decimals={0} />
<CompareRow
stats={stats}
name="Shell velocity"
value="shellVelocity"
decimals={0}
/>
<CompareRow
stats={stats}
name="Shell range"
value="shellRange"
decimals={0}
/>
<CompareRow
stats={stats}
name="Shell capacity"
value="shellCapacity"
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"
@@ -187,7 +143,6 @@ export function Body({ stats }: BodyProps) {
/>
<CompareRow
stats={stats}
name="hull traversing"
indent
value="dispersionHullTraversing"
deltaType="lowerIsBetter"
@@ -195,7 +150,6 @@ export function Body({ stats }: BodyProps) {
/>
<CompareRow
stats={stats}
name="turret traversing"
indent
value="dispersionTurretTraversing"
deltaType="lowerIsBetter"
@@ -203,7 +157,6 @@ export function Body({ stats }: BodyProps) {
/>
<CompareRow
stats={stats}
name="shooting"
indent
value="dispersionShooting"
deltaType="lowerIsBetter"
@@ -211,79 +164,42 @@ export function Body({ stats }: BodyProps) {
/>
<CompareRow
stats={stats}
name="gun damaged"
indent
value="dispersionGunDamaged"
deltaType="lowerIsBetter"
decimals={3}
/>
<CompareRow
stats={stats}
name="Gun depression"
value="gunDepression"
decimals={1}
/>
<CompareRow
stats={stats}
name="elevation"
indent
value="gunElevation"
decimals={1}
/>
<CompareRow stats={stats} value="gunDepression" decimals={1} />
<CompareRow stats={stats} indent value="gunElevation" decimals={1} />
</Table.Body>
<CompareSectionTitle>Maneuverability</CompareSectionTitle>
<Table.Body>
<CompareRow stats={stats} value="speedForwards" decimals={0} />
<CompareRow stats={stats} indent value="speedBackwards" decimals={0} />
<CompareRow stats={stats} value="enginePower" decimals={0} />
<CompareRow stats={stats} value="weight" decimals={1} />
<CompareRow stats={stats} value="hardTerrainCoefficient" decimals={0} />
<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"
deltaType="lowerIsBetter"
decimals={2}
/>
<CompareRow
stats={stats}
name="medium terrain"
indent
value="mediumTerrainCoefficientRaw"
deltaType="lowerIsBetter"
@@ -291,7 +207,6 @@ export function Body({ stats }: BodyProps) {
/>
<CompareRow
stats={stats}
name="soft terrain"
indent
value="softTerrainCoefficientRaw"
deltaType="lowerIsBetter"
@@ -299,46 +214,35 @@ export function Body({ stats }: BodyProps) {
/>
<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} value="turretTraverseSpeed" 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}
@@ -347,31 +251,23 @@ export function Body({ stats }: BodyProps) {
<CompareSectionTitle>Survivability</CompareSectionTitle>
<Table.Body>
<CompareRow stats={stats} name="Health" value="health" decimals={0} />
<CompareRow stats={stats} 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} value="viewRange" decimals={0} />
<CompareRow
stats={stats}
name="View range"
value="viewRange"
decimals={0}
/>
<CompareRow
stats={stats}
name="Camouflage"
value="camouflageStill"
display={(stats) => (stats.camouflageStill * 100).toFixed(2)}
deltaNominalDisplay={(delta) => (delta * 100).toFixed(2)}
/>
<CompareRow
stats={stats}
name="moving"
indent
value="camouflageMoving"
display={(stats) => (stats.camouflageMoving * 100).toFixed(2)}
@@ -379,7 +275,6 @@ export function Body({ stats }: BodyProps) {
/>
<CompareRow
stats={stats}
name="shooting"
indent
value="camouflageShootingStill"
display={(stats) => (stats.camouflageShootingStill * 100).toFixed(2)}
@@ -387,7 +282,6 @@ export function Body({ stats }: BodyProps) {
/>
<CompareRow
stats={stats}
name="shooting & moving"
indent
value="camouflageShootingMoving"
display={(stats) => (stats.camouflageShootingMoving * 100).toFixed(2)}
@@ -395,7 +289,6 @@ export function Body({ stats }: BodyProps) {
/>
<CompareRow
stats={stats}
name="caught on fire"
indent
value="camouflageCaughtOnFire"
display={(stats) => (stats.camouflageCaughtOnFire * 100).toFixed(2)}
@@ -403,14 +296,12 @@ export function Body({ stats }: BodyProps) {
/>
<CompareRow
stats={stats}
name="Volume"
value="volume"
deltaType="lowerIsBetter"
decimals={1}
/>
<CompareRow
stats={stats}
name="width"
indent
value="width"
deltaType="lowerIsBetter"
@@ -418,7 +309,6 @@ export function Body({ stats }: BodyProps) {
/>
<CompareRow
stats={stats}
name="height"
indent
value="height"
deltaType="lowerIsBetter"
@@ -426,7 +316,6 @@ export function Body({ stats }: BodyProps) {
/>
<CompareRow
stats={stats}
name="length"
indent
value="length"
deltaType="lowerIsBetter"
@@ -4,17 +4,15 @@ import {
CaretSortIcon,
} from '@radix-ui/react-icons';
import { Flex, IconButton, Table, Text } from '@radix-ui/themes';
import { useRef } from 'react';
import type { TankCharacteristics } from '../../core/blitzkit/tankCharacteristics';
import { Var } from '../../core/radix/var';
import { useLocale } from '../../hooks/useLocale';
import { CompareEphemeral } from '../../stores/compareEphemeral';
import { ComparePersistent } from '../../stores/comparePersistent';
import { StickyRowHeaderCell } from '../StickyRowHeaderCell';
interface CompareRowProps {
name: string;
value:
| keyof TankCharacteristics
| ((member: TankCharacteristics) => number | undefined);
type CompareRowProps = {
display?: (
member: Awaited<TankCharacteristics>,
) => number | string | undefined;
@@ -23,24 +21,39 @@ interface CompareRowProps {
deltaNominalDisplay?: (delta: number) => string | number;
indent?: boolean;
stats: TankCharacteristics[];
}
} & (
| {
name: string;
value: (member: TankCharacteristics) => number | undefined;
}
| {
value: keyof TankCharacteristics;
}
);
export function CompareRow({
value,
name,
display,
deltaType = 'higherIsBetter',
decimals,
deltaNominalDisplay,
indent,
stats,
...props
}: CompareRowProps) {
const { strings } = useLocale();
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),
typeof props.value === 'function'
? props.value(stat)!
: (stat[props.value] as number),
);
const id = useRef(Math.random());
const name =
typeof props.value === 'string'
? strings.website.tools.tankopedia.characteristics.values[props.value]
: props.name;
return (
<Table.Row>
@@ -51,7 +64,7 @@ export function CompareRow({
gap={indent ? { initial: '4', sm: '6' } : { initial: '1', sm: '2' }}
>
<IconButton
color={sorting?.by === name ? undefined : 'gray'}
color={sorting?.by === id.current ? undefined : 'gray'}
variant="ghost"
onClick={() => {
mutateCompareEphemeral((draft) => {
@@ -62,26 +75,26 @@ export function CompareRow({
const valueB = values[indexB];
return draft.sorting?.direction === 'ascending' &&
draft.sorting.by === name
draft.sorting.by === id.current
? valueA - valueB
: valueB - valueA;
});
draft.sorting = {
by: name,
by: id.current,
direction:
draft.sorting?.direction === 'ascending' &&
draft.sorting.by === name
draft.sorting.by === id.current
? 'descending'
: 'ascending',
};
});
}}
>
{(sorting === undefined || sorting.by !== name) && (
{(sorting === undefined || sorting.by !== id.current) && (
<CaretSortIcon style={{ transform: 'rotate(90deg)' }} />
)}
{sorting?.by === name && (
{sorting?.by === id.current && (
<>
{sorting.direction === 'ascending' && <CaretRightIcon />}
{sorting.direction === 'descending' && <CaretLeftIcon />}
@@ -9,6 +9,7 @@ import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDe
import { awaitableSkillDefinitions } from '../../core/awaitables/skillDefinitions';
import type { TankCharacteristics } from '../../core/blitzkit/tankCharacteristics';
import { Var } from '../../core/radix/var';
import { useLocale } from '../../hooks/useLocale';
import { CompareEphemeral } from '../../stores/compareEphemeral';
import type { EquipmentMatrix } from '../../stores/duel';
import { BlitzkitButtonGrayIcon } from '../BlitzkitButtonGrayIcon';
@@ -43,6 +44,7 @@ export function CompareTable({ stats }: CompareTableProps) {
const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
const members = CompareEphemeral.use((state) => state.members);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const { unwrap, strings } = useLocale();
return (
<StickyTableRoot
@@ -140,7 +142,7 @@ export function CompareTable({ stats }: CompareTableProps) {
});
}}
>
Clear
{strings.website.tools.compare.table.skills.clear}
</Button>
<Button
variant="ghost"
@@ -152,7 +154,7 @@ export function CompareTable({ stats }: CompareTableProps) {
});
}}
>
Maximize
{strings.website.tools.compare.table.skills.maximize}
</Button>
</Flex>
</Popover.Content>
@@ -255,7 +257,7 @@ export function CompareTable({ stats }: CompareTableProps) {
});
}}
>
Stock
{strings.website.tools.compare.table.modules.stock}
</Button>
<Button
variant="ghost"
@@ -272,7 +274,10 @@ export function CompareTable({ stats }: CompareTableProps) {
});
}}
>
Upgrade
{
strings.website.tools.compare.table.modules
.upgrade
}
</Button>
</Flex>
</Popover.Content>
@@ -293,10 +298,10 @@ export function CompareTable({ stats }: CompareTableProps) {
<Flex direction="column" justify="center">
{provisions.map((provision, index) => (
<img
alt={
alt={unwrap(
provisionDefinitions.provisions[provision]
.name
}
.name,
)}
key={provision}
src={asset(
`/icons/provisions/${provision}.webp`,
@@ -355,7 +360,10 @@ export function CompareTable({ stats }: CompareTableProps) {
});
}}
>
Clear
{
strings.website.tools.compare.table.provisions
.clear
}
</Button>
</Flex>
</Popover.Content>
@@ -439,7 +447,10 @@ export function CompareTable({ stats }: CompareTableProps) {
});
}}
>
Clear
{
strings.website.tools.compare.table.equipment
.clear
}
</Button>
<Button
variant="ghost"
@@ -453,7 +464,10 @@ export function CompareTable({ stats }: CompareTableProps) {
});
}}
>
Apply to all
{
strings.website.tools.compare.table.equipment
.apply_to_all
}
</Button>
</Flex>
</Popover.Content>
@@ -473,11 +487,11 @@ export function CompareTable({ stats }: CompareTableProps) {
<Flex direction="column" justify="center">
{consumables.map((consumable, index) => (
<img
alt={
alt={unwrap(
consumableDefinitions.consumables[
consumable
].name
}
].name,
)}
key={consumable}
src={asset(
`/icons/consumables/${consumable}.webp`,
@@ -537,7 +551,10 @@ export function CompareTable({ stats }: CompareTableProps) {
});
}}
>
Clear
{
strings.website.tools.compare.table.consumables
.clear
}
</Button>
</Flex>
</Popover.Content>
@@ -2,6 +2,7 @@ import { PlusIcon, TrashIcon } from '@radix-ui/react-icons';
import { Button, Dialog, Flex, SegmentedControl } from '@radix-ui/themes';
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions';
import { tankToCompareMember } from '../../core/blitzkit/tankToCompareMember';
import { useLocale } from '../../hooks/useLocale';
import { CompareEphemeral } from '../../stores/compareEphemeral';
import {
ComparePersistent,
@@ -23,6 +24,7 @@ export function Controls({
const deltaMode = ComparePersistent.use((state) => state.deltaMode);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const mutateComparePersistent = ComparePersistent.useMutation();
const { strings } = useLocale();
return (
<Flex gap="2" wrap="wrap" justify="center">
@@ -32,14 +34,16 @@ export function Controls({
>
<Dialog.Trigger>
<Button variant="soft">
<PlusIcon /> Add
<PlusIcon /> {strings.website.tools.compare.actions.add.button}
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title align="center">Add tanks</Dialog.Title>
<Dialog.Title align="center">
{strings.website.tools.compare.actions.add.title}
</Dialog.Title>
<Dialog.Description align="center">
Select tanks to add to comparison
{strings.website.tools.compare.actions.add.description}
</Dialog.Description>
<Flex gap="4" direction="column">
@@ -87,7 +91,7 @@ export function Controls({
});
}}
>
<TrashIcon /> Clear
<TrashIcon /> {strings.website.tools.compare.actions.clear}
</Button>
<SegmentedControl.Root
@@ -99,13 +103,13 @@ export function Controls({
}}
>
<SegmentedControl.Item value={'none' satisfies DeltaMode}>
No deltas
{strings.website.tools.compare.actions.deltas.none}
</SegmentedControl.Item>
<SegmentedControl.Item value={'percentage' satisfies DeltaMode}>
Percentage
{strings.website.tools.compare.actions.deltas.percentage}
</SegmentedControl.Item>
<SegmentedControl.Item value={'absolute' satisfies DeltaMode}>
Absolute
{strings.website.tools.compare.actions.deltas.absolute}
</SegmentedControl.Item>
</SegmentedControl.Root>
</Flex>
@@ -2,6 +2,7 @@ import { type TankDefinition, tankIcon } from '@blitzkit/core';
import { Flex, Text } from '@radix-ui/themes';
import { useEffect, useRef } from 'react';
import { Vector2 } from 'three';
import { useLocale } from '../../hooks/useLocale';
import { CompareEphemeral } from '../../stores/compareEphemeral';
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell';
import { InsertionMarker } from './IntersectionMarker';
@@ -16,6 +17,7 @@ interface TankCardProps {
export function TankCard({ index, tank }: TankCardProps) {
const draggable = useRef<HTMLDivElement>(null);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const { unwrap } = useLocale();
useEffect(() => {
const initial = new Vector2();
@@ -118,7 +120,7 @@ export function TankCard({ index, tank }: TankCardProps) {
}}
>
<img
alt={tank.name}
alt={unwrap(tank.name)}
src={tankIcon(tank.id)}
width={64}
height={64}
@@ -136,7 +138,7 @@ export function TankCard({ index, tank }: TankCardProps) {
textOverflow: 'ellipsis',
}}
>
{tank.name}
{unwrap(tank.name)}
</Text>
</Flex>
</Flex>
@@ -1,9 +1,11 @@
import { ExternalLinkIcon, LoopIcon, TrashIcon } from '@radix-ui/react-icons';
import { Dialog, Flex, IconButton, Link } from '@radix-ui/themes';
import { Dialog, Flex, IconButton } from '@radix-ui/themes';
import { useState } from 'react';
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions';
import { tankToCompareMember } from '../../core/blitzkit/tankToCompareMember';
import { useLocale } from '../../hooks/useLocale';
import { CompareEphemeral } from '../../stores/compareEphemeral';
import { LinkI18n } from '../LinkI18n';
import { TankSearch } from '../TankSearch';
interface TankControlProps {
@@ -14,6 +16,7 @@ interface TankControlProps {
const provisionDefinitions = await awaitableProvisionDefinitions;
export function TankControl({ index, id }: TankControlProps) {
const { locale } = useLocale();
const [switchTankDialogOpen, setSwitchTankDialogOpen] = useState(false);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
@@ -69,11 +72,15 @@ export function TankControl({ index, id }: TankControlProps) {
</Dialog.Content>
</Dialog.Root>
<Link href={`/tools/tankopedia/${id}`} target="_blank">
<LinkI18n
locale={locale}
href={`/tools/tankopedia/${id}`}
target="_blank"
>
<IconButton variant="ghost">
<ExternalLinkIcon />
</IconButton>
</Link>
</LinkI18n>
</Flex>
);
}
@@ -1,5 +1,6 @@
import { Button, type ButtonProps, Tooltip } from '@radix-ui/themes';
import { useState } from 'react';
import { useLocale } from '../hooks/useLocale';
interface CopyButtonProps extends ButtonProps {
copy: () => string | undefined;
@@ -7,9 +8,13 @@ interface CopyButtonProps extends ButtonProps {
export function CopyButton({ copy, ...props }: CopyButtonProps) {
const [tooltipOpen, setTooltipOpen] = useState(false);
const { strings } = useLocale();
return (
<Tooltip open={tooltipOpen} content="Copied!">
<Tooltip
open={tooltipOpen}
content={strings.website.common.copy_button.copied}
>
<Button
{...props}
onClick={() => {
@@ -16,6 +16,7 @@ import {
} from '@radix-ui/themes';
import { times } from 'lodash-es';
import { useState } from 'react';
import { useLocale } from '../../hooks/useLocale';
import './index.css';
const DAY_TITLES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -37,6 +38,7 @@ export function DatePicker({
const [yearOffset, setYearOffset] = useState(0);
const [monthOffset, setMonthOffset] = useState(0);
const date = new Date(year + yearOffset, month + monthOffset, day);
const { locale } = useLocale();
const daysInLastMonth = new Date(
year + yearOffset,
@@ -94,11 +96,7 @@ export function DatePicker({
</IconButton>
<Flex gap="1">
<Text>
{date.toLocaleString(undefined, {
month: 'long',
})}
</Text>
<Text>{date.toLocaleString(locale, { month: 'long' })}</Text>
<Popover.Root onOpenChange={() => setYearScroll(0)}>
<Popover.Trigger>
@@ -1,7 +0,0 @@
import { Link, type LinkProps } from '@radix-ui/themes';
export function DummyLink({ href = '#', onClick, ...props }: LinkProps) {
return (
<Link href={href} onClick={(event) => event.preventDefault()} {...props} />
);
}
@@ -1,5 +1,5 @@
import { Switch } from '@radix-ui/themes';
import type { EmbedPreviewControllerProps } from '../../pages/tools/embed/[embed]/_index';
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/tools/embed/[embed]/_index';
import { EmbedState } from '../../stores/embedState';
export function Boolean({ configKey }: EmbedPreviewControllerProps) {
@@ -1,4 +1,4 @@
import type { EmbedPreviewControllerProps } from '../../pages/tools/embed/[embed]/_index';
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/tools/embed/[embed]/_index';
import { EmbedState } from '../../stores/embedState';
import { ColorControllerRaw } from './ColorControllerRaw';
@@ -1,5 +1,6 @@
import { Select } from '@radix-ui/themes';
import type { EmbedPreviewControllerProps } from '../../pages/tools/embed/[embed]/_index';
import { useLocale } from '../../hooks/useLocale';
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/tools/embed/[embed]/_index';
import { EmbedState } from '../../stores/embedState';
import type {
EmbedConfigItemType,
@@ -13,6 +14,7 @@ export function Enum({
config: EmbedConfigItemType<EmbedItemType.Enum>;
}) {
const mutateEmbedState = EmbedState.useMutation();
const { locale } = useLocale();
return (
<Select.Root
@@ -27,7 +29,7 @@ export function Enum({
<Select.Content>
{config.options.map((option) => (
<Select.Item key={option.value} value={option.value}>
{option.label}
{option.locales[locale]}
</Select.Item>
))}
</Select.Content>
@@ -3,7 +3,7 @@ import {
type AccountListItem,
type AccountListWithServer,
} from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en.json';
import { literals } from '@blitzkit/i18n/src/literals';
import { CaretLeftIcon, Link2Icon, PersonIcon } from '@radix-ui/react-icons';
import {
Badge,
@@ -19,6 +19,7 @@ import { debounce } from 'lodash-es';
import { useCallback, useRef, useState } from 'react';
import { stringify } from 'urlon';
import type { embedConfigurations } from '../../constants/embeds';
import { useLocale } from '../../hooks/useLocale';
import { EmbedState, type EmbedStateStore } from '../../stores/embedState';
import { CopyButton } from '../CopyButton';
@@ -34,6 +35,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
const input = useRef<HTMLInputElement>(null);
const [searching, setSearching] = useState(false);
const [results, setResults] = useState<AccountListWithServer>([]);
const { strings } = useLocale();
const performSearch = useCallback(
debounce(async () => {
if (!input.current) return;
@@ -72,20 +74,27 @@ export function GenerateURL({ embed }: GenerateURLProps) {
<Dialog.Trigger>
<Button>
<Link2Icon />
Generate URL
{strings.website.tools.embed.configuration.export.generate.button}
</Button>
</Dialog.Trigger>
<Dialog.Content width="fit-content">
<Flex direction="column" gap="1">
<Heading size="5">Generate URL</Heading>
<Text color="gray">Search an account to track</Text>
<Heading size="5">
{strings.website.tools.embed.configuration.export.generate.title}
</Heading>
<Text color="gray">
{
strings.website.tools.embed.configuration.export.generate
.subtitle
}
</Text>
</Flex>
<TextField.Root
mt="4"
style={{ width: '20rem' }}
placeholder="Search players..."
placeholder={strings.website.common.player_search.hint}
ref={input}
onChange={handleChange}
>
@@ -98,12 +107,14 @@ export function GenerateURL({ embed }: GenerateURLProps) {
{searching && (
<Text color="gray">
<Flex align="center" gap="2">
<Spinner /> Searching
<Spinner /> {strings.website.common.player_search.searching}
</Flex>
</Text>
)}
{!searching && results.length === 0 && (
<Text color="gray">No results</Text>
<Text color="gray">
{strings.website.common.player_search.no_results}
</Text>
)}
{!searching && results.length > 0 && (
@@ -131,7 +142,7 @@ export function GenerateURL({ embed }: GenerateURLProps) {
<Dialog.Close>
<Button color="red" variant="outline" style={{ width: '100%' }}>
Cancel
{strings.website.tools.embed.configuration.export.generate.cancel}
</Button>
</Dialog.Close>
</Dialog.Content>
@@ -140,12 +151,18 @@ export function GenerateURL({ embed }: GenerateURLProps) {
<Dialog.Root open={copyDialogOpen} onOpenChange={setCopyDialogOpen}>
<Dialog.Content style={{ width: 'fit-content' }}>
<Flex direction="column" gap="1">
<Heading size="5">Copy URL</Heading>
<Heading size="5">
{
strings.website.tools.embed.configuration.export.generate
.success.title
}
</Heading>
<Text color="gray">
Generated the URL for {copyUser?.nickname}. The embed will start
tracking as soon as the URL is pasted. Reset at any time by
right-clicking on PC or long-pressing on mobile and then selecting
"Reset".
{literals(
strings.website.tools.embed.configuration.export.generate
.success.info,
[`${copyUser?.nickname}`],
)}
</Text>
</Flex>
@@ -158,7 +175,11 @@ export function GenerateURL({ embed }: GenerateURLProps) {
setSearchDialogOpen(true);
}}
>
<CaretLeftIcon /> Back
<CaretLeftIcon />{' '}
{
strings.website.tools.embed.configuration.export.generate
.success.back
}
</Button>
<CopyButton
style={{ flex: 1 }}
@@ -186,7 +207,11 @@ export function GenerateURL({ embed }: GenerateURLProps) {
return `${location.origin}/tools/embed/${embed}/host?${searchParams.toString()}`;
}}
>
<Link2Icon /> Copy URL
<Link2Icon />{' '}
{
strings.website.tools.embed.configuration.export.generate
.success.copy
}
</CopyButton>
</Flex>
</Dialog.Content>
@@ -9,6 +9,7 @@ import {
} from '@radix-ui/themes';
import { useRef, useState } from 'react';
import { parse } from 'urlon';
import { useLocale } from '../../hooks/useLocale';
import { EmbedState } from '../../stores/embedState';
export function Import() {
@@ -16,12 +17,15 @@ export function Import() {
const [showInvalidURLPrompt, setShowInvalidURLPrompt] = useState(false);
const [showConfirmation, setConfirmation] = useState(false);
const mutateEmbedState = EmbedState.useMutation();
const { strings } = useLocale();
return (
<>
<Heading>Import</Heading>
<Heading>
{strings.website.tools.embed.configuration.import.title}
</Heading>
<Text size="2" color="gray" mb="2">
Paste your current URL if you have one already to import your settings
{strings.website.tools.embed.configuration.import.description}
</Text>
<AlertDialog.Root
@@ -29,15 +33,18 @@ export function Import() {
onOpenChange={setShowInvalidURLPrompt}
>
<AlertDialog.Content>
<AlertDialog.Title>Invalid URL</AlertDialog.Title>
<AlertDialog.Title>
{strings.website.tools.embed.configuration.import.idiot_title}
</AlertDialog.Title>
<AlertDialog.Description>
Try pasting the URL that BlitzKit previously generated, you silly
goose!
{strings.website.tools.embed.configuration.import.idiot_description}
</AlertDialog.Description>
<Flex gap="3" mt="4" justify="end">
<AlertDialog.Cancel>
<Button>Okay, my bad</Button>
<Button>
{strings.website.tools.embed.configuration.import.idiot_button}
</Button>
</AlertDialog.Cancel>
</Flex>
</AlertDialog.Content>
@@ -46,7 +53,7 @@ export function Import() {
<Flex gap="2" mb="6">
<TextField.Root
ref={importInput}
placeholder="Paste URL..."
placeholder={strings.website.tools.embed.configuration.import.hint}
style={{ flex: 1 }}
/>
<Tooltip content="Applied!" open={showConfirmation}>
@@ -72,7 +79,7 @@ export function Import() {
}
}}
>
Apply
{strings.website.tools.embed.configuration.import.apply}
</Button>
</Tooltip>
</Flex>
@@ -1,5 +1,5 @@
import { Slider } from '@radix-ui/themes';
import type { EmbedPreviewControllerProps } from '../../pages/tools/embed/[embed]/_index';
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/tools/embed/[embed]/_index';
import { EmbedState, type RadixRadius } from '../../stores/embedState';
export function Radius({ configKey }: EmbedPreviewControllerProps) {
@@ -1,6 +1,6 @@
import { FontBoldIcon, FontSizeIcon } from '@radix-ui/react-icons';
import { Flex, Slider, Text } from '@radix-ui/themes';
import type { EmbedPreviewControllerProps } from '../../pages/tools/embed/[embed]/_index';
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/tools/embed/[embed]/_index';
import { EmbedState, type RadixSizeWithout0 } from '../../stores/embedState';
import {
radixTextWeights,
@@ -1,5 +1,5 @@
import { Slider } from '@radix-ui/themes';
import type { EmbedPreviewControllerProps } from '../../pages/tools/embed/[embed]/_index';
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/tools/embed/[embed]/_index';
import { EmbedState, type RadixSize } from '../../stores/embedState';
export function Size({ configKey }: EmbedPreviewControllerProps) {
@@ -1,5 +1,5 @@
import { Slider } from '@radix-ui/themes';
import type { EmbedPreviewControllerProps } from '../../pages/tools/embed/[embed]/_index';
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/tools/embed/[embed]/_index';
import { EmbedState, type RadixSizeWithout0 } from '../../stores/embedState';
export function SizeWithout0({ configKey }: EmbedPreviewControllerProps) {
@@ -1,5 +1,5 @@
import { Slider as RadixSlider } from '@radix-ui/themes';
import type { EmbedPreviewControllerProps } from '../../pages/tools/embed/[embed]/_index';
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/tools/embed/[embed]/_index';
import { EmbedState, type RadixSize } from '../../stores/embedState';
import type {
EmbedConfigItemType,
@@ -1,5 +1,5 @@
import { TextField } from '@radix-ui/themes';
import type { EmbedPreviewControllerProps } from '../../pages/tools/embed/[embed]/_index';
import type { EmbedPreviewControllerProps } from '../../pages/[...locale]/tools/embed/[embed]/_index';
import { EmbedState } from '../../stores/embedState';
export function TextController({ configKey }: EmbedPreviewControllerProps) {
@@ -8,7 +8,7 @@ import {
sumCompositeStats,
type IndividualTankStats,
} from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en.json';
import { STRINGS } from '@blitzkit/i18n';
import { ReloadIcon } from '@radix-ui/react-icons';
import { ContextMenu } from '@radix-ui/themes';
import { useEffect, useMemo, useState } from 'react';
@@ -20,8 +20,13 @@ import { useEmbedStateCurry } from '../../stores/embedState/utilities';
import { BreakdownEmbedCard, BreakdownEmbedWrapper } from '../TanksEmbed';
export const compositeStatsKeysOptions = compositeStatsKeys.map((value) => ({
label: strings.common.composite_stats[value],
value,
locales: Object.entries(STRINGS).reduce((accumulator, [locale, strings]) => {
return {
...accumulator,
[locale]: strings.common.composite_stats[value],
};
}, {}),
}));
const [tankDefinitions, averageDefinitions] = await Promise.all([
+33 -19
View File
@@ -1,10 +1,16 @@
---
import { assertSecret, fetchGameDefinitions } from '@blitzkit/core';
import { Code, Flex, Heading, Link, Text } from '@radix-ui/themes';
import { DEFAULT_LOCALE } from '@blitzkit/i18n';
import { Box, Code, Flex, Heading, Link, Text } from '@radix-ui/themes';
import packageJson from '../../../../package.json';
import { getStrings } from '../core/i18n/getStrings';
import { LinkI18n } from './LinkI18n';
import { LocaleSwitcherThemeWrapper } from './LocaleSwitcher';
const gameDefinitions = await fetchGameDefinitions();
const PUBLIC_ASSET_BRANCH = assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH);
const locale = Astro.params.locale ?? DEFAULT_LOCALE;
const strings = getStrings(locale);
---
<style>
@@ -32,10 +38,14 @@ const PUBLIC_ASSET_BRANCH = assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH);
}}
>
<Heading>BlitzKit</Heading>
<Text color="gray">Everything World of Tanks Blitz</Text>
<Text color="gray">{strings.website.footer.motto}</Text>
<Text size="1" color="gray">
Unaffiliated with Wargaming or any service
{strings.website.footer.copyright}
</Text>
<Box mt="2">
<LocaleSwitcherThemeWrapper {locale} client:load />
</Box>
</Flex>
<Flex
@@ -61,7 +71,7 @@ const PUBLIC_ASSET_BRANCH = assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH);
sm: 'auto',
}}
>
<Heading size="3">About</Heading>
<Heading size="3">{strings.website.footer.about.title}</Heading>
<Flex
direction="column"
align={{
@@ -69,22 +79,26 @@ const PUBLIC_ASSET_BRANCH = assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH);
sm: 'start',
}}
>
<Link href="/docs/about/credits" color="gray"> Credits </Link>
<LinkI18n {locale} href="/docs/about/credits" color="gray">
{strings.website.footer.about.credits}
</LinkI18n>
<Link
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
color="gray"
>
Discord
{strings.website.footer.about.discord}
</Link>
<Link
href="https://github.com/tresabhi/blitzkit"
target="_blank"
color="gray"
>
GitHub
{strings.website.footer.about.github}
</Link>
<Link href="/docs/patches" color="gray"> Patches </Link>
<LinkI18n {locale} href="/docs/patches" color="gray">
{strings.website.footer.about.patches}
</LinkI18n>
</Flex>
</Flex>
@@ -100,14 +114,14 @@ const PUBLIC_ASSET_BRANCH = assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH);
sm: 'auto',
}}
>
<Heading size="3">Legal</Heading>
<Heading size="3">{strings.website.footer.legal.title}</Heading>
<Flex direction="column">
<Link href="/docs/legal/privacy-policy" color="gray">
Privacy policy
</Link>
<Link href="/docs/legal/terms-of-service" color="gray">
Terms of service
</Link>
<LinkI18n {locale} href="/docs/legal/privacy-policy" color="gray">
{strings.website.footer.legal.privacy}
</LinkI18n>
<LinkI18n {locale} href="/docs/legal/terms-of-service" color="gray">
{strings.website.footer.legal.terms}
</LinkI18n>
</Flex>
</Flex>
</Flex>
@@ -119,7 +133,7 @@ const PUBLIC_ASSET_BRANCH = assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH);
{
PUBLIC_ASSET_BRANCH !== 'preview' && (
<Text size="2" color="gray">
WoT Blitz
WoTB
</Text>
)
}
@@ -144,7 +158,7 @@ const PUBLIC_ASSET_BRANCH = assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH);
</Flex>
<Flex gap="2" align="center" display={{ initial: 'none', sm: 'flex' }}>
<Text size="2" color="gray"> BlitzKit </Text>
<Text size="2" color="gray">BlitzKit</Text>
<Code size="2" color="gray">
{packageJson.version}
</Code>
@@ -153,7 +167,7 @@ const PUBLIC_ASSET_BRANCH = assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH);
PUBLIC_ASSET_BRANCH !== 'preview' && (
<>
<Text size="2" color="gray">
WoT Blitz
WoTB
</Text>
<Code size="2" color="gray">
{gameDefinitions.version}
@@ -162,7 +176,7 @@ const PUBLIC_ASSET_BRANCH = assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH);
)
}
<Text size="2" color="gray"> CDN </Text>
<Text size="2" color="gray">CDN</Text>
<Code size="2" color="gray">
{PUBLIC_ASSET_BRANCH}
</Code>
@@ -1,7 +1,9 @@
import { literals } from '@blitzkit/i18n/src/literals';
import { Flex, Text } from '@radix-ui/themes';
import fuzzysort from 'fuzzysort';
import { times } from 'lodash-es';
import { useMemo, useState } from 'react';
import { useLocale } from '../../hooks/useLocale';
import { GalleryEphemeral } from '../../stores/galleryEphemeral';
import { GalleryCard } from './Card';
@@ -20,6 +22,7 @@ const PREVIEW_COUNT = 28;
export function GalleryList({ avatars }: GalleryListProps) {
const search = GalleryEphemeral.use((state) => state.search);
const [loadedCards, setLoadedCards] = useState(DEFAULT_LOADED);
const { locale, strings } = useLocale();
const filtered = useMemo(() => {
setLoadedCards(DEFAULT_LOADED);
@@ -35,7 +38,9 @@ export function GalleryList({ avatars }: GalleryListProps) {
return (
<>
<Text align="center" color="gray">
{filtered.length.toLocaleString()} avatars
{literals(strings.website.tools.gallery.search.results, [
filtered.length.toLocaleString(locale),
])}
</Text>
<Flex wrap="wrap" gap="4" justify="center">
@@ -1,15 +1,17 @@
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import { TextField } from '@radix-ui/themes';
import { useRef } from 'react';
import { useLocale } from '../../hooks/useLocale';
import { GalleryEphemeral } from '../../stores/galleryEphemeral';
export function GallerySearch() {
const input = useRef<HTMLInputElement>(null);
const mutateGalleryEphemeral = GalleryEphemeral.useMutation();
const { strings } = useLocale();
return (
<TextField.Root
placeholder="Search avatars..."
placeholder={strings.website.tools.gallery.search.hint}
ref={input}
onChange={(event) => {
mutateGalleryEphemeral((draft) => {
@@ -0,0 +1,22 @@
import { DEFAULT_LOCALE } from '@blitzkit/i18n';
import { GlobeIcon } from '@radix-ui/react-icons';
import { Flex, Text } from '@radix-ui/themes';
import type { LocaleAcceptorProps } from '../../hooks/useLocale';
import { BlitzKitTheme } from '../BlitzKitTheme';
import { LocaleSwitcher } from '../LocaleSwitcher';
export function HomeLocaleSwitcher({
locale = DEFAULT_LOCALE,
}: LocaleAcceptorProps) {
return (
<BlitzKitTheme>
<Flex justify="center" mt="4" py="6" align="center" gap="3">
<Text size="5" trim="end" color="gray">
<GlobeIcon width="1em" height="1em" />
</Text>
<LocaleSwitcher locale={locale} />
</Flex>
</BlitzKitTheme>
);
}
@@ -1,9 +1,13 @@
---
import { Code, Flex, Heading, Text } from '@radix-ui/themes';
import { resolveBranchName } from '../core/blitzkit/branches';
import { getStrings } from '../core/i18n/getStrings';
import { literals } from '@blitzkit/i18n/src/literals';
const branchName = resolveBranchName();
const { locale } = Astro.params;
const branchName = resolveBranchName(locale);
const isBranchNamed = branchName !== undefined;
const strings = getStrings(locale);
---
<style>
@@ -141,7 +145,9 @@ const isBranchNamed = branchName !== undefined;
</Flex>
</Heading>
<Text color="gray" align="center" size="4">By TrèsAbhi ❤️</Text>
<Text color="gray" align="center" size="4"
>{literals(strings.website.home.hero.credit, ['TrèsAbhi'])}</Text
>
</Flex>
</Flex>
</Flex>
@@ -1,8 +1,14 @@
import { assertSecret } from '@blitzkit/core';
import { literals } from '@blitzkit/i18n/src/literals';
import { EyeOpenIcon } from '@radix-ui/react-icons';
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
import { google } from 'googleapis';
import { awaitableTankDefinitions } from '../core/awaitables/tankDefinitions';
import {
LocaleProvider,
useLocale,
type LocaleAcceptorProps,
} from '../hooks/useLocale';
import { TankopediaPersistent } from '../stores/tankopediaPersistent';
import { TankCard } from './TankCard';
@@ -63,22 +69,26 @@ const hotTanks = report.data.rows
views,
}));
export function HomePageHotTanks() {
export function HomePageHotTanks({ locale }: LocaleAcceptorProps) {
return (
<TankopediaPersistent.Provider>
<Content />
</TankopediaPersistent.Provider>
<LocaleProvider locale={locale}>
<TankopediaPersistent.Provider>
<Content />
</TankopediaPersistent.Provider>
</LocaleProvider>
);
}
function Content() {
const { locale, strings } = useLocale();
return (
<Flex direction="column" pt="4" pb="8">
<Heading align="center" size="5">
Popular tanks
{strings.website.home.hot_tanks.title}
</Heading>
<Text color="gray" align="center" mb="5" size="2">
Last 7 days
{literals(strings.website.home.hot_tanks.subtitle, ['7'])}
</Text>
<Flex justify="center" gap="4" wrap="wrap">
@@ -89,7 +99,7 @@ function Content() {
discriminator={
<Flex align="center" gap="1" justify="center">
<EyeOpenIcon />
{Math.round(row.views * (30 / 7)).toLocaleString('en-US')}
{Math.round(row.views * (30 / 7)).toLocaleString(locale)}
</Flex>
}
/>
@@ -0,0 +1,14 @@
import { DEFAULT_LOCALE } from '@blitzkit/i18n';
import { Link, type LinkProps } from '@radix-ui/themes';
import type { LocaleAcceptorProps } from '../hooks/useLocale';
type LinkI18nProps = LinkProps & LocaleAcceptorProps;
export function LinkI18n({ locale, href, ...props }: LinkI18nProps) {
const prefix =
locale === undefined || locale === DEFAULT_LOCALE ? '' : `/${locale}`;
return (
<Link href={href?.startsWith('/') ? `${prefix}${href}` : href} {...props} />
);
}
@@ -0,0 +1,53 @@
import {} from '@blitzkit/core';
import {
DEFAULT_LOCALE,
STRINGS,
SUPPORTED_LOCALES,
SUPPORTED_LOCALE_FLAGS,
} from '@blitzkit/i18n';
import { Select } from '@radix-ui/themes';
import type { LocaleAcceptorProps } from '../hooks/useLocale';
import { BlitzKitTheme } from './BlitzKitTheme';
export function LocaleSwitcherThemeWrapper({ locale }: LocaleAcceptorProps) {
return (
<BlitzKitTheme style={{ background: 'transparent' }}>
<LocaleSwitcher locale={locale} />
</BlitzKitTheme>
);
}
export function LocaleSwitcher({ locale }: LocaleAcceptorProps) {
return (
<Select.Root
defaultValue={locale}
onValueChange={(locale) => {
localStorage.setItem('preferred-locale', locale);
let rawPath = window.location.pathname;
for (const supportedLocale of SUPPORTED_LOCALES) {
if (window.location.pathname.startsWith(`/${supportedLocale}`)) {
rawPath = rawPath.replace(`/${supportedLocale}`, '');
break;
}
}
window.location.pathname = `${
locale === DEFAULT_LOCALE ? '' : `/${locale}`
}${rawPath}`;
}}
>
<Select.Trigger />
<Select.Content>
{SUPPORTED_LOCALES.map((locale) => (
<Select.Item key={locale} value={locale}>
{SUPPORTED_LOCALE_FLAGS[locale]}{' '}
{STRINGS[locale].common.locales[locale]}
</Select.Item>
))}
</Select.Content>
</Select.Root>
);
}
@@ -3,6 +3,7 @@ import { ClockIcon, ReloadIcon } from '@radix-ui/react-icons';
import { Flex, Text } from '@radix-ui/themes';
import { awaitableConsumableDefinitions } from '../../core/awaitables/consumableDefinitions';
import { useDelta } from '../../hooks/useDelta';
import { useLocale } from '../../hooks/useLocale';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import type { TankComponentButtonProps } from './TankComponentButton';
@@ -22,11 +23,12 @@ export function ConsumableButton({
}: ConsumableButtonProps) {
const cooldownDelta = useDelta(cooldown ?? 0);
const durationDelta = useDelta(duration ?? 0);
const { unwrap } = useLocale();
return (
<GenericTankComponentButton
style={{ width: '6rem' }}
tooltip={consumableDefinitions.consumables[consumable].name}
tooltip={unwrap(consumableDefinitions.consumables[consumable].name)}
icon={asset(`icons/consumables/${consumable}.webp`)}
{...props}
>
@@ -1,5 +1,6 @@
import { asset } from '@blitzkit/core';
import { awaitableEquipmentDefinitions } from '../../core/awaitables/equipmentDefinitions';
import { useLocale } from '../../hooks/useLocale';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import type { TankComponentButtonProps } from './TankComponentButton';
@@ -10,9 +11,11 @@ interface EquipmentButtonProps extends TankComponentButtonProps {
const equipmentDefinitions = await awaitableEquipmentDefinitions;
export function EquipmentButton({ equipment, ...props }: EquipmentButtonProps) {
const { unwrap } = useLocale();
return (
<GenericTankComponentButton
tooltip={equipmentDefinitions.equipments[equipment].name}
tooltip={unwrap(equipmentDefinitions.equipments[equipment].name)}
icon={asset(`icons/equipment/${equipment}.webp`)}
{...props}
/>
@@ -1,5 +1,6 @@
import { asset } from '@blitzkit/core';
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions';
import { useLocale } from '../../hooks/useLocale';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import type { TankComponentButtonProps } from './TankComponentButton';
@@ -10,9 +11,11 @@ interface ProvisionButtonProps extends TankComponentButtonProps {
const provisionDefinitions = await awaitableProvisionDefinitions;
export function ProvisionButton({ provision, ...props }: ProvisionButtonProps) {
const { unwrap } = useLocale();
return (
<GenericTankComponentButton
tooltip={provisionDefinitions.provisions[provision].name}
tooltip={unwrap(provisionDefinitions.provisions[provision].name)}
icon={asset(`icons/provisions/${provision}.webp`)}
{...props}
/>
@@ -9,6 +9,7 @@ import {
} from '@blitzkit/core';
import { DropdownMenu, Flex } from '@radix-ui/themes';
import { useState } from 'react';
import { useLocale } from '../hooks/useLocale';
import { ModuleButton } from './ModuleButtons/ModuleButton';
interface ModuleManagerProps {
@@ -35,6 +36,7 @@ export function ModuleManager({
const [gunMenuOpen, setGunMenuOpen] = useState(false);
const [engineMenuOpen, setEngineMenuOpen] = useState(false);
const [trackMenuOpen, setTrackMenuOpen] = useState(false);
const { unwrap } = useLocale();
return (
<Flex gap="1" wrap="wrap">
@@ -71,7 +73,7 @@ export function ModuleManager({
module="turret"
discriminator={TIER_ROMAN_NUMERALS[turret.tier]}
/>{' '}
{turret.name}
{unwrap(turret.name)}
</DropdownMenu.RadioItem>
);
})}
@@ -117,7 +119,7 @@ export function ModuleManager({
TIER_ROMAN_NUMERALS[gun.gun_type!.value.base.tier]
}
/>{' '}
{gun.gun_type!.value.base.name}
{unwrap(gun.gun_type!.value.base.name)}
</DropdownMenu.RadioItem>
);
})}
@@ -156,7 +158,7 @@ export function ModuleManager({
module="engine"
discriminator={TIER_ROMAN_NUMERALS[engine.tier]}
/>{' '}
{engine.name}
{unwrap(engine.name)}
</DropdownMenu.RadioItem>
);
})}
@@ -195,7 +197,7 @@ export function ModuleManager({
module="chassis"
discriminator={TIER_ROMAN_NUMERALS[track.tier]}
/>{' '}
{track.name}
{unwrap(track.name)}
</DropdownMenu.RadioItem>
);
})}
+32 -17
View File
@@ -19,12 +19,15 @@ import {
} from '@radix-ui/themes';
import { resolveBranchName } from '../core/blitzkit/branches';
import { homeTool, TOOLS } from '../constants/tools';
import { NavbarAccount } from './NavbarAccount';
import { LinkI18n } from './LinkI18n';
import { getStrings } from '../core/i18n/getStrings';
const toolsFiltered = TOOLS.filter((tool) => !('href' in tool));
const tools = [homeTool, ...toolsFiltered];
const branchName = resolveBranchName();
const { locale } = Astro.params;
const branchName = resolveBranchName(locale);
const isBranchNamed = branchName !== undefined;
const strings = getStrings(locale);
---
<style>
@@ -108,7 +111,8 @@ const isBranchNamed = branchName !== undefined;
<Flex className="expander" data-expanded="false" direction="column" gap="2">
<Flex justify="center">
<Flex flexGrow="1" maxWidth="1024px" p="3" align="center">
<Link
<LinkI18n
{locale}
color="gray"
highContrast
href="/"
@@ -125,7 +129,7 @@ const isBranchNamed = branchName !== undefined;
)
}
</Flex>
</Link>
</LinkI18n>
<Box flexGrow="1" />
@@ -136,24 +140,28 @@ const isBranchNamed = branchName !== undefined;
(branch) =>
branch !== assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH),
);
const selected = Astro.url.pathname.startsWith(
`/tools/${tool.id}`,
);
const selected = Astro.url.pathname.includes(`/tools/${tool.id}`);
const toolStrings =
strings.website.tools[
(tool.stringsId ??
tool.id) as keyof typeof strings.website.tools
];
if (unavailableOnBranch) return null;
return (
<Fragment key={tool.title}>
<Fragment key={tool.id}>
{index > 0 && <Separator orientation="vertical" size="1" />}
<Link
<LinkI18n
{locale}
color="gray"
highContrast={selected}
size="2"
href={`/tools/${tool.id}`}
underline={selected ? 'always' : 'hover'}
>
{tool.title}
</Link>
{toolStrings.name}
</LinkI18n>
</Fragment>
);
})
@@ -173,16 +181,17 @@ const isBranchNamed = branchName !== undefined;
<DiscordLogoIcon />
</Link>
<Link
<LinkI18n
{locale}
style={{ display: 'flex', alignContent: 'center' }}
color="gray"
href="/settings"
underline="none"
>
<GearIcon />
</Link>
</LinkI18n>
<NavbarAccount client:idle />
<!-- <NavbarAccount client:idle /> -->
<IconButton variant="ghost" color="gray" className="hamburger">
<Cross1Icon data-expanded />
@@ -207,6 +216,11 @@ const isBranchNamed = branchName !== undefined;
(branch) =>
branch !== assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH),
);
const toolStrings =
strings.website.tools[
(tool.stringsId ??
tool.id) as keyof typeof strings.website.tools
];
if (unavailableOnBranch) return null;
@@ -226,7 +240,8 @@ const isBranchNamed = branchName !== undefined;
aspectRatio: '2 / 1',
}}
>
<Link
<LinkI18n
{locale}
href={tool.id.length === 0 ? '/' : `/tools/${tool.id}`}
style={{
display: 'flex',
@@ -259,9 +274,9 @@ const isBranchNamed = branchName !== undefined;
WebkitBackdropFilter: 'blur(4rem)',
}}
>
<Text>{tool.title}</Text>
<Text>{toolStrings.name}</Text>
</Flex>
</Link>
</LinkI18n>
</Flex>
);
})
@@ -4,21 +4,23 @@ import {
CaretUpIcon,
} from '@radix-ui/react-icons';
import { Button, Flex, Table } from '@radix-ui/themes';
import { useLocale } from '../../hooks/useLocale';
import { TankPerformanceSort } from '../../stores/tankPerformanceSort';
import {
TankPerformanceSortTypeNames,
TankPerformanceSortTypeNamesArray,
} from '../../stores/tankPerformanceSort/constants';
import { TankPerformanceSortTypeNamesArray } from '../../stores/tankPerformanceSort/constants';
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell';
export function Header() {
const sort = TankPerformanceSort.use();
const store = TankPerformanceSort.useStore();
const { strings } = useLocale();
return (
<Table.Header>
<Table.Row align="center">
<StickyColumnHeaderCell>Tank</StickyColumnHeaderCell>
<StickyColumnHeaderCell>
{strings.website.tools.performance.table.tanks.header}
</StickyColumnHeaderCell>
{TankPerformanceSortTypeNamesArray.map((type) => {
const isSelected = sort.type === type;
@@ -42,7 +44,7 @@ export function Header() {
}
}}
>
{TankPerformanceSortTypeNames[type]}
{strings.website.tools.performance.table.stats[type]}
{!isSelected && <CaretSortIcon />}
{isSelected && (
@@ -1,8 +1,10 @@
import { formatCompact } from '@blitzkit/core';
import { literals } from '@blitzkit/i18n/src/literals';
import { InfoCircledIcon } from '@radix-ui/react-icons';
import { Callout, Flex } from '@radix-ui/themes';
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions';
import { awaitableDiscoveredIdsDefinitions } from '../../core/awaitables/discoveredIdsDefinitions';
import { useLocale } from '../../hooks/useLocale';
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps';
import { InlineSkeleton } from '../InlineSkeleton';
@@ -12,14 +14,21 @@ const [discoveredIdsDefinitions, averageDefinitions] = await Promise.all([
]);
export function PerformanceInfo({ skeleton }: MaybeSkeletonComponentProps) {
const samples = formatCompact(Math.round(discoveredIdsDefinitions.count));
const { strings, locale } = useLocale();
const samples = formatCompact(
locale,
Math.round(discoveredIdsDefinitions.count),
);
const minutesAgo = Math.floor(
(Date.now() - averageDefinitions.time) / (1000 * 60),
);
const hoursAgo = Math.floor(minutesAgo / 60);
const timeAgo = `${hoursAgo === 0 ? minutesAgo : hoursAgo} ${
hoursAgo === 0 ? 'minutes' : 'hours'
}`;
const timeAgo = literals(
strings.website.tools.performance.estimation[
hoursAgo === 0 ? 'minutes' : 'hours'
],
[`${hoursAgo === 0 ? minutesAgo : hoursAgo}`],
);
return (
<Flex justify="center">
@@ -28,9 +37,13 @@ export function PerformanceInfo({ skeleton }: MaybeSkeletonComponentProps) {
<InfoCircledIcon />
</Callout.Icon>
<Callout.Text>
Estimated stats based on{' '}
{skeleton ? <InlineSkeleton width="2rem" /> : samples} players.
Updated {skeleton ? <InlineSkeleton width="3rem" /> : timeAgo} ago.
{skeleton && <InlineSkeleton width="9rem" />}
{!skeleton &&
literals(strings.website.tools.performance.estimation.body, [
samples,
timeAgo,
])}
</Callout.Text>
</Callout.Root>
</Flex>
@@ -1,4 +1,5 @@
import { Flex, Select, Text } from '@radix-ui/themes';
import { useLocale } from '../../hooks/useLocale';
import {
TankPerformanceEphemeral,
type PlayerCountPeriod,
@@ -9,10 +10,11 @@ export function PlayerCountControl() {
(state) => state.playerCountPeriod,
);
const mutateTankPerformanceSort = TankPerformanceEphemeral.useMutation();
const { strings } = useLocale();
return (
<Flex justify="center" align="center" gap="2">
<Text>Player count</Text>
<Text>{strings.website.tools.performance.player_count.label}</Text>
<Select.Root
value={playerCountPeriod}
onValueChange={(period) => {
@@ -23,13 +25,27 @@ export function PlayerCountControl() {
>
<Select.Trigger />
<Select.Content>
<Select.Item value="total">Total</Select.Item>
<Select.Item value="d_120">Past 120 days</Select.Item>
<Select.Item value="d_90">Past 90 days</Select.Item>
<Select.Item value="d_60">Past 60 days</Select.Item>
<Select.Item value="d_30">This month</Select.Item>
<Select.Item value="d_7">This week</Select.Item>
<Select.Item value="d_1">Yesterday</Select.Item>
<Select.Item value="total">
{strings.common.player_count_period.total}
</Select.Item>
<Select.Item value="d_120">
{strings.common.player_count_period.d_120}
</Select.Item>
<Select.Item value="d_90">
{strings.common.player_count_period.d_90}
</Select.Item>
<Select.Item value="d_60">
{strings.common.player_count_period.d_60}
</Select.Item>
<Select.Item value="d_30">
{strings.common.player_count_period.d_30}
</Select.Item>
<Select.Item value="d_7">
{strings.common.player_count_period.d_7}
</Select.Item>
<Select.Item value="d_1">
{strings.common.player_count_period.d_1}
</Select.Item>
</Select.Content>
</Select.Root>
</Flex>
@@ -3,6 +3,7 @@ import { Table } from '@radix-ui/themes';
import { memo } from 'react';
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions';
import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio';
import { useLocale } from '../../hooks/useLocale';
import { TankPerformanceEphemeral } from '../../stores/tankPerformanceEphemeral';
import { TankRowHeaderCell } from '../TankRowHeaderCell';
@@ -19,6 +20,7 @@ export const TankRow = memo<TankRowProps>(
const playerCountPeriod = TankPerformanceEphemeral.use(
(state) => state.playerCountPeriod,
);
const { locale } = useLocale();
return (
<Table.Row>
@@ -29,13 +31,14 @@ export const TankRow = memo<TankRowProps>(
</Table.Cell>
<Table.Cell align="center">
{formatCompact(
locale,
Math.round(ratio * averages.samples[playerCountPeriod]),
)}
</Table.Cell>
<Table.Cell align="center">
{Math.round(
averages.mu.damage_dealt / averages.mu.battles,
).toLocaleString()}
).toLocaleString(locale)}
</Table.Cell>
<Table.Cell align="center">
{Math.round(
@@ -44,7 +47,9 @@ export const TankRow = memo<TankRowProps>(
%
</Table.Cell>
<Table.Cell align="center">
{Math.round(averages.mu.xp / averages.mu.battles).toLocaleString()}
{Math.round(averages.mu.xp / averages.mu.battles).toLocaleString(
locale,
)}
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.frags / averages.mu.battles).toFixed(2)}
@@ -67,7 +72,7 @@ export const TankRow = memo<TankRowProps>(
<Table.Cell align="center">
{Math.round(
averages.mu.damage_received / averages.mu.battles,
).toLocaleString()}
).toLocaleString(locale)}
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.capture_points / averages.mu.battles).toFixed(2)}
@@ -51,7 +51,7 @@ export function Tanks({ skeleton }: MaybeSkeletonComponentProps) {
(a, b) =>
sort.direction * (a.mu.hits / a.mu.shots - b.mu.hits / b.mu.shots),
);
case 'capturePoints':
case 'capture_points':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
@@ -65,14 +65,14 @@ export function Tanks({ skeleton }: MaybeSkeletonComponentProps) {
(a.mu.damage_dealt / a.mu.battles -
b.mu.damage_dealt / b.mu.battles),
);
case 'damageRatio':
case 'damage_ratio':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.damage_dealt / a.mu.damage_received -
b.mu.damage_dealt / b.mu.damage_received),
);
case 'damageTaken':
case 'damage_taken':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
@@ -7,6 +7,7 @@ import { Table } from '@radix-ui/themes';
import { memo, useCallback } from 'react';
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions';
import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio';
import { useLocale } from '../../hooks/useLocale';
import { TankPerformanceEphemeral } from '../../stores/tankPerformanceEphemeral';
import { StickyRowHeaderCell } from '../StickyRowHeaderCell';
@@ -25,6 +26,7 @@ export const Total = memo<TotalProps>(
const playerCountPeriod = TankPerformanceEphemeral.use(
(state) => state.playerCountPeriod,
);
const { strings, locale } = useLocale();
const ratio = useAveragesExclusionRatio();
const sum = useCallback(
(slice: (tank: BlitzkitStats) => number) => {
@@ -54,20 +56,23 @@ export const Total = memo<TotalProps>(
return (
<Table.Row>
<StickyRowHeaderCell style={{ overflow: 'hidden', display: 'flex' }}>
Total
{strings.website.tools.performance.table.tanks.total}
</StickyRowHeaderCell>
<Table.Cell align="center">{(winrate * 100).toFixed(1)}%</Table.Cell>
<Table.Cell align="center">
{formatCompact(ratio * averageDefinitions.samples[playerCountPeriod])}
{formatCompact(
locale,
ratio * averageDefinitions.samples[playerCountPeriod],
)}
</Table.Cell>
<Table.Cell align="center">
{Math.round(damage).toLocaleString()}
{Math.round(damage).toLocaleString(locale)}
</Table.Cell>
<Table.Cell align="center">
{Math.round(survival * 100).toFixed(1)}%
</Table.Cell>
<Table.Cell align="center">
{Math.round(xp).toLocaleString()}
{Math.round(xp).toLocaleString(locale)}
</Table.Cell>
<Table.Cell align="center">{kills.toFixed(2)}</Table.Cell>
<Table.Cell align="center">{spots.toFixed(2)}</Table.Cell>
@@ -76,7 +81,7 @@ export const Total = memo<TotalProps>(
<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()}
{Math.round(damageReceived).toLocaleString(locale)}
</Table.Cell>
<Table.Cell align="center">{capturePoints.toFixed(2)}</Table.Cell>
</Table.Row>
@@ -1,9 +1,16 @@
import { asset, TankType, type TankDefinition } from '@blitzkit/core';
import { Flex, Link, Text, type TextProps } from '@radix-ui/themes';
import {
asset,
fetchTankDefinitions,
TankType,
type TankDefinition,
} from '@blitzkit/core';
import { Flex, Text, type TextProps } from '@radix-ui/themes';
import { uniq } from 'lodash-es';
import { forwardRef, type ReactNode } from 'react';
import { useLocale } from '../../hooks/useLocale';
import { TankopediaPersistent } from '../../stores/tankopediaPersistent';
import { classIcons } from '../ClassIcon';
import { LinkI18n } from '../LinkI18n';
import { MAX_RECENTLY_VIEWED } from '../TankSearch/constants';
import './index.css';
@@ -14,6 +21,8 @@ type TankCardProps = TextProps & {
noLink?: boolean;
};
const tankDefinitions = await fetchTankDefinitions();
export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
(
{
@@ -26,6 +35,7 @@ export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
}: TankCardProps,
ref,
) => {
const { unwrap, locale } = useLocale();
const provideLink = !noLink && onSelect === undefined;
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const Icon = classIcons[tank.class];
@@ -46,10 +56,9 @@ export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
onClick={() => {
onSelect?.(tank);
mutateTankopediaPersistent((draft) => {
draft.recentlyViewed = uniq([
tank.id,
...draft.recentlyViewed,
]).slice(0, MAX_RECENTLY_VIEWED);
draft.recentlyViewed = uniq([tank.id, ...draft.recentlyViewed])
.filter((id) => id in tankDefinitions.tanks)
.slice(0, MAX_RECENTLY_VIEWED);
});
}}
className="tank-search-card"
@@ -60,7 +69,8 @@ export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
}}
{...props}
>
<Link
<LinkI18n
locale={locale}
className="link"
underline="hover"
href={provideLink ? `/tools/tankopedia/${tank.id}` : '#'}
@@ -69,7 +79,7 @@ export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
}}
>
<img
alt={tank.name}
alt={unwrap(tank.name)}
src={asset(`icons/tanks/blitzkit/${tank.id}.webp`)}
className="image"
draggable={false}
@@ -85,7 +95,7 @@ export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
>
<Icon className="class-icon" />
<Text align="center" className="name">
{tank.name}
{unwrap(tank.name)}
</Text>
</Flex>
@@ -102,7 +112,7 @@ export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
{discriminator}
</Text>
)}
</Link>
</LinkI18n>
</Text>
);
},
@@ -1,7 +1,8 @@
import { type TankDefinition, tankIcon, TankType } from '@blitzkit/core';
import { Link } from '@radix-ui/themes';
import { useLocale } from '../hooks/useLocale';
import { classIcons } from './ClassIcon';
import { ExperimentIcon } from './ExperimentIcon';
import { LinkI18n } from './LinkI18n';
import { StickyRowHeaderCell } from './StickyRowHeaderCell';
interface TankRowHeaderCellProps {
@@ -9,6 +10,7 @@ interface TankRowHeaderCellProps {
}
export function TankRowHeaderCell({ tank }: TankRowHeaderCellProps) {
const { locale, unwrap } = useLocale();
const Icon = classIcons[tank.class];
return (
@@ -16,9 +18,13 @@ export function TankRowHeaderCell({ tank }: TankRowHeaderCellProps) {
width={{ initial: '144px', sm: '240px' }}
style={{ overflow: 'hidden' }}
>
<Link href={`/tools/tankopedia/${tank.id}`} tabIndex={-1}>
<LinkI18n
locale={locale}
href={`/tools/tankopedia/${tank.id}`}
tabIndex={-1}
>
<img
alt={tank.name}
alt={unwrap(tank.name)}
draggable={false}
src={tankIcon(tank.id)}
style={{
@@ -28,9 +34,10 @@ export function TankRowHeaderCell({ tank }: TankRowHeaderCellProps) {
objectFit: 'cover',
}}
/>
</Link>
</LinkI18n>
<Link
<LinkI18n
locale={locale}
color={
tank.type === TankType.COLLECTOR
? 'blue'
@@ -63,8 +70,8 @@ export function TankRowHeaderCell({ tank }: TankRowHeaderCellProps) {
<ExperimentIcon style={{ width: '1em', height: '1em' }} />
)}
{tank.name}
</Link>
{unwrap(tank.name)}
</LinkI18n>
</StickyRowHeaderCell>
);
}
@@ -1,4 +1,5 @@
import { Flex, Link, Text } from '@radix-ui/themes';
import { useLocale } from '../../../hooks/useLocale';
import { $tankFilters, initialTankFilters } from '../../../stores/tankFilters';
interface NoResultsProps {
@@ -6,10 +7,12 @@ interface NoResultsProps {
}
export function NoResults({ type = 'filters' }: NoResultsProps) {
const { strings } = useLocale();
return (
<Flex flexGrow="1" align="center" justify="center">
<Text color="gray">
No tanks found.{' '}
{strings.website.common.tank_search.no_tanks_found.body}{' '}
<Link
href="#"
underline="always"
@@ -18,9 +21,10 @@ export function NoResults({ type = 'filters' }: NoResultsProps) {
$tankFilters.set(initialTankFilters);
}}
>
{type === 'filters' ? 'Try clearing filters' : 'Try searching again'}
{type === 'filters'
? strings.website.common.tank_search.no_tanks_found.clear_filters
: strings.website.common.tank_search.no_tanks_found.search_again}
</Link>
.
</Text>
</Flex>
);
@@ -1,8 +1,10 @@
import { type TankDefinition } from '@blitzkit/core';
import { useStore } from '@nanostores/react';
import { CaretRightIcon } from '@radix-ui/react-icons';
import { Button, Link, TextField } from '@radix-ui/themes';
import { Button, TextField } from '@radix-ui/themes';
import { useLocale } from '../../../hooks/useLocale';
import { $tankFilters } from '../../../stores/tankFilters';
import { LinkI18n } from '../../LinkI18n';
interface QuickLinkProps {
topResult?: TankDefinition;
@@ -10,16 +12,17 @@ interface QuickLinkProps {
export function QuickLink({ topResult }: QuickLinkProps) {
const tankFilters = useStore($tankFilters);
const { locale, unwrap } = useLocale();
if (!tankFilters.search || !topResult || tankFilters.searching) return null;
return (
<TextField.Slot>
<Link href={`/tools/tankopedia/${topResult.id}`}>
<LinkI18n locale={locale} href={`/tools/tankopedia/${topResult.id}`}>
<Button variant="ghost">
{topResult.name} <CaretRightIcon />
{unwrap(topResult.name)} <CaretRightIcon />
</Button>
</Link>
</LinkI18n>
</TextField.Slot>
);
}
@@ -3,6 +3,7 @@ import { Flex, Separator, Text } from '@radix-ui/themes';
import { isEqual } from 'lodash-es';
import { useMemo } from 'react';
import { awaitableTankDefinitions } from '../../../core/awaitables/tankDefinitions';
import { useLocale } from '../../../hooks/useLocale';
import { $tankFilters, initialTankFilters } from '../../../stores/tankFilters';
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent';
import { $tankopediaSort } from '../../../stores/tankopediaSort';
@@ -15,7 +16,9 @@ export function RecentlyViewed() {
const tankopediaPersistentStore = TankopediaPersistent.useStore();
const filters = useStore($tankFilters);
// non-reactive because it is a little weird that it updates instantly even before the page loads
const recentlyViewed = tankopediaPersistentStore.getState().recentlyViewed;
const recentlyViewed = tankopediaPersistentStore
.getState()
.recentlyViewed.filter((id) => id in tankDefinitions.tanks);
const sort = useStore($tankopediaSort);
const hasFilters = useMemo(
() =>
@@ -27,6 +30,7 @@ export function RecentlyViewed() {
}),
[filters],
);
const { strings } = useLocale();
if (recentlyViewed.length === 0 || sort.by !== 'meta.none' || hasFilters) {
return null;
@@ -35,7 +39,7 @@ export function RecentlyViewed() {
return (
<Flex direction="column" gap="2" mt="2" mb="6">
<Text color="gray" align="center">
Recently viewed
{strings.website.common.tank_search.recent}
</Text>
<TankCardWrapper>
{recentlyViewed.map((id) => (
@@ -4,6 +4,7 @@ import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import { Flex, Spinner, TextField } from '@radix-ui/themes';
import { debounce } from 'lodash-es';
import { type KeyboardEventHandler, useCallback, useRef } from 'react';
import { useLocale } from '../../../hooks/useLocale';
import { $tankFilters } from '../../../stores/tankFilters';
import { QuickLink } from './QuickLink';
import { Sort } from './Sort';
@@ -14,6 +15,7 @@ interface SearchBarProps {
}
export function SearchBar({ topResult, onSelect }: SearchBarProps) {
const { strings } = useLocale();
const tankFilters = useStore($tankFilters);
const input = useRef<HTMLInputElement>(null);
const performSearch = useCallback(
@@ -67,7 +69,7 @@ export function SearchBar({ topResult, onSelect }: SearchBarProps) {
defaultValue={tankFilters.search}
style={{ flex: 1 }}
ref={input}
placeholder="Search tanks..."
placeholder={strings.website.common.tank_search.search_bar_hint}
onChange={handleChange}
onKeyDown={handleKeyDown}
>
@@ -1,308 +1,122 @@
import { useStore } from '@nanostores/react';
import { CaretDownIcon } from '@radix-ui/react-icons';
import { Button, DropdownMenu } from '@radix-ui/themes';
import type { TankopediaSortDirection } from '../../../stores/tankopediaPersistent';
import { useLocale } from '../../../hooks/useLocale';
import type {
TankopediaSortBy,
TankopediaSortDirection,
} from '../../../stores/tankopediaPersistent';
import { $tankopediaSort } from '../../../stores/tankopediaSort';
interface ItemProps {
by: TankopediaSortBy;
}
interface ItemProps {
by: TankopediaSortBy;
}
function Item({ by }: ItemProps) {
const { strings } = useLocale();
const tankopediaSort = useStore($tankopediaSort);
return (
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === by}
onClick={() => {
$tankopediaSort.setKey('by', by);
}}
>
{strings.website.common.tank_search.sort[by]}
</DropdownMenu.CheckboxItem>
);
}
export function Sort() {
const tankopediaSort = useStore($tankopediaSort);
const { strings } = useLocale();
return (
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger>
<Button variant="surface" color="gray">
Sort
{strings.website.common.tank_search.sort_dropdown.label}
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'meta.none'}
onClick={() => {
// mutateTankopediaSort((draft) => {
// draft.by = 'meta.none';
// });
$tankopediaSort.setKey('by', 'meta.none');
}}
>
No sort
</DropdownMenu.CheckboxItem>
<DropdownMenu.Label>
{strings.website.common.tank_search.sort_dropdown.groups.sort.label}
</DropdownMenu.Label>
<DropdownMenu.Label>Sort by</DropdownMenu.Label>
<Item by="meta.none" />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Fire</DropdownMenu.SubTrigger>
<DropdownMenu.SubTrigger>
{strings.website.common.tank_search.sort_dropdown.groups.sort.fire}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.dpm'}
onClick={() => {
// mutateTankopediaSort((draft) => {
// draft.by = 'fire.dpm';
// });
$tankopediaSort.setKey('by', 'fire.dpm');
}}
>
Standard DPM
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.dpmPremium'}
onClick={() => {
// mutateTankopediaSort((draft) => {
// draft.by = 'fire.dpmPremium';
// });
$tankopediaSort.setKey('by', 'fire.dpmPremium');
}}
>
Premium DPM
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.reload'}
onClick={() => {
// mutateTankopediaSort((draft) => {
// draft.by = 'fire.reload';
// });
$tankopediaSort.setKey('by', 'fire.reload');
}}
>
Reload
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.caliber'}
onClick={() => {
// mutateTankopediaSort((draft) => {
// draft.by = 'fire.caliber';
// });
$tankopediaSort.setKey('by', 'fire.caliber');
}}
>
Caliber
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.standardPenetration'}
onClick={() => {
// mutateTankopediaSort((draft) => {
// draft.by = 'fire.standardPenetration';
// });
$tankopediaSort.setKey('by', 'fire.standardPenetration');
}}
>
Standard penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.premiumPenetration'}
onClick={() => {
// mutateTankopediaSort((draft) => {
// draft.by = 'fire.premiumPenetration';
// });
$tankopediaSort.setKey('by', 'fire.premiumPenetration');
}}
>
Premium penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.damage'}
onClick={() => {
$tankopediaSort.setKey('by', 'fire.damage');
}}
>
Damage
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.moduleDamage'}
onClick={() => {
$tankopediaSort.setKey('by', 'fire.moduleDamage');
}}
>
Module damage
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.shellVelocity'}
onClick={() => {
$tankopediaSort.setKey('by', 'fire.shellVelocity');
}}
>
Shell velocity
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.shellRange'}
onClick={() => {
$tankopediaSort.setKey('by', 'fire.shellRange');
}}
>
Shell range
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.shellCapacity'}
onClick={() => {
$tankopediaSort.setKey('by', 'fire.shellCapacity');
}}
>
Shell capacity
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.aimTime'}
onClick={() => {
$tankopediaSort.setKey('by', 'fire.aimTime');
}}
>
Aim time
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.dispersionStill'}
onClick={() => {
$tankopediaSort.setKey('by', 'fire.dispersionStill');
}}
>
Dispersion still
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.dispersionMoving'}
onClick={() => {
$tankopediaSort.setKey('by', 'fire.dispersionMoving');
}}
>
Dispersion moving
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.gunDepression'}
onClick={() => {
$tankopediaSort.setKey('by', 'fire.gunDepression');
}}
>
Gun depression
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'fire.gunElevation'}
onClick={() => {
$tankopediaSort.setKey('by', 'fire.gunElevation');
}}
>
Gun elevation
</DropdownMenu.CheckboxItem>
<Item by="fire.dpm" />
<Item by="fire.dpmPremium" />
<Item by="fire.reload" />
<Item by="fire.caliber" />
<Item by="fire.standardPenetration" />
<Item by="fire.premiumPenetration" />
<Item by="fire.damage" />
<Item by="fire.moduleDamage" />
<Item by="fire.shellVelocity" />
<Item by="fire.shellRange" />
<Item by="fire.shellCapacity" />
<Item by="fire.aimTime" />
<Item by="fire.dispersionStill" />
<Item by="fire.dispersionMoving" />
<Item by="fire.gunDepression" />
<Item by="fire.gunElevation" />
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Maneuverability</DropdownMenu.SubTrigger>
<DropdownMenu.SubTrigger>
{
strings.website.common.tank_search.sort_dropdown.groups.sort
.maneuverability
}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'maneuverability.forwardsSpeed'}
onClick={() => {
$tankopediaSort.setKey('by', 'maneuverability.forwardsSpeed');
}}
>
Forwards speed
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'maneuverability.backwardsSpeed'}
onClick={() => {
$tankopediaSort.setKey('by', 'maneuverability.backwardsSpeed');
}}
>
Backwards speed
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'maneuverability.power'}
onClick={() => {
$tankopediaSort.setKey('by', 'maneuverability.power');
}}
>
Power
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'maneuverability.powerToWeight'}
onClick={() => {
$tankopediaSort.setKey('by', 'maneuverability.powerToWeight');
}}
>
Power to weight
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'maneuverability.weight'}
onClick={() => {
$tankopediaSort.setKey('by', 'maneuverability.weight');
}}
>
Weight
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'maneuverability.traverseSpeed'}
onClick={() => {
$tankopediaSort.setKey('by', 'maneuverability.traverseSpeed');
}}
>
Traverse speed
</DropdownMenu.CheckboxItem>
<Item by="maneuverability.forwardsSpeed" />
<Item by="maneuverability.backwardsSpeed" />
<Item by="maneuverability.power" />
<Item by="maneuverability.powerToWeight" />
<Item by="maneuverability.weight" />
<Item by="maneuverability.traverseSpeed" />
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Survivability</DropdownMenu.SubTrigger>
<DropdownMenu.SubTrigger>
{
strings.website.common.tank_search.sort_dropdown.groups.sort
.survivability
}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'survivability.health'}
onClick={() => {
$tankopediaSort.setKey('by', 'survivability.health');
}}
>
Health
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'survivability.viewRange'}
onClick={() => {
$tankopediaSort.setKey('by', 'survivability.viewRange');
}}
>
View range
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'survivability.camouflageStill'}
onClick={() => {
$tankopediaSort.setKey('by', 'survivability.camouflageStill');
}}
>
Camouflage
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'survivability.camouflageMoving'}
onClick={() => {
$tankopediaSort.setKey('by', 'survivability.camouflageMoving');
}}
>
Camouflage moving
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'survivability.camouflageShooting'}
onClick={() => {
$tankopediaSort.setKey(
'by',
'survivability.camouflageShooting',
);
}}
>
Camouflage shooting
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'survivability.volume'}
onClick={() => {
$tankopediaSort.setKey('by', 'survivability.volume');
}}
>
Volume
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={tankopediaSort.by === 'survivability.length'}
onClick={() => {
$tankopediaSort.setKey('by', 'survivability.length');
}}
>
Length
</DropdownMenu.CheckboxItem>
<Item by="survivability.health" />
<Item by="survivability.viewRange" />
<Item by="survivability.camouflageStill" />
<Item by="survivability.camouflageMoving" />
<Item by="survivability.camouflageShooting" />
<Item by="survivability.volume" />
<Item by="survivability.length" />
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Label>Direction</DropdownMenu.Label>
<DropdownMenu.Label>
{
strings.website.common.tank_search.sort_dropdown.groups.direction
.label
}
</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={tankopediaSort.direction}
onValueChange={(value) => {
@@ -313,10 +127,16 @@ export function Sort() {
}}
>
<DropdownMenu.RadioItem value="ascending">
Ascending
{
strings.website.common.tank_search.sort_dropdown.groups.direction
.ascending
}
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="descending">
Descending
{
strings.website.common.tank_search.sort_dropdown.groups.direction
.descending
}
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
@@ -2,9 +2,11 @@ import {
metaSortTank,
normalizeBoundingBox,
resolveDpm,
SEARCH_KEYS,
unionBoundingBox,
type TankDefinition,
} from '@blitzkit/core';
import { literals } from '@blitzkit/i18n/src/literals';
import { useStore } from '@nanostores/react';
import { Callout, Flex, Link, Text, type FlexProps } from '@radix-ui/themes';
import fuzzysort from 'fuzzysort';
@@ -16,12 +18,10 @@ import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions'
import { awaitableTankNames } from '../../core/awaitables/tankNames';
import { filterTank } from '../../core/blitzkit/filterTank';
import { resolveReload } from '../../core/blitzkit/resolveReload';
import { useLocale } from '../../hooks/useLocale';
import { $tankFilters } from '../../stores/tankFilters';
import { TankopediaPersistent } from '../../stores/tankopediaPersistent';
import {
SORT_NAMES,
SORT_UNITS,
} from '../../stores/tankopediaPersistent/constants';
import { SORT_UNITS } from '../../stores/tankopediaPersistent/constants';
import { $tankopediaSort } from '../../stores/tankopediaSort';
import { ExperimentIcon } from '../ExperimentIcon';
import { TankSearchCard } from './components/Card';
@@ -52,6 +52,7 @@ const [gameDefinitions, modelDefinitions, tankDefinitions, tankNames] =
export const TankSearch = memo<TankSearchProps>(
({ compact, onSelect, onSelectAll, ...props }) => {
const { strings, locale } = useLocale();
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const awaitedTanksDefinitionsArray = Object.values(tankDefinitions.tanks);
const tankFilters = useStore($tankFilters);
@@ -410,7 +411,7 @@ export const TankSearch = memo<TankSearchProps>(
: sorted.reverse();
} else {
const searchedRaw = fuzzysort.go(tankFilters.search, tankNames, {
keys: ['searchableName', 'searchableNameDeburr', 'camouflages'],
keys: SEARCH_KEYS,
});
const searchedTanks = searchedRaw.map(
(result) => tankDefinitions.tanks[result.obj.id],
@@ -438,8 +439,11 @@ export const TankSearch = memo<TankSearchProps>(
<Flex mt="2" gap="1" align="center" justify="center" direction="column">
<Flex gap="2">
<Text color="gray">
{tanksFiltered.length} tank
{tanksFiltered.length === 1 ? '' : 's'}
{tanksFiltered.length === 1
? strings.website.common.tank_search.count_singular
: literals(strings.website.common.tank_search.count_plural, [
`${tanksFiltered.length.toLocaleString(locale)}`,
])}
</Text>
{onSelectAll && (
@@ -453,18 +457,22 @@ export const TankSearch = memo<TankSearchProps>(
draft.recentlyViewed = uniq([
...tanksFiltered.map(({ id }) => id),
...draft.recentlyViewed,
]).slice(0, MAX_RECENTLY_VIEWED);
])
.filter((id) => id in tankDefinitions.tanks)
.slice(0, MAX_RECENTLY_VIEWED);
});
}}
>
Select all
{strings.website.common.tank_search.select_all}
</Link>
)}
</Flex>
{tankopediaSort.by !== 'meta.none' && (
<Text color="gray">
Sorting by {SORT_NAMES[tankopediaSort.by]}
{literals(strings.website.common.tank_search.sorting_by, [
strings.website.common.tank_search.sort[tankopediaSort.by],
])}
{SORT_UNITS[tankopediaSort.by] === undefined
? ''
: ` (${SORT_UNITS[tankopediaSort.by]})`}
@@ -480,8 +488,7 @@ export const TankSearch = memo<TankSearchProps>(
<ExperimentIcon style={{ width: '1em', height: '1em' }} />
</Callout.Icon>
<Callout.Text>
Tanks in testing are subject to change and may not represent the
final product.
{strings.website.common.warnings.test}
</Callout.Text>
</Callout.Root>
</Flex>
@@ -1,4 +1,4 @@
import { assertSecret } from '@blitzkit/core';
import { assertSecret, fetchTankDefinitions } from '@blitzkit/core';
import { MinusCircledIcon } from '@radix-ui/react-icons';
import { Callout, Flex } from '@radix-ui/themes';
import { uniq } from 'lodash-es';
@@ -9,6 +9,8 @@ import { AesonPlug } from '../AesonPlug';
import { ExperimentIcon } from '../ExperimentIcon';
import { MAX_RECENTLY_VIEWED } from '../TankSearch/constants';
const tankDefinitions = await fetchTankDefinitions();
export function CalloutsSection() {
const tank = Duel.use((state) => state.protagonist.tank);
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
@@ -17,10 +19,9 @@ export function CalloutsSection() {
useEffect(() => {
mutateTankopediaPersistent((draft) => {
draft.recentlyViewed = uniq([tank.id, ...draft.recentlyViewed]).slice(
0,
MAX_RECENTLY_VIEWED,
);
draft.recentlyViewed = uniq([tank.id, ...draft.recentlyViewed])
.filter((id) => id in tankDefinitions.tanks)
.slice(0, MAX_RECENTLY_VIEWED);
});
}, [tank]);
@@ -1,6 +1,7 @@
import { TIER_ROMAN_NUMERALS } from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en.json';
import { literals } from '@blitzkit/i18n/src/literals';
import { Flex, Heading, SegmentedControl, Text } from '@radix-ui/themes';
import { useLocale } from '../../../../../../hooks/useLocale';
import { Duel } from '../../../../../../stores/duel';
import {
TankopediaEphemeral,
@@ -13,6 +14,7 @@ export function CharacteristicsHeading() {
);
const tank = Duel.use((state) => state.protagonist.tank);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const { strings } = useLocale();
return (
<Flex
@@ -20,9 +22,13 @@ export function CharacteristicsHeading() {
gap="2"
direction="column"
>
<Heading mb="2">Characteristics</Heading>
<Heading mb="2">
{strings.website.tools.tankopedia.characteristics.title}
</Heading>
<Text color="gray">Compare against</Text>
<Text color="gray">
{strings.website.tools.tankopedia.characteristics.compare.label}
</Text>
<SegmentedControl.Root
size={{ initial: '1', md: '2' }}
value={`${relativeAgainst}`}
@@ -33,14 +39,22 @@ export function CharacteristicsHeading() {
}}
>
<SegmentedControl.Item value={`${TankopediaRelativeAgainst.Class}`}>
Tier {TIER_ROMAN_NUMERALS[tank.tier]}{' '}
{strings.common.tank_class_short_plural_lowercase[tank.class]}
{literals(
strings.website.tools.tankopedia.characteristics.compare.class,
[
TIER_ROMAN_NUMERALS[tank.tier],
strings.common.tank_class_short_plural_lowercase[tank.class],
],
)}
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaRelativeAgainst.Tier}`}>
All of tier {TIER_ROMAN_NUMERALS[tank.tier]}
{literals(
strings.website.tools.tankopedia.characteristics.compare.tier,
[TIER_ROMAN_NUMERALS[tank.tier]],
)}
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaRelativeAgainst.All}`}>
All tanks
{strings.website.tools.tankopedia.characteristics.compare.all}
</SegmentedControl.Item>
</SegmentedControl.Root>
</Flex>
@@ -1,6 +1,6 @@
import { createDefaultSkills } from '@blitzkit/core';
import { Flex, Progress, Text } from '@radix-ui/themes';
import { useMemo, type ComponentProps } from 'react';
import { useMemo, type ComponentProps, type ReactNode } from 'react';
import { awaitableEquipmentDefinitions } from '../../../../../../core/awaitables/equipmentDefinitions';
import { awaitableModelDefinitions } from '../../../../../../core/awaitables/modelDefinitions';
import { awaitableProvisionDefinitions } from '../../../../../../core/awaitables/provisionDefinitions';
@@ -12,6 +12,7 @@ import {
} from '../../../../../../core/blitzkit/tankCharacteristics';
import { tankToDuelMember } from '../../../../../../core/blitzkit/tankToDuelMember';
import { useDelta } from '../../../../../../hooks/useDelta';
import { useLocale } from '../../../../../../hooks/useLocale';
import { Duel } from '../../../../../../stores/duel';
import {
TankopediaEphemeral,
@@ -19,13 +20,18 @@ import {
} from '../../../../../../stores/tankopediaEphemeral';
import { Info, type InfoProps } from './Info';
interface InfoWithDeltaProps extends InfoProps {
type InfoWithDeltaProps = Omit<InfoProps, 'name'> & {
stats: TankCharacteristics;
value:
| keyof TankCharacteristics
| ((tank: TankCharacteristics) => number | undefined);
noRanking?: boolean;
}
} & (
| {
value: keyof TankCharacteristics;
}
| {
value: (tank: TankCharacteristics) => number | undefined;
name: ReactNode;
}
);
const [
tankDefinitions,
@@ -42,18 +48,20 @@ const [
]);
export function InfoWithDelta({
value,
stats,
indent,
noRanking,
deltaType,
...props
}: InfoWithDeltaProps) {
const { strings } = useLocale();
const relativeAgainst = TankopediaEphemeral.use(
(state) => state.relativeAgainst,
);
const uhWhatDoICallThisVariable =
typeof value === 'function' ? value(stats)! : (stats[value] as number);
typeof props.value === 'function'
? props.value(stats)!
: (stats[props.value] as number);
const delta = useDelta(uhWhatDoICallThisVariable);
const protagonistTank = Duel.use((state) => state.protagonist.tank);
const others = useMemo(() => {
@@ -102,14 +110,18 @@ export function InfoWithDelta({
})
.filter((tank) => {
const othersValue =
typeof value === 'function' ? value(tank)! : (tank[value] as number);
typeof props.value === 'function'
? props.value(tank)!
: (tank[props.value] as number);
return othersValue !== undefined;
});
}, [relativeAgainst]);
const betterTanks = others.filter((tank) => {
const othersValue =
typeof value === 'function' ? value(tank)! : (tank[value] as number);
typeof props.value === 'function'
? props.value(tank)!
: (tank[props.value] as number);
if (othersValue === undefined) return false;
return deltaType === 'lowerIsBetter'
@@ -126,7 +138,19 @@ export function InfoWithDelta({
return (
<Flex direction="column">
<Info deltaType={deltaType} indent={indent} {...props} delta={delta}>
<Info
deltaType={deltaType}
indent={indent}
{...props}
name={
'name' in props
? props.name
: strings.website.tools.tankopedia.characteristics.values[
props.value
]
}
delta={delta}
>
{uhWhatDoICallThisVariable}
</Info>
@@ -3,10 +3,10 @@ import {
coefficient,
CREW_MEMBER_NAMES,
CrewType,
GUN_TYPE_NAMES,
isExplosive,
resolvePenetrationCoefficient,
} from '@blitzkit/core';
import { literals } from '@blitzkit/i18n/src/literals';
import { AccessibilityIcon, InfoCircledIcon } from '@radix-ui/react-icons';
import {
Flex,
@@ -27,6 +27,7 @@ import { awaitableProvisionDefinitions } from '../../../../../core/awaitables/pr
import { applyPitchYawLimits } from '../../../../../core/blitz/applyPitchYawLimits';
import { tankCharacteristics } from '../../../../../core/blitzkit/tankCharacteristics';
import { useEquipment } from '../../../../../hooks/useEquipment';
import { useLocale } from '../../../../../hooks/useLocale';
import { useTankModelDefinition } from '../../../../../hooks/useTankModelDefinition';
import { Duel } from '../../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../../stores/tankopediaEphemeral';
@@ -39,6 +40,7 @@ const [equipmentDefinitions, provisionDefinitions] = await Promise.all([
]);
export function Characteristics() {
const { unwrap, strings } = useLocale();
const mutateDuel = Duel.useMutation();
const crewSkills = TankopediaEphemeral.use((state) => state.skills);
const penetrationDistanceInput = useRef<HTMLInputElement>(null);
@@ -105,6 +107,7 @@ export function Characteristics() {
const hasSupercharger = useEquipment(107);
const hasCalibratedShells = useEquipment(103);
const hasImprovedVerticalStabilizer = useEquipment(122);
const hasDownImprovedVerticalStabilizer = useEquipment(124);
const penetrationCoefficient = coefficient([
hasCalibratedShells,
resolvePenetrationCoefficient(true, shell.type) - 1,
@@ -127,9 +130,10 @@ export function Characteristics() {
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
});
}, [hasImprovedVerticalStabilizer]);
}, [hasImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer]);
return (
<Flex
@@ -142,11 +146,13 @@ export function Characteristics() {
<Flex direction="column" gap="8" style={{ flex: 1 }}>
<Flex direction="column" gap="2">
<Flex align="center" gap="4">
<Heading size="5">Firepower</Heading>
<Heading size="5">
{strings.website.tools.tankopedia.firepower.title}
</Heading>
<Flex>
{gun.gun_type!.value.base.shells.map((thisShell, shellIndex) => (
<Tooltip content={thisShell.name} key={thisShell.id}>
<Tooltip content={unwrap(thisShell.name)} key={thisShell.id}>
<IconButton
color={thisShell.id === shell.id ? undefined : 'gray'}
variant="soft"
@@ -172,7 +178,7 @@ export function Characteristics() {
}}
>
<img
alt={thisShell.name}
alt={unwrap(thisShell.name)}
width={16}
height={16}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
@@ -182,10 +188,15 @@ export function Characteristics() {
))}
</Flex>
</Flex>
<Info name="Gun type">{GUN_TYPE_NAMES[gun.gun_type!.$case]}</Info>
<Info
name={
strings.website.tools.tankopedia.characteristics.values.gunType
}
>
{strings.common.gun_types[gun.gun_type!.$case]}
</Info>
<InfoWithDelta
stats={stats}
name="DPM"
decimals={0}
unit="hp / min"
value="dpm"
@@ -195,26 +206,41 @@ export function Characteristics() {
stats={stats}
decimals={0}
indent
name="Effective at 60s"
unit="hp / min"
value="dpmEffective"
/>
)}
{gun.gun_type!.$case !== 'regular' && (
<InfoWithDelta stats={stats} name="Shells" value="shells" />
<InfoWithDelta stats={stats} value="shells" />
)}
{gun.gun_type!.$case === 'auto_reloader' ? (
<>
<Info indent name="Most optimal shell index">
<Info
indent
name={
strings.website.tools.tankopedia.characteristics.values
.mostOptimalShellIndex
}
>
{stats.mostOptimalShellIndex! + 1}
</Info>
<Info name="Shell reloads" unit="s" />
<Info
name={
strings.website.tools.tankopedia.characteristics.values
.shellReloads
}
unit="s"
/>
{stats.shellReloads!.map((reload, index) => (
<InfoWithDelta
stats={stats}
key={index}
indent
name={`Shell ${index + 1}`}
name={literals(
strings.website.tools.tankopedia.characteristics.values
.shell_index,
[`${index + 1}`],
)}
decimals={2}
deltaType="lowerIsBetter"
noRanking
@@ -226,7 +252,6 @@ export function Characteristics() {
<InfoWithDelta
stats={stats}
decimals={2}
name="Reload"
unit="s"
deltaType="lowerIsBetter"
value="shellReload"
@@ -238,7 +263,6 @@ export function Characteristics() {
stats={stats}
indent
decimals={2}
name="Intra-clip"
unit="s"
deltaType="lowerIsBetter"
value="intraClip"
@@ -247,7 +271,6 @@ export function Characteristics() {
<InfoWithDelta
stats={stats}
decimals={0}
name="Penetration"
unit="mm"
value="penetration"
/>
@@ -257,7 +280,11 @@ export function Characteristics() {
stats={stats}
indent
decimals={0}
name={`At ${penetrationDistance}m`}
name={literals(
strings.website.tools.tankopedia.characteristics.values
.at_distance,
[`${penetrationDistance}`],
)}
noRanking
value={() =>
lerp(
@@ -270,7 +297,12 @@ export function Characteristics() {
}
/>
<Flex align="center" gap="2" style={{ paddingLeft: 24 }}>
<Text color="gray">Distance</Text>
<Text color="gray">
{
strings.website.tools.tankopedia.characteristics.values
.at_distance_slider_label
}
</Text>
<Slider
key={penetrationDistance}
min={0}
@@ -308,17 +340,10 @@ export function Characteristics() {
</Flex>
</>
)}
<InfoWithDelta
stats={stats}
name="Damage"
unit="hp"
decimals={0}
value="damage"
/>
<InfoWithDelta stats={stats} unit="hp" decimals={0} value="damage" />
{gun.gun_type!.$case !== 'regular' && (
<InfoWithDelta
stats={stats}
name="Clipping potential"
indent
decimals={0}
value="clipDamage"
@@ -326,7 +351,6 @@ export function Characteristics() {
)}
<InfoWithDelta
stats={stats}
name="Module damage"
unit="hp"
decimals={0}
value="moduleDamage"
@@ -334,23 +358,15 @@ export function Characteristics() {
{isExplosive(shell.type) && (
<InfoWithDelta
stats={stats}
name="Splash radius"
unit="m"
noRanking
decimals={0}
value="explosionRadius"
/>
)}
<InfoWithDelta stats={stats} decimals={0} unit="mm" value="caliber" />
<InfoWithDelta
stats={stats}
name="Caliber"
decimals={0}
unit="mm"
value="caliber"
/>
<InfoWithDelta
stats={stats}
name="Normalization"
decimals={0}
unit="°"
noRanking
@@ -359,7 +375,6 @@ export function Characteristics() {
{!isExplosive(shell.type) && (
<InfoWithDelta
stats={stats}
name="Ricochet"
noRanking
decimals={0}
deltaType="lowerIsBetter"
@@ -369,39 +384,35 @@ export function Characteristics() {
)}
<InfoWithDelta
stats={stats}
name="Shell velocity"
unit="m/s"
decimals={0}
value="shellVelocity"
/>
<InfoWithDelta
stats={stats}
name="Shell range"
unit="m/s"
decimals={0}
value="shellRange"
/>
<InfoWithDelta
stats={stats}
name="Shell capacity"
unit="m/s"
decimals={0}
value="shellCapacity"
/>
<InfoWithDelta stats={stats} decimals={0} value="shellCapacity" />
<InfoWithDelta
stats={stats}
decimals={2}
name="Aim time"
unit="s"
deltaType="lowerIsBetter"
value="aimTime"
/>
<Info name="Dispersion at 100m" />
<Info
name={literals(
strings.website.tools.tankopedia.characteristics.values
.dispersion_at_distance,
['100'],
)}
/>
<InfoWithDelta
stats={stats}
decimals={3}
indent
name="Still"
unit="m"
deltaType="lowerIsBetter"
value="dispersion"
@@ -411,7 +422,6 @@ export function Characteristics() {
prefix="+"
decimals={3}
indent
name="Moving"
deltaType="lowerIsBetter"
value="dispersionMoving"
/>
@@ -420,7 +430,6 @@ export function Characteristics() {
decimals={3}
prefix="+"
indent
name="Hull traversing"
deltaType="lowerIsBetter"
value="dispersionHullTraversing"
/>
@@ -429,7 +438,6 @@ export function Characteristics() {
decimals={3}
prefix="+"
indent
name="Turret traversing"
deltaType="lowerIsBetter"
value="dispersionTurretTraversing"
/>
@@ -438,7 +446,6 @@ export function Characteristics() {
decimals={3}
prefix="+"
indent
name="After shooting"
deltaType="lowerIsBetter"
value="dispersionShooting"
/>
@@ -447,24 +454,26 @@ export function Characteristics() {
decimals={1}
prefix="x "
indent
name="Gun damaged"
unit="scalar"
deltaType="lowerIsBetter"
value="dispersionGunDamaged"
/>
<Info name="Gun flexibility" unit="°" />
<Info
name={
strings.website.tools.tankopedia.characteristics.values
.gun_flexibility
}
unit="°"
/>
<InfoWithDelta
value="gunDepression"
stats={stats}
decimals={1}
indent
name="Depression"
/>
<InfoWithDelta
stats={stats}
decimals={1}
indent
name="Elevation"
value="gunElevation"
/>
{gunModelDefinition.pitch.front && (
@@ -473,14 +482,12 @@ export function Characteristics() {
stats={stats}
decimals={1}
indent
name="Frontal depression"
value="gunFrontalDepression"
/>
<InfoWithDelta
stats={stats}
decimals={1}
indent
name="Frontal elevation"
value="gunFrontalElevation"
/>
</>
@@ -491,14 +498,12 @@ export function Characteristics() {
stats={stats}
decimals={1}
indent
name="Rear depression"
value="gunRearDepression"
/>
<InfoWithDelta
stats={stats}
decimals={1}
indent
name="Rear elevation"
value="gunRearElevation"
/>
</>
@@ -509,14 +514,12 @@ export function Characteristics() {
stats={stats}
decimals={1}
indent
name="Azimuth left"
value="azimuthLeft"
/>
<InfoWithDelta
stats={stats}
decimals={1}
indent
name="Azimuth right"
value="azimuthRight"
/>
</>
@@ -525,7 +528,9 @@ export function Characteristics() {
<Flex direction="column" gap="2">
<Flex gap="2" align="center">
<Heading size="5">Crew training</Heading>
<Heading size="5">
{strings.website.tools.tankopedia.crew.title}
</Heading>
<Popover.Root>
<Popover.Trigger>
@@ -537,20 +542,13 @@ export function Characteristics() {
<Popover.Content>
<Flex gap="1" align="center">
<AccessibilityIcon />
<Text>
represents the other roles a crew member can take.
</Text>
<Text>{strings.website.tools.tankopedia.crew.info}</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
<InfoWithDelta
value="crewCount"
stats={stats}
name="Crew count"
decimals={0}
/>
<InfoWithDelta value="crewCount" stats={stats} decimals={0} />
{tank.crew.map((member) => {
return (
@@ -558,8 +556,16 @@ export function Characteristics() {
<InfoWithDelta
stats={stats}
key={`${member.type}-root`}
name={`${CREW_MEMBER_NAMES[member.type]}${
member.count > 1 ? ` x ${member.count}` : ''
name={`${
strings.website.tools.tankopedia.crew[
CREW_MEMBER_NAMES[member.type]
]
}${
member.count > 1
? ` ${literals(strings.common.units.x, [
`${member.count}`,
])}`
: ''
}`}
unit="%"
decimals={0}
@@ -584,7 +590,11 @@ export function Characteristics() {
<AccessibilityIcon />
{member.substitute
.map((sub, index) =>
index === 0 ? CREW_MEMBER_NAMES[sub] : sub,
index === 0
? strings.website.tools.tankopedia.crew[
CREW_MEMBER_NAMES[sub]
]
: sub,
)
.join(', ')}
</Flex>
@@ -602,52 +612,51 @@ export function Characteristics() {
<Flex direction="column" gap="8" style={{ flex: 1 }}>
<Flex direction="column" gap="2">
<Heading size="5">Maneuverability</Heading>
<Info name="Speed" unit="kph" />
<Heading size="5">
{strings.website.tools.tankopedia.maneuverability.title}
</Heading>
<Info
name={strings.website.tools.tankopedia.characteristics.values.speed}
unit="kph"
/>
<InfoWithDelta
value="speedForwards"
stats={stats}
decimals={0}
indent
name="Forwards"
/>
<InfoWithDelta
value="speedBackwards"
stats={stats}
decimals={0}
indent
name="Backwards"
/>
<InfoWithDelta
stats={stats}
decimals={0}
name="Engine power"
unit="hp"
value="enginePower"
/>
<InfoWithDelta stats={stats} decimals={0} value="enginePower" />
<InfoWithDelta
stats={stats}
decimals={1}
name="Weight"
unit="tn"
deltaType="lowerIsBetter"
value="weight"
/>
<Info name="Terrain coefficients" />
<Info
name={
strings.website.tools.tankopedia.characteristics.values
.terrain_coefficients
}
/>
<InfoWithDelta
value="hardTerrainCoefficient"
stats={stats}
decimals={0}
unit="%"
indent
name="Hard"
/>
<InfoWithDelta
stats={stats}
decimals={0}
unit="%"
indent
name="Medium"
value="mediumTerrainCoefficient"
/>
<InfoWithDelta
@@ -656,9 +665,14 @@ export function Characteristics() {
decimals={0}
unit="%"
indent
name="Soft"
/>
<Info name="Raw terrain coefficients" deltaType="lowerIsBetter" />
<Info
name={
strings.website.tools.tankopedia.characteristics.values
.raw_terrain_coefficients
}
deltaType="lowerIsBetter"
/>
<InfoWithDelta
value="hardTerrainCoefficientRaw"
stats={stats}
@@ -666,14 +680,12 @@ export function Characteristics() {
unit="%"
indent
deltaType="lowerIsBetter"
name="Hard"
/>
<InfoWithDelta
stats={stats}
decimals={2}
unit="%"
indent
name="Medium"
deltaType="lowerIsBetter"
value="mediumTerrainCoefficientRaw"
/>
@@ -682,75 +694,77 @@ export function Characteristics() {
decimals={2}
unit="%"
indent
name="Soft"
deltaType="lowerIsBetter"
value="softTerrainCoefficientRaw"
/>
<Info name="Power to weight ratio" unit="hp/tn" />
<Info
name={
strings.website.tools.tankopedia.characteristics.values
.power_to_weight_ratio
}
unit="hp/tn"
/>
<InfoWithDelta
stats={stats}
decimals={1}
indent
name="On hard terrain"
value="powerToWeightRatioHardTerrain"
/>
<InfoWithDelta
stats={stats}
decimals={1}
indent
name="On medium terrain"
value="powerToWeightRatioMediumTerrain"
/>
<InfoWithDelta
stats={stats}
decimals={1}
indent
name="On soft terrain"
value="powerToWeightRatioSoftTerrain"
/>
<InfoWithDelta
stats={stats}
name="Turret traverse speed"
unit="°/s"
decimals={1}
value="turretTraverseSpeed"
/>
<Info name="Hull traverse speed" unit="°/s" />
<Info
name={
strings.website.tools.tankopedia.characteristics.values
.hull_traverse_speed
}
unit="°/s"
/>
<InfoWithDelta
stats={stats}
decimals={1}
indent
name="On hard terrain"
value="hullTraverseHardTerrain"
/>
<InfoWithDelta
stats={stats}
decimals={1}
indent
name="On medium terrain"
value="hullTraverseMediumTerrain"
/>
<InfoWithDelta
stats={stats}
decimals={1}
indent
name="On soft terrain"
value="hullTraverseSoftTerrain"
/>
</Flex>
<Flex direction="column" gap="2">
<Heading size="5">Survivability</Heading>
<InfoWithDelta
value="health"
stats={stats}
name="Health"
unit="hp"
decimals={0}
/>
<Heading size="5">
{strings.website.tools.tankopedia.survivability.title}
</Heading>
<InfoWithDelta value="health" stats={stats} unit="hp" decimals={0} />
<InfoWithDelta
stats={stats}
name="Fire chance"
name={
strings.website.tools.tankopedia.characteristics.values.fireChance
}
unit="%"
deltaType="lowerIsBetter"
decimals={0}
@@ -759,48 +773,66 @@ export function Characteristics() {
<InfoWithDelta
value="viewRange"
stats={stats}
name="View range"
unit="m"
decimals={0}
/>
<Info name="Camouflage" unit="%" />
<Info
name={
strings.website.tools.tankopedia.characteristics.values.camouflage
}
unit="%"
/>
<InfoWithDelta
value={(stats) => stats.camouflageStill * 100}
stats={stats}
indent
name="Still"
name={
strings.website.tools.tankopedia.characteristics.values
.camouflageStill
}
decimals={2}
/>
<InfoWithDelta
value={(stats) => stats.camouflageMoving * 100}
stats={stats}
indent
name="Moving"
name={
strings.website.tools.tankopedia.characteristics.values
.camouflageMoving
}
decimals={2}
/>
<InfoWithDelta
stats={stats}
indent
name="Shooting still"
name={
strings.website.tools.tankopedia.characteristics.values
.camouflageShootingStill
}
decimals={2}
value={(stats) => stats.camouflageShootingStill * 100}
/>
<InfoWithDelta
stats={stats}
indent
name="Shooting moving"
name={
strings.website.tools.tankopedia.characteristics.values
.camouflageShootingMoving
}
decimals={2}
value={(stats) => stats.camouflageShootingMoving * 100}
/>
<InfoWithDelta
stats={stats}
indent
name="Caught on fire"
name={
strings.website.tools.tankopedia.characteristics.values
.camouflageCaughtOnFire
}
decimals={2}
value={(stats) => stats.camouflageCaughtOnFire * 100}
/>
<InfoWithDelta
name="Width"
unit="m"
decimals={0}
deltaType="lowerIsBetter"
@@ -808,7 +840,6 @@ export function Characteristics() {
value="width"
/>
<InfoWithDelta
name="Height"
unit="m"
decimals={0}
deltaType="lowerIsBetter"
@@ -816,7 +847,6 @@ export function Characteristics() {
value="height"
/>
<InfoWithDelta
name="Length"
unit="m"
decimals={0}
deltaType="lowerIsBetter"
@@ -824,7 +854,6 @@ export function Characteristics() {
value="length"
/>
<InfoWithDelta
name="Volume"
unit="m"
decimals={0}
deltaType="lowerIsBetter"
@@ -10,6 +10,7 @@ import {
} from '@radix-ui/themes';
import { awaitableConsumableDefinitions } from '../../../../core/awaitables/consumableDefinitions';
import { useEquipment } from '../../../../hooks/useEquipment';
import { useLocale } from '../../../../hooks/useLocale';
import { Duel } from '../../../../stores/duel';
import { ConsumablesManager } from '../../../ConsumablesManager';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
@@ -34,12 +35,15 @@ export function Consumables() {
);
const hasConsumableDeliverySystem = useEquipment(118);
const hasHighEndConsumables = useEquipment(101);
const { strings } = useLocale();
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Flex gap="2" align="center">
<Heading size="4">Consumables</Heading>
<Heading size="4">
{strings.website.tools.tankopedia.configuration.consumables.title}
</Heading>
<Popover.Root>
<Popover.Trigger>
@@ -50,8 +54,10 @@ export function Consumables() {
<Popover.Content>
<Text>
<Text color="amber">Yellow consumables</Text> do not affect the
statistics.
{
strings.website.tools.tankopedia.configuration.consumables
.info
}
</Text>
</Popover.Content>
</Popover.Root>
@@ -66,7 +72,7 @@ export function Consumables() {
});
}}
>
Clear
{strings.website.tools.tankopedia.configuration.consumables.clear}
</Button>
</Flex>
@@ -1,5 +1,6 @@
import { Button, Flex, Heading } from '@radix-ui/themes';
import { awaitableEquipmentDefinitions } from '../../../../core/awaitables/equipmentDefinitions';
import { useLocale } from '../../../../hooks/useLocale';
import { Duel } from '../../../../stores/duel';
import { EquipmentManager } from '../../../EquipmentManager';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
@@ -14,11 +15,14 @@ export function Equipment() {
const equipmentMatrix = Duel.use(
(state) => state.protagonist.equipmentMatrix,
);
const { strings } = useLocale();
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Heading size="4">Equipment</Heading>
<Heading size="4">
{strings.website.tools.tankopedia.configuration.equipment.title}
</Heading>
<Button
variant="ghost"
color="red"
@@ -32,7 +36,7 @@ export function Equipment() {
});
}}
>
Clear
{strings.website.tools.tankopedia.configuration.equipment.clear}
</Button>
</Flex>
@@ -1,5 +1,6 @@
import { asset } from '@blitzkit/core';
import { Flex, Heading } from '@radix-ui/themes';
import { useLocale } from '../../../../hooks/useLocale';
import { Duel } from '../../../../stores/duel';
import { GenericTankComponentButton } from '../../../ModuleButtons/GenericTankComponentButton';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
@@ -11,10 +12,13 @@ export function Miscellaneous() {
(state) => state.protagonist.cooldownBooster,
);
const { tank } = Duel.use((state) => state.protagonist);
const { strings } = useLocale();
return (
<ConfigurationChildWrapper>
<Heading size="4">Miscellaneous</Heading>
<Heading size="4">
{strings.website.tools.tankopedia.configuration.miscellaneous.title}
</Heading>
<Flex gap="2" align="center">
{!tank.fixed_camouflage && (
@@ -17,11 +17,12 @@ import {
Flex,
Heading,
IconButton,
Link,
Text,
} from '@radix-ui/themes';
import { awaitableTankDefinitions } from '../../../../core/awaitables/tankDefinitions';
import { useLocale } from '../../../../hooks/useLocale';
import { Duel } from '../../../../stores/duel';
import { LinkI18n } from '../../../LinkI18n';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
type ModuleDefinition =
@@ -63,6 +64,8 @@ function ModuleButton({
onClick: () => void;
}) {
const isTank = unlock.type === ModuleType.VEHICLE;
const { locale } = useLocale();
const button = (
<IconButton
size="4"
@@ -100,7 +103,7 @@ function ModuleButton({
gap={isTank ? '1' : undefined}
>
<Text size="1" color="gray">
{formatCompact(unlock.cost.value)}
{formatCompact(locale, unlock.cost.value)}
</Text>
<img
@@ -132,7 +135,9 @@ function ModuleButton({
);
return isTank ? (
<Link href={`/tools/tankopedia/${unlock.id}`}>{button}</Link>
<LinkI18n locale={locale} href={`/tools/tankopedia/${unlock.id}`}>
{button}
</LinkI18n>
) : (
button
);
@@ -160,6 +165,7 @@ export function Modules() {
const topGun = topTurret.guns.at(-1)!;
const topEngine = tank.engines.at(-1)!;
const topTrack = tank.tracks.at(-1)!;
const { strings } = useLocale();
function setByUnlock(unlock: Unlock) {
mutateDuel((draft) => {
@@ -349,7 +355,9 @@ export function Modules() {
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Heading size="4">Modules</Heading>
<Heading size="4">
{strings.website.tools.tankopedia.configuration.modules.title}
</Heading>
{hasUpgrades && (
<>
@@ -367,7 +375,7 @@ export function Modules() {
});
}}
>
Stock
{strings.website.tools.tankopedia.configuration.modules.stock}
</Button>
<Button
variant="ghost"
@@ -385,7 +393,7 @@ export function Modules() {
});
}}
>
Upgrade
{strings.website.tools.tankopedia.configuration.modules.upgrade}
</Button>
</>
)}
@@ -1,6 +1,7 @@
import { availableProvisions } from '@blitzkit/core';
import { Button, Flex, Heading } from '@radix-ui/themes';
import { awaitableProvisionDefinitions } from '../../../../core/awaitables/provisionDefinitions';
import { useLocale } from '../../../../hooks/useLocale';
import { Duel } from '../../../../stores/duel';
import { ProvisionsManager } from '../../../ProvisionsManager';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
@@ -12,11 +13,14 @@ export function Provisions() {
const { tank, gun } = Duel.use((state) => state.protagonist);
const provisions = Duel.use((state) => state.protagonist.provisions);
const provisionsList = availableProvisions(tank, gun, provisionDefinitions);
const { strings } = useLocale();
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Heading size="4">Provisions</Heading>
<Heading size="4">
{strings.website.tools.tankopedia.configuration.provisions.title}
</Heading>
<Button
variant="ghost"
color="red"
@@ -26,7 +30,7 @@ export function Provisions() {
});
}}
>
Clear
{strings.website.tools.tankopedia.configuration.provisions.clear}
</Button>
</Flex>
@@ -4,10 +4,10 @@ import {
Flex,
Heading,
IconButton,
Kbd,
Popover,
Text,
} from '@radix-ui/themes';
import { useLocale } from '../../../../hooks/useLocale';
import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral';
import { CrewSkillManager } from '../../../CrewSkillManager';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
@@ -15,12 +15,15 @@ import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Skills() {
const skillLevels = TankopediaEphemeral.use((state) => state.skills);
const mutateTankopediaTemporary = TankopediaEphemeral.useMutation();
const { strings } = useLocale();
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Flex gap="2" align="center">
<Heading size="4">Crew skills</Heading>
<Heading size="4">
{strings.website.tools.tankopedia.configuration.skills.title}
</Heading>
<Popover.Root>
<Popover.Trigger>
@@ -32,12 +35,7 @@ export function Skills() {
<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.
{strings.website.tools.tankopedia.configuration.skills.info}
</Text>
</Flex>
</Popover.Content>
@@ -55,7 +53,7 @@ export function Skills() {
});
}}
>
Clear
{strings.website.tools.tankopedia.configuration.skills.clear}
</Button>
<Button
variant="ghost"
@@ -67,7 +65,7 @@ export function Skills() {
});
}}
>
Maximize
{strings.website.tools.tankopedia.configuration.skills.maximize}
</Button>
</Flex>
@@ -1,4 +1,5 @@
import { Flex, Heading } from '@radix-ui/themes';
import { useLocale } from '../../../hooks/useLocale';
import { Characteristics } from './components/Characteristics';
import { CharacteristicsHeading } from './components/Characteristics/components/Heading';
import { Consumables } from './components/Consumables';
@@ -9,6 +10,8 @@ import { Provisions } from './components/Provisions';
import { Skills } from './components/Skills';
export function CharacteristicsSection() {
const { strings } = useLocale();
return (
<Flex mt="4" px="4" style={{ boxSizing: 'border-box' }} gap="6">
<Flex
@@ -24,7 +27,9 @@ export function CharacteristicsSection() {
maxWidth="21rem"
align={{ initial: 'center', sm: 'start' }}
>
<Heading>Configuration</Heading>
<Heading>
{strings.website.tools.tankopedia.configuration.title}
</Heading>
<Modules />
<Equipment />
<Provisions />
@@ -3,6 +3,7 @@ import { Box, Flex, Heading, Text } from '@radix-ui/themes';
import { awaitableConsumableDefinitions } from '../../core/awaitables/consumableDefinitions';
import { awaitableGameDefinitions } from '../../core/awaitables/gameDefinitions';
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions';
import { useLocale } from '../../hooks/useLocale';
import { Duel } from '../../stores/duel';
const [gameDefinitions, consumableDefinitions, provisionDefinitions] =
@@ -13,6 +14,7 @@ const [gameDefinitions, consumableDefinitions, provisionDefinitions] =
]);
export function GameModeSection() {
const { unwrap, strings } = useLocale();
const tank = Duel.use((state) => state.protagonist.tank);
const roles = Object.entries(tank.roles);
@@ -20,7 +22,9 @@ export function GameModeSection() {
return (
<Flex direction="column" gap="4" align="center">
<Heading size="6">Game mode abilities</Heading>
<Heading size="6">
{strings.website.tools.tankopedia.game_modes.title}
</Heading>
<Flex justify="center" gap="4" wrap="wrap" px="4">
{roles.map(([key, value]) => {
@@ -67,7 +71,7 @@ export function GameModeSection() {
justify="center"
>
<Text size="4" weight="bold">
{gameMode.name}
{unwrap(gameMode.name)}
</Text>
<Flex direction="column">
@@ -84,9 +88,9 @@ export function GameModeSection() {
objectFit: 'contain',
}}
src={asset(`icons/provisions/${id}.webp`)}
alt={provisions.name}
alt={unwrap(provisions.name)}
/>
{provisions.name}
{unwrap(provisions.name)}
</Flex>
</Text>
);
@@ -104,9 +108,9 @@ export function GameModeSection() {
objectFit: 'contain',
}}
src={asset(`icons/consumables/${id}.webp`)}
alt={consumable.name}
alt={unwrap(consumable.name)}
/>
{consumable.name}
{unwrap(consumable.name)}
</Flex>
</Text>
);
@@ -1,4 +1,5 @@
import { imgur, ShellType } from '@blitzkit/core';
import { literals } from '@blitzkit/i18n/src/literals';
import {
Button,
Flex,
@@ -11,6 +12,7 @@ import {
} from '@radix-ui/themes';
import { throttle } from 'lodash-es';
import { type ChangeEvent } from 'react';
import { useLocale } from '../../../../../../hooks/useLocale';
import { Duel } from '../../../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
@@ -19,6 +21,7 @@ export function CustomShellButton() {
const gun = Duel.use((state) => state.protagonist.gun);
const antagonistShell = Duel.use((state) => state.antagonist.shell);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const { strings } = useLocale();
return (
<Popover.Root
@@ -49,13 +52,16 @@ export function CustomShellButton() {
</Popover.Trigger>
<Popover.Content>
{/* <Popover.Title>Custom shell</Popover.Title> */}
{customShell && (
<>
<Flex direction="column" gap="2">
<Flex align="center" justify="between" gap="4">
<Text>Type</Text>
<Text>
{
strings.website.tools.tankopedia.sandbox.dynamic
.custom_shell.type
}
</Text>
<Select.Root
value={`${customShell.type}`}
onValueChange={(value) => {
@@ -99,16 +105,29 @@ export function CustomShellButton() {
<Select.Trigger />
<Select.Content>
<Select.Item value={`${ShellType.AP}`}>AP</Select.Item>
<Select.Item value={`${ShellType.APCR}`}>APCR</Select.Item>
<Select.Item value={`${ShellType.HEAT}`}>HEAT</Select.Item>
<Select.Item value={`${ShellType.HE}`}>HE</Select.Item>
<Select.Item value={`${ShellType.AP}`}>
{strings.common.shells.ap}
</Select.Item>
<Select.Item value={`${ShellType.APCR}`}>
{strings.common.shells.apcr}
</Select.Item>
<Select.Item value={`${ShellType.HEAT}`}>
{strings.common.shells.heat}
</Select.Item>
<Select.Item value={`${ShellType.HE}`}>
{strings.common.shells.he}
</Select.Item>
</Select.Content>
</Select.Root>
</Flex>
<Flex align="center" justify="between" gap="4">
<Text>Caliber</Text>
<Text>
{
strings.website.tools.tankopedia.sandbox.dynamic
.custom_shell.caliber
}
</Text>
<TextField.Root
style={{ maxWidth: '6rem' }}
defaultValue={customShell.caliber}
@@ -131,12 +150,19 @@ export function CustomShellButton() {
}
}}
>
<TextField.Slot side="right">mm</TextField.Slot>
<TextField.Slot side="right">
{literals(strings.common.units.mm, [''])}
</TextField.Slot>
</TextField.Root>
</Flex>
<Flex align="center" justify="between" gap="4">
<Text>Penetration</Text>
<Text>
{
strings.website.tools.tankopedia.sandbox.dynamic
.custom_shell.penetration
}
</Text>
<TextField.Root
style={{ maxWidth: '6rem' }}
defaultValue={customShell.penetration.near}
@@ -160,7 +186,9 @@ export function CustomShellButton() {
}
}}
>
<TextField.Slot side="right">mm</TextField.Slot>
<TextField.Slot side="right">
{literals(strings.common.units.mm, [''])}
</TextField.Slot>
</TextField.Root>
</Flex>
@@ -168,7 +196,12 @@ export function CustomShellButton() {
customShell.type === ShellType.APCR) && (
<>
<Flex align="center" justify="between" gap="4" py="1">
<Text>Normalization</Text>
<Text>
{
strings.website.tools.tankopedia.sandbox.dynamic
.custom_shell.normalization
}
</Text>
<Flex
align="center"
@@ -178,7 +211,9 @@ export function CustomShellButton() {
minWidth="8rem"
>
<Text color="gray">
{Math.round(customShell.normalization ?? 0)}°
{literals(strings.common.units.deg, [
(customShell.normalization ?? 0).toFixed(0),
])}
</Text>
<Slider
min={0}
@@ -197,11 +232,18 @@ export function CustomShellButton() {
</Flex>
<Flex align="center" justify="between" gap="4" py="1">
<Text>Ricochet</Text>
<Text>
{
strings.website.tools.tankopedia.sandbox.dynamic
.custom_shell.ricochet
}
</Text>
<Flex align="center" gap="2" flexGrow="1" maxWidth="15rem">
<Text color="gray">
{Math.round(customShell.ricochet ?? 90)}°
{literals(strings.common.units.deg, [
(customShell.ricochet ?? 90).toFixed(0),
])}
</Text>
<Slider
min={0}
@@ -254,7 +296,10 @@ export function CustomShellButton() {
<Flex justify="end" mt="6" gap="2">
<Popover.Close>
<Button color="red" variant="outline">
Exit
{
strings.website.tools.tankopedia.sandbox.dynamic
.custom_shell.exit
}
</Button>
</Popover.Close>
<Button
@@ -265,7 +310,10 @@ export function CustomShellButton() {
});
}}
>
Reset
{
strings.website.tools.tankopedia.sandbox.dynamic.custom_shell
.reset
}
</Button>
</Flex>
</>
@@ -25,6 +25,7 @@ export function QuickInputs() {
const initialGunPitch =
tankModelDefinition.initial_turret_rotation?.pitch ?? 0;
const hasImprovedVerticalStabilizer = useEquipment(122);
const hasDownImprovedVerticalStabilizer = useEquipment(124);
useEffect(() => {
if (!yawInput.current) return;
@@ -92,6 +93,7 @@ export function QuickInputs() {
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
@@ -133,6 +135,7 @@ export function QuickInputs() {
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
@@ -1,6 +1,8 @@
import { literals } from '@blitzkit/i18n/src/literals';
import { EyeClosedIcon, EyeOpenIcon, ReloadIcon } from '@radix-ui/react-icons';
import { Box, Button, Checkbox, Flex, Text } from '@radix-ui/themes';
import { Button, Checkbox, Flex, Text } from '@radix-ui/themes';
import { Suspense } from 'react';
import { useLocale } from '../../../../../../hooks/useLocale';
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
import { TankopediaPersistent } from '../../../../../../stores/tankopediaPersistent';
import type { ThicknessRange } from '../../../../../Armor/components/StaticArmor';
@@ -24,6 +26,7 @@ export function Thicknesses({ thicknessRange }: ThicknessesProps) {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const editStatic = TankopediaEphemeral.use((state) => state.editStatic);
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const { strings } = useLocale();
return (
<Flex
@@ -33,112 +36,144 @@ export function Thicknesses({ thicknessRange }: ThicknessesProps) {
mr="4"
style={{ transform: 'translateY(-50%)', userSelect: 'none' }}
direction="column"
gap="2"
gap="5"
align="end"
>
<Flex
height="64px"
gap="2"
style={{ opacity: showPrimaryArmor ? 1 : 0.5, cursor: 'pointer' }}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.showPrimaryArmor = !draft.showPrimaryArmor;
});
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
});
}}
>
<Flex direction="column" align="end" justify="between">
<Text color="gray" size="1">
{(thicknessRange.value * 1.5).toFixed(0)}
</Text>
<Flex gap="1">
<Flex direction="column" align="end" gap="3">
<Flex
height="64px"
gap="2"
style={{ opacity: showPrimaryArmor ? 1 : 0.5, cursor: 'pointer' }}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.showPrimaryArmor = !draft.showPrimaryArmor;
});
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
});
}}
>
<Flex direction="column" align="end" justify="between">
<Text color="gray" size="1">
{literals(strings.common.units.mm, [
(thicknessRange.value * 1.5).toFixed(0),
])}
</Text>
<Text size="1">
{strings.website.tools.tankopedia.sandbox.static.primary}
</Text>
<Text color="gray" size="1">
{literals(strings.common.units.mm, ['0'])}
</Text>
</Flex>
<Flex
align="center"
width="1rem"
style={{
borderRadius: 'var(--radius-full)',
overflow: 'hidden',
background: `linear-gradient(rgb(64, 0, 0), rgb(255, 0, 0) 33%, rgb(255, 255, 0) 66%, rgb(0, 255, 0))`,
color: 'black',
}}
>
{showPrimaryArmor ? <EyeOpenIcon /> : <EyeClosedIcon />}
<Text size="1">Primary</Text>
</Flex>
<Text color="gray" size="1">
0
</Text>
</Flex>
<Box
width="16px"
style={{
borderRadius: 'var(--radius-full)',
overflow: 'hidden',
background: `linear-gradient(rgb(64, 0, 0), rgb(255, 0, 0) 33%, rgb(255, 255, 0) 66%, rgb(0, 255, 0))`,
<Flex
height="64px"
gap="2"
style={{ opacity: showSpacedArmor ? 1 : 0.5, cursor: 'pointer' }}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.showSpacedArmor = !draft.showSpacedArmor;
});
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
});
}}
/>
</Flex>
>
<Flex direction="column" align="end" justify="between">
<Text color="gray" size="1">
{literals(strings.common.units.mm, [
thicknessRange.value.toFixed(0),
])}
</Text>
<Text size="1">
{strings.website.tools.tankopedia.sandbox.static.spaced}
</Text>
<Text color="gray" size="1">
{literals(strings.common.units.mm, ['0'])}
</Text>
</Flex>
<Flex
height="64px"
gap="2"
style={{ opacity: showSpacedArmor ? 1 : 0.5, cursor: 'pointer' }}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.showSpacedArmor = !draft.showSpacedArmor;
});
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
});
}}
>
<Flex direction="column" align="end" justify="between">
<Text color="gray" size="1">
{thicknessRange.value.toFixed(0)}
</Text>
<Flex gap="1">
<Flex
align="center"
width="1rem"
style={{
borderRadius: 'var(--radius-full)',
overflow: 'hidden',
background: `linear-gradient(rgb(32, 0, 225), rgb(255, 0, 255))`,
}}
>
{showSpacedArmor ? <EyeOpenIcon /> : <EyeClosedIcon />}
<Text size="1">Spaced</Text>
</Flex>
<Text color="gray" size="1">
0
</Text>
</Flex>
<Box
width="16px"
style={{
borderRadius: 'var(--radius-full)',
overflow: 'hidden',
background: `linear-gradient(rgb(32, 0, 225), rgb(255, 0, 255))`,
<Flex
gap="2"
style={{ opacity: showExternalModules ? 1 : 0.5, cursor: 'pointer' }}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.showExternalModules = !draft.showExternalModules;
});
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
});
}}
/>
</Flex>
>
<Flex direction="column" align="end" justify="between">
<Text size="1">
{strings.website.tools.tankopedia.sandbox.static.external}
</Text>
</Flex>
<Flex
gap="2"
style={{ opacity: showExternalModules ? 1 : 0.5, cursor: 'pointer' }}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.showExternalModules = !draft.showExternalModules;
});
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
});
}}
>
<Flex direction="column" align="end" justify="between">
<Flex gap="1">
<Flex
justify="center"
width="1rem"
height="1rem"
style={{
borderRadius: 'var(--radius-full)',
overflow: 'hidden',
backgroundColor: '#c0c0c0',
color: 'black',
}}
>
{showExternalModules ? <EyeOpenIcon /> : <EyeClosedIcon />}
<Text size="1">External</Text>
</Flex>
</Flex>
</Flex>
<Flex direction="column" align="end" gap="2">
<Flex align="center" gap="2">
<Flex
gap="2"
style={{ cursor: 'pointer' }}
onClick={() => {
mutateTankopediaEphemeral((draft) => {
draft.editStatic = !draft.editStatic;
draft.highlightArmor = undefined;
});
}}
>
<Text size="1">
{strings.website.tools.tankopedia.sandbox.static.edit.label}
</Text>
<Checkbox size="2" checked={editStatic} />
</Flex>
</Flex>
<Box
width="16px"
height="16px"
style={{
borderRadius: 'var(--radius-full)',
overflow: 'hidden',
backgroundColor: '#c0c0c0',
}}
/>
</Flex>
<Flex align="center" gap="2" mt="2">
<Button
color="red"
size="1"
@@ -151,22 +186,8 @@ export function Thicknesses({ thicknessRange }: ThicknessesProps) {
}}
>
<ReloadIcon />
Reset
{strings.website.tools.tankopedia.sandbox.static.edit.reset}
</Button>
<Flex
gap="2"
style={{ cursor: 'pointer' }}
onClick={() => {
mutateTankopediaEphemeral((draft) => {
draft.editStatic = !draft.editStatic;
draft.highlightArmor = undefined;
});
}}
>
<Text size="1">Edit</Text>
<Checkbox size="2" checked={editStatic} />
</Flex>
</Flex>
<Suspense>
@@ -5,6 +5,7 @@ import {
TIER_ROMAN_NUMERALS,
uniqueGuns,
} from '@blitzkit/core';
import { literals } from '@blitzkit/i18n/src/literals';
import {
CameraIcon,
CopyIcon,
@@ -33,6 +34,7 @@ import { Pose, poseEvent } from '../../../../../core/blitzkit/pose';
import { useEquipment } from '../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../hooks/useFullScreen';
import { useFullscreenAvailability } from '../../../../../hooks/useFullscreenAvailability';
import { useLocale } from '../../../../../hooks/useLocale';
import { App } from '../../../../../stores/app';
import { Duel } from '../../../../../stores/duel';
import {
@@ -87,6 +89,7 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
const antagonistUniqueGuns = uniqueGuns(antagonistTank.turrets);
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const { strings, unwrap } = useLocale();
return (
<>
@@ -124,13 +127,14 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
>
{!hasCustomShell && (
<Text color="gray" size={{ initial: '1', sm: '2' }}>
{(
resolvePenetrationCoefficient(
hasCalibratedShells,
antagonistShell.type,
) * antagonistShell.penetration.near
).toFixed(0)}
mm
{literals(strings.common.units.mm, [
(
resolvePenetrationCoefficient(
hasCalibratedShells,
antagonistShell.type,
) * antagonistShell.penetration.near
).toFixed(0),
])}
</Text>
)}
{antagonistUniqueGuns.length > 1 && (
@@ -198,10 +202,11 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
}
secondaryDiscriminator={
<Text style={{ fontSize: '0.75em' }}>
{Math.round(
gun.gun_type!.value.base.shells[0].caliber,
)}
mm
{literals(strings.common.units.mm, [
gun.gun_type!.value.base.shells[0].caliber.toFixed(
0,
),
])}
</Text>
}
/>
@@ -239,7 +244,7 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
}}
>
<img
alt={thisShell.name}
alt={unwrap(thisShell.name)}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
style={{
width: '50%',
@@ -341,13 +346,13 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
>
<Checkbox checked={advancedHighlighting} />
<Text color="gray" size="2">
Advanced highlighting
{strings.website.tools.tankopedia.sandbox.dynamic.advanced}
</Text>
</Flex>
<Flex align="center" gap="2">
<Text color="gray" size="2">
Shooter:
{strings.website.tools.tankopedia.sandbox.dynamic.shooter}
</Text>
<Dialog.Root
open={antagonistSelectorOpen}
@@ -357,7 +362,7 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
<Button variant="ghost">
<Flex gap="2" align="center">
<SmallTankIcon id={antagonistTank.id} size={16} />
{antagonistTank.name}
{unwrap(antagonistTank.name)}
</Flex>
</Button>
</Dialog.Trigger>
@@ -403,21 +408,27 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
}}
>
<SegmentedControl.Item value={`${TankopediaDisplay.Model}`}>
<Tooltip content="Model">
<Tooltip
content={strings.website.tools.tankopedia.sandbox.model.name}
>
<Flex height="100%" align="center">
<img src={imgur('jAdYf0m')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaDisplay.DynamicArmor}`}>
<Tooltip content="Dynamic armor">
<Tooltip
content={strings.website.tools.tankopedia.sandbox.dynamic.name}
>
<Flex height="100%" align="center">
<img src={imgur('oe4Cq0g')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaDisplay.StaticArmor}`}>
<Tooltip content="Static armor">
<Tooltip
content={strings.website.tools.tankopedia.sandbox.static.name}
>
<Flex height="100%" align="center">
<img src={imgur('VQ4uDno')} style={{ height: '1.25em' }} />
</Flex>
@@ -441,15 +452,15 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
<DropdownMenu.Content>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
Hull down
{strings.website.tools.tankopedia.sandbox.poses.hull_down}
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
Face hug
{strings.website.tools.tankopedia.sandbox.poses.face_hug}
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
Default
{strings.website.tools.tankopedia.sandbox.poses.default}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -464,7 +475,9 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
<DropdownMenu.Content>
{display === TankopediaDisplay.DynamicArmor && (
<>
<DropdownMenu.Label>Armor</DropdownMenu.Label>
<DropdownMenu.Label>
{strings.website.tools.tankopedia.sandbox.settings.armor}
</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={greenPenetration}
@@ -474,7 +487,10 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
});
}}
>
Green penetration
{
strings.website.tools.tankopedia.sandbox.settings
.green_penetration
}
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
@@ -485,7 +501,10 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
});
}}
>
Hide tank model under armor
{
strings.website.tools.tankopedia.sandbox.settings
.hide_model_under_armor
}
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
@@ -496,7 +515,7 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
});
}}
>
Opaque
{strings.website.tools.tankopedia.sandbox.settings.opaque}
</DropdownMenu.CheckboxItem>
{developerMode && (
@@ -508,13 +527,18 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
});
}}
>
<b>DEV:</b> Wireframe
{
strings.website.tools.tankopedia.sandbox.settings
.dev_wireframe
}
</DropdownMenu.CheckboxItem>
)}
</>
)}
<DropdownMenu.Label>Environment</DropdownMenu.Label>
<DropdownMenu.Label>
{strings.website.tools.tankopedia.sandbox.settings.environment}
</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={showGrid}
@@ -524,7 +548,7 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
});
}}
>
Show grid
{strings.website.tools.tankopedia.sandbox.settings.show_grid}
</DropdownMenu.CheckboxItem>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -557,7 +581,10 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
}}
>
<DownloadIcon />
Download
{
strings.website.tools.tankopedia.sandbox.screenshot
.download
}
</Button>
</Popover.Close>
<Popover.Close>
@@ -576,7 +603,7 @@ export function Options({ thicknessRange, canvas }: OptionsProps) {
}}
>
<CopyIcon />
Copy
{strings.website.tools.tankopedia.sandbox.screenshot.copy}
</Button>
</Popover.Close>
</Flex>
@@ -118,6 +118,11 @@ export function Controls({ naked }: ControlsProps) {
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = await hasEquipment(
124,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
switch (event) {
case Pose.HullDown: {
@@ -127,6 +132,7 @@ export function Controls({ naked }: ControlsProps) {
protagonistGunModelDefinition.pitch,
protagonistTurretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
camera.position
@@ -159,6 +165,7 @@ export function Controls({ naked }: ControlsProps) {
protagonistGunModelDefinition.pitch,
protagonistTurretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
camera.position
@@ -185,6 +185,11 @@ export function TankModel() {
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = await hasEquipment(
124,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
@@ -196,6 +201,7 @@ export function TankModel() {
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
}
@@ -287,6 +293,11 @@ export function TankModel() {
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = await hasEquipment(
124,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
@@ -297,6 +308,7 @@ export function TankModel() {
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
}
@@ -41,6 +41,7 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const canvas = useRef<HTMLCanvasElement>(null);
const hasImprovedVerticalStabilizer = useEquipment(122);
const hasDownImprovedVerticalStabilizer = useEquipment(124);
const protagonistGun = Duel.use((state) => state.protagonist.gun);
const protagonistTurret = Duel.use((state) => state.protagonist.turret);
const mutateDuel = Duel.useMutation();
@@ -82,6 +83,7 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
@@ -6,6 +6,7 @@ import { NAVBAR_HEIGHT } from '../../../constants/navbar';
import { awaitableTankDefinitions } from '../../../core/awaitables/tankDefinitions';
import { Var } from '../../../core/radix/var';
import { useFullScreen } from '../../../hooks/useFullScreen';
import { useLocale } from '../../../hooks/useLocale';
import { Duel } from '../../../stores/duel';
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral';
import type { MaybeSkeletonComponentProps } from '../../../types/maybeSkeletonComponentProps';
@@ -18,6 +19,7 @@ import { TankSandboxLoader } from './components/TankSandboxLoader';
const tankDefinitions = await awaitableTankDefinitions;
export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
const { unwrap } = useLocale();
const canvas = useRef<HTMLCanvasElement>(null);
const isFullScreen = useFullScreen();
const protagonist = Duel.use((state) => state.protagonist.tank);
@@ -117,7 +119,7 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
wrap="nowrap"
color={treeColor}
>
{protagonist.name}
{unwrap(protagonist.name)}
</Heading>
</Flex>
@@ -1,16 +0,0 @@
import { Heading, Text } from '@radix-ui/themes';
import { Duel } from '../../stores/duel';
import { PageWrapper } from '../PageWrapper';
export function HistorySection() {
const tank = Duel.use((state) => state.protagonist.tank);
if (!tank.description) return null;
return (
<PageWrapper noFlex1 align="center" noMinHeight>
<Heading>History of the {tank.name}</Heading>
<Text>{tank.description}</Text>
</PageWrapper>
);
}
@@ -9,6 +9,7 @@ import {
Text,
} from '@radix-ui/themes';
import { useState } from 'react';
import { useLocale } from '../../../../../../hooks/useLocale';
import { App } from '../../../../../../stores/app';
import { Duel } from '../../../../../../stores/duel';
import { Stars } from '../../Stars';
@@ -24,6 +25,7 @@ export function VoteCaster() {
const [postVote, setPostVote] = useState<boolean | null>(null);
// true = open, false = closed, null = casting
const [showDialogue, setShowDialogue] = useState<boolean | null>(false);
const { unwrap } = useLocale();
return (
<>
@@ -35,7 +37,7 @@ export function VoteCaster() {
</Dialog.Trigger>
<Dialog.Content maxWidth="23rem">
<Dialog.Title>Voting for {tank.name}</Dialog.Title>
<Dialog.Title>Voting for {unwrap(tank.name)}</Dialog.Title>
<Flex direction="column" mt="4">
<Flex align="center" justify="between">
@@ -1,6 +1,7 @@
import { formatCompact } from '@blitzkit/core';
import { Flex, Heading, Skeleton, Text } from '@radix-ui/themes';
import { memo } from 'react';
import { useLocale } from '../../../../../hooks/useLocale';
import { useTankVotes } from '../../../../../hooks/useTankVotes';
import { App } from '../../../../../stores/app';
import { Duel } from '../../../../../stores/duel';
@@ -13,13 +14,14 @@ export const Votes = memo(
const id = Duel.use((state) => state.protagonist.tank.id);
const votes = useTankVotes(id);
const wargaming = App.use((state) => state.logins.wargaming);
const { locale } = useLocale();
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 && formatCompact(locale, votes.votes)}{' '}
{votes && (votes.votes > 1 ? 'votes' : 'vote')}
</Heading>
@@ -5,17 +5,18 @@ import {
TankType,
TIER_ROMAN_NUMERALS,
} from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en.json';
import { ChevronLeftIcon, MixIcon, UpdateIcon } from '@radix-ui/react-icons';
import { Button, Code, Dialog, Flex, Link } from '@radix-ui/themes';
import { useState } from 'react';
import { awaitableModelDefinitions } from '../../../core/awaitables/modelDefinitions';
import { awaitableProvisionDefinitions } from '../../../core/awaitables/provisionDefinitions';
import { tankToDuelMember } from '../../../core/blitzkit/tankToDuelMember';
import { useLocale } from '../../../hooks/useLocale';
import { App } from '../../../stores/app';
import { Duel } from '../../../stores/duel';
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral';
import { classIcons } from '../../ClassIcon';
import { LinkI18n } from '../../LinkI18n';
import { ScienceIcon } from '../../ScienceIcon';
import { TankSearch } from '../../TankSearch';
import { Listing } from './components/Listing';
@@ -37,24 +38,28 @@ export function MetaSection() {
const mutateDuel = Duel.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const [showSwapDialog, setShowSwapDialog] = useState(false);
const { locale, strings, unwrap } = useLocale();
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">
<LinkI18n locale={locale} href="/tools/tankopedia">
<Button variant="outline">
<ChevronLeftIcon />
Back
{strings.website.tools.tankopedia.meta.back}
</Button>
</Link>
</LinkI18n>
<Link href={`/tools/compare?tanks=${compareTanks.join('%2C')}`}>
<LinkI18n
locale={locale}
href={`/tools/compare?tanks=${compareTanks.join('%2C')}`}
>
<Button variant="outline">
<MixIcon />
Compare
{strings.website.tools.tankopedia.meta.compare}
</Button>
</Link>
</LinkI18n>
{assertSecret(import.meta.env.PUBLIC_PROMOTE_OPENTEST) === 'true' && (
<Link
@@ -68,8 +73,8 @@ export function MetaSection() {
<ScienceIcon height="1.25em" width="1.25em" />
{assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH) ===
'opentest'
? 'Vanilla'
: 'OpenTest'}
? strings.website.tools.tankopedia.meta.vanilla
: strings.website.tools.tankopedia.meta.opentest}
</Button>
</Link>
)}
@@ -78,12 +83,14 @@ export function MetaSection() {
<Dialog.Trigger>
<Button>
<UpdateIcon />
Swap
{strings.website.tools.tankopedia.meta.swap.button}
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Swap tanks</Dialog.Title>
<Dialog.Title>
{strings.website.tools.tankopedia.meta.swap.title}
</Dialog.Title>
<TankSearch
compact
@@ -116,9 +123,11 @@ export function MetaSection() {
>
<Flex direction="column" width="100%">
{protagonist.name_full && (
<Listing label="Full-name">{protagonist.name_full}</Listing>
<Listing label={strings.website.tools.tankopedia.meta.full_name}>
{unwrap(protagonist.name_full)}
</Listing>
)}
<Listing label="Nation">
<Listing label={strings.website.tools.tankopedia.meta.nation}>
<Flex align="center" gap="1">
<img
style={{ width: '1em', height: '1em' }}
@@ -132,17 +141,17 @@ export function MetaSection() {
}
</Flex>
</Listing>
<Listing label="Class">
<Listing label={strings.website.tools.tankopedia.meta.class}>
<Flex align="center" gap="1">
<ClassIcon width="1em" height="1em" />
{strings.common.tank_class_short[protagonist.class]}
</Flex>
</Listing>
<Listing label="Tier">
<Listing label={strings.website.tools.tankopedia.meta.tier}>
{TIER_ROMAN_NUMERALS[protagonist.tier]}
</Listing>
<Listing
label="Type"
label={strings.website.tools.tankopedia.meta.type}
color={
protagonist.type === TankType.COLLECTOR
? 'blue'
@@ -157,12 +166,12 @@ export function MetaSection() {
<Flex direction="column" width="100%">
{developerMode && (
<Listing label="DEV: ID">
<Listing label={strings.website.tools.tankopedia.meta.dev_id}>
<Code>{protagonist.id}</Code>
</Listing>
)}
{protagonist.type === TankType.PREMIUM && (
<Listing label="Purchase price">
<Listing label={strings.website.tools.tankopedia.meta.purchase}>
<Flex align="center" gap="1">
{protagonist.price.value / 400}
<img
@@ -174,14 +183,14 @@ export function MetaSection() {
</Listing>
)}
<Listing
label={`${
label={
protagonist.type === TankType.RESEARCHABLE
? 'Purchase'
: 'Restoration'
} price`}
? strings.website.tools.tankopedia.meta.purchase
: strings.website.tools.tankopedia.meta.restoration
}
>
<Flex align="center" gap="1">
{protagonist.price.value.toLocaleString()}
{protagonist.price.value.toLocaleString(locale)}
<img
style={{ width: '1em', height: '1em' }}
alt={TankPriceType[protagonist.price.type]}
@@ -195,9 +204,9 @@ export function MetaSection() {
/>
</Flex>
</Listing>
<Listing label="Sale price">
<Listing label={strings.website.tools.tankopedia.meta.sale}>
<Flex align="center" gap="1">
{(protagonist.price.value / 2).toLocaleString()}
{(protagonist.price.value / 2).toLocaleString(locale)}
<img
style={{ width: '1em', height: '1em' }}
alt={TankPriceType[protagonist.price.type]}
@@ -212,12 +221,12 @@ export function MetaSection() {
</Flex>
</Listing>
{protagonist.research_cost && (
<Listing label="Research XP">
<Listing label={strings.website.tools.tankopedia.meta.research}>
<Flex align="center" gap="1">
{(
protagonist.research_cost.research_cost_type!
.value as number
).toLocaleString()}
).toLocaleString(locale)}
<img
style={{ width: '1em', height: '1em' }}
alt="xp"
@@ -228,8 +237,6 @@ export function MetaSection() {
)}
</Flex>
</Flex>
{/* <Votes /> */}
</Flex>
</Flex>
);
@@ -1,63 +0,0 @@
import { asset, AverageDefinitions } from '@blitzkit/core';
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
import { range } from 'lodash-es';
import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio';
import { Duel } from '../../stores/duel';
import { FIRST_AVERAGES_ENTRY } from '../Charts/constants';
import { ThemedLine } from '../Nivo/ThemedLine';
const currentEntry = Math.floor(Date.now() / 1000 / 60 / 60 / 24);
const days = await Promise.all(
range(FIRST_AVERAGES_ENTRY, currentEntry).map(async (day) => {
const url = asset(`averages/${day}.pb`);
const response = await fetch(url);
if (response.status !== 200) return null;
const buffer = await response.arrayBuffer();
const averageDefinitions = AverageDefinitions.decode(
new Uint8Array(buffer),
);
return { day, definitions: averageDefinitions };
}),
).then((days) => days.filter((day) => day !== null));
export function TankopediaCharts() {
const id = Duel.use((state) => state.protagonist.tank.id);
const ratio = useAveragesExclusionRatio();
const data = days.map(({ day, definitions }) => {
const entry = definitions.averages[id];
const x = new Date(day * 24 * 60 * 60 * 1000);
const y = entry ? Math.round((entry.samples.d_7 / 7) * ratio) : 0;
return { x, y };
});
return (
<Flex direction="column" align="center">
<Flex direction="column" width="50rem">
<Heading>Players</Heading>
<Text color="gray" mb="5">
7-day rolling average
</Text>
<Box height="25rem">
<ThemedLine
enablePoints={false}
enableGridX={false}
data={[{ id: 'value', data }]}
xFormat={(date) => {
return (date as Date).toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}}
xScale={{ type: 'time' }}
/>
</Box>
</Flex>
</Flex>
);
}
@@ -1,9 +1,12 @@
import { asset, formatCompact, TIER_ROMAN_NUMERALS } from '@blitzkit/core';
import { Box, Flex, Link, Skeleton, Text } from '@radix-ui/themes';
import { literals } from '@blitzkit/i18n/src/literals';
import { Box, Flex, Skeleton, Text } from '@radix-ui/themes';
import { awaitableAverageDefinitions } from '../../../../core/awaitables/averageDefinitions';
import { awaitableTankDefinitions } from '../../../../core/awaitables/tankDefinitions';
import { useLocale } from '../../../../hooks/useLocale';
import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral';
import type { MaybeSkeletonComponentProps } from '../../../../types/maybeSkeletonComponentProps';
import { LinkI18n } from '../../../LinkI18n';
type NodeProps = MaybeSkeletonComponentProps & {
id: number;
@@ -17,6 +20,7 @@ const [tankDefinitions, averageDefinitions] = await Promise.all([
]);
export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
const { locale, strings, unwrap } = useLocale();
const xpMultiplier = TankopediaEphemeral.use((state) => state.xpMultiplier);
const tank = tankDefinitions.tanks[id];
const nextTanks = nextIds?.map((id) => tankDefinitions.tanks[id]);
@@ -40,7 +44,8 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
averages && nextTanks ? Math.round(nextTanksXp! / averageXp!) : undefined;
return (
<Link
<LinkI18n
locale={locale}
href={`/tools/tankopedia/${id}`}
key={id}
style={{
@@ -73,7 +78,7 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
}}
>
<img
alt={tank.name}
alt={unwrap(tank.name)}
src={asset(`icons/tanks/big/${id}.webp`)}
width={64}
height={64}
@@ -87,7 +92,7 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
<Text color="gray" size="1">
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
<Text wrap="nowrap">{tank.name}</Text>
<Text wrap="nowrap">{unwrap(tank.name)}</Text>
</Flex>
<Flex gap="2" align="center">
<Text color="gray" size="1" wrap="nowrap">
@@ -102,7 +107,7 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
objectPosition: 'center',
}}
/>
{formatCompact(thisTankXp)}
{formatCompact(locale, thisTankXp)}
</Flex>
</Text>
<Text color="gray" size="1" wrap="nowrap">
@@ -117,7 +122,7 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
objectPosition: 'center',
}}
/>
{formatCompact(tank.price.value)}
{formatCompact(locale, tank.price.value)}
</Flex>
</Text>
</Flex>
@@ -125,16 +130,18 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
{averages && nextTanks && tank.tier !== 10 && (
<Text color="gray" size="1" mt="2">
{skeleton && <Skeleton height="1em" width="4em" />}
{!skeleton && (
<>
{games!} {games === 1 ? 'battle' : 'battles'}
</>
)}
{!skeleton &&
(games === 1
? strings.website.tools.tankopedia.tech_tree.battle
: literals(
strings.website.tools.tankopedia.tech_tree.battles,
[`${games}`],
))}
</Text>
)}
</Flex>
</Flex>
</Box>
</Link>
</LinkI18n>
);
}
@@ -1,8 +1,10 @@
import { asset, imgur, TankType } from '@blitzkit/core';
import { literals } from '@blitzkit/i18n/src/literals';
import { CaretLeftIcon, CaretRightIcon, PlusIcon } from '@radix-ui/react-icons';
import { Flex, Heading, IconButton, ScrollArea, Text } from '@radix-ui/themes';
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { awaitableTankDefinitions } from '../../../core/awaitables/tankDefinitions';
import { useLocale } from '../../../hooks/useLocale';
import { Duel } from '../../../stores/duel';
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral';
import type { MaybeSkeletonComponentProps } from '../../../types/maybeSkeletonComponentProps';
@@ -16,6 +18,7 @@ const tankDefinitions = await awaitableTankDefinitions;
export const XP_MULTIPLIERS = [1, 2, 3, 4, 5, 10];
export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
const { locale, strings } = useLocale();
const master = Duel.use((state) => state.protagonist.tank);
const xpMultiplier = TankopediaEphemeral.use((state) => state.xpMultiplier);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
@@ -89,7 +92,9 @@ export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
py="6"
>
<Flex direction="column" align="center">
<Heading size="6">Tech tree</Heading>
<Heading size="6">
{strings.website.tools.tankopedia.tech_tree.title}
</Heading>
{master.tier > 1 && (
<Flex gap="4" align="center">
@@ -105,7 +110,7 @@ export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
objectPosition: 'center',
}}
/>
{Math.round(totalXp / xpMultiplier).toLocaleString()}
{Math.round(totalXp / xpMultiplier).toLocaleString(locale)}
</Flex>
</Text>
@@ -121,7 +126,7 @@ export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
objectPosition: 'center',
}}
/>
{totalCredits.toLocaleString()}
{totalCredits.toLocaleString(locale)}
</Flex>
</Text>
</Flex>
@@ -154,7 +159,7 @@ export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
pl="1"
>
<Text size="2">
x<b>{multiplier}</b>
{literals(strings.common.units.x, [`${multiplier}`])}
</Text>
</Flex>
);
@@ -214,7 +219,10 @@ export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
<CaretLeftIcon />
</IconButton>
<Text>
Route {lineIndex + 1} of {lines.length}
{literals(strings.website.tools.tankopedia.tech_tree.route, [
`${lineIndex + 1}`,
`${lines.length}`,
])}
</Text>
<IconButton
size="2"
@@ -1,7 +1,9 @@
import { youtubers } from '@blitzkit/core';
import { literals } from '@blitzkit/i18n/src/literals';
import { Box, Flex, Heading, Link, Skeleton, Text } from '@radix-ui/themes';
import { awaitableReviews } from '../../core/awaitables/reviews';
import { Var } from '../../core/radix/var';
import { useLocale } from '../../hooks/useLocale';
import { Duel } from '../../stores/duel';
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps';
import { PageWrapper } from '../PageWrapper';
@@ -12,6 +14,7 @@ const reviews = await awaitableReviews;
export function VideoSection({ skeleton }: MaybeSkeletonComponentProps) {
const tank = Duel.use((state) => state.protagonist.tank);
const videos = reviews.reviews[tank.id]?.videos;
const { strings } = useLocale();
if (!videos) return null;
@@ -19,9 +22,13 @@ export function VideoSection({ skeleton }: MaybeSkeletonComponentProps) {
<PageWrapper noFlex1 noMinHeight>
<Flex direction="column" gap="6" align="center">
<Flex direction="column" align="center">
<Heading size="6">Review videos</Heading>
<Heading size="6">
{strings.website.tools.tankopedia.review.title}
</Heading>
<Text color="gray">BlitzKit does not endorse any review videos.</Text>
<Text color="gray">
{strings.website.tools.tankopedia.review.disclaimer}
</Text>
</Flex>
<Flex gap="3" wrap="wrap" justify="center">
@@ -110,7 +117,9 @@ export function VideoSection({ skeleton }: MaybeSkeletonComponentProps) {
width: '9rem',
}}
>
Apply to BlitzKit YouTuber program
{literals(strings.website.tools.tankopedia.review.join, [
'BlitzKit',
])}
</Text>
</Flex>
</Link>
@@ -6,13 +6,14 @@ import {
type CompositeStats,
type CompositeStatsKey,
} from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en.json';
import strings from '@blitzkit/i18n/strings/en.json';
import { amberDark, blueDark } from '@radix-ui/colors';
import { Flex, Text, type FlexProps } from '@radix-ui/themes';
import { times } from 'lodash-es';
import type { ReactNode } from 'react';
import { breakdownConfig } from '../constants/embeds';
import { toRadiusVar } from '../core/radix/utils';
import { useLocale } from '../hooks/useLocale';
import { useEmbedStateCurry } from '../stores/embedState/utilities';
import { ClassHeavy } from './ClassIcon/components/ClassHeavy';
import { ClassLight } from './ClassIcon/components/ClassLight';
@@ -52,6 +53,7 @@ export function BreakdownEmbedCard({
}: BreakdownEmbedCardProps) {
const { useEmbedState, useRichText } =
useEmbedStateCurry<typeof breakdownConfig>();
const { unwrap } = useLocale();
return (
<Flex
@@ -111,7 +113,7 @@ export function BreakdownEmbedCard({
? 'Total'
: tank === undefined
? `Unknown tank`
: tank.name}
: unwrap(tank.name)}
</Flex>
</Text>
</Flex>
@@ -1,10 +1,12 @@
import { Share2Icon } from '@radix-ui/react-icons';
import { generateTierListParams } from '../../../../core/blitzkit/generateTierListParams';
import { useLocale } from '../../../../hooks/useLocale';
import { TierList } from '../../../../stores/tierList';
import { CopyButton } from '../../../CopyButton';
export function Share() {
const rows = TierList.use((state) => state.rows);
const { strings } = useLocale();
return (
<CopyButton
@@ -13,7 +15,7 @@ export function Share() {
`${location.origin}/tools/tier-list?${generateTierListParams(rows)}`
}
>
<Share2Icon /> Share
<Share2Icon /> {strings.website.tools.tier_list.buttons.share}
</CopyButton>
);
}
@@ -1,10 +1,12 @@
import { ReloadIcon } from '@radix-ui/react-icons';
import { Button, Flex } from '@radix-ui/themes';
import { useLocale } from '../../../hooks/useLocale';
import { TierList, tierListInitialState } from '../../../stores/tierList';
import { Share } from './components/Share';
export function TierListControls() {
const mutateTierList = TierList.useMutation();
const { strings } = useLocale();
return (
<Flex justify="center" gap="2">
@@ -16,7 +18,7 @@ export function TierListControls() {
});
}}
>
<ReloadIcon /> Reset
<ReloadIcon /> {strings.website.tools.tier_list.buttons.reset}
</Button>
<Share />
+15 -6
View File
@@ -1,8 +1,10 @@
---
import { assertSecret, imgur, ImgurSize } from '@blitzkit/core';
import { CaretRightIcon } from '@radix-ui/react-icons';
import { Box, Button, Code, Flex, Heading, Link, Text } from '@radix-ui/themes';
import { Box, Button, Code, Flex, Heading, Text } from '@radix-ui/themes';
import type { Tool } from '../constants/tools';
import { getStrings } from '../core/i18n/getStrings';
import { LinkI18n } from './LinkI18n';
interface Props {
tool: Tool;
@@ -12,6 +14,12 @@ const { tool } = Astro.props;
const disabled = tool.branches?.every(
(branch) => branch !== assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH),
);
const { locale } = Astro.params;
const strings = getStrings(locale);
const toolStrings =
strings.website.tools[
(tool.stringsId??tool.id ) as keyof typeof strings.website.tools
];
---
<style>
@@ -54,7 +62,8 @@ const disabled = tool.branches?.every(
flexGrow={tool.significant ? undefined : '1'}
flexBasis={tool.significant ? undefined : { initial: undefined, sm: '0' }}
>
<Link
<LinkI18n
{locale}
className="link"
underline="none"
href={disabled ? '#' : (tool.href ?? `/tools/${tool.id}`)}
@@ -98,7 +107,7 @@ const disabled = tool.branches?.every(
size={{ initial: '6', sm: tool.significant ? '7' : '5' }}
weight="medium"
>
{tool.title}
{toolStrings.name}
</Heading>
<Text
align={{ initial: 'center', sm: 'left' }}
@@ -112,7 +121,7 @@ const disabled = tool.branches?.every(
<Code>{assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH)}</Code>
</>
) : (
tool.description
toolStrings.description
)
}
</Text>
@@ -129,11 +138,11 @@ const disabled = tool.branches?.every(
color={tool.button.color}
style={{ cursor: 'inherit' }}
>
{tool.button.text}
{toolStrings.button}
<CaretRightIcon />
</Button>
)
}
</Flex>
</Link>
</LinkI18n>
</Flex>
@@ -1,22 +1,22 @@
---
import { imgur } from '@blitzkit/core';
import { type Tool } from '../constants/tools';
import { getStrings } from '../core/i18n/getStrings';
interface Props {
tool: Tool;
}
const { tool } = Astro.props;
const { locale } = Astro.params;
const strings = getStrings(locale);
const toolStrings =
strings.website.tools[
(tool.stringsId ?? tool.id) as keyof typeof strings.website.tools
];
---
<title>{tool.title}</title>
<title>{toolStrings.name}</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} />
</>
)
}
<meta name="description" content={toolStrings.seo_description} />
<meta property="og:description" content={toolStrings.seo_description} />
@@ -1,9 +1,11 @@
import { assertSecret, REGIONS } from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en.json';
import { Button, type ButtonProps, Dialog, Flex, Link } from '@radix-ui/themes';
import { useLocale } from '../hooks/useLocale';
import { WargamingIcon } from './WargamingIcon';
export function WargamingLoginButton({ children, ...props }: ButtonProps) {
const { strings } = useLocale();
return (
<Dialog.Root>
<Dialog.Trigger>
+6 -63
View File
@@ -3,15 +3,12 @@ import type { ComponentProps } from 'react';
export interface Tool {
id: string;
stringsId?: string;
image: string;
title: string;
description: string;
disabled?: boolean;
href?: string;
pageDescription?: string;
button: {
highContrast?: boolean;
text: string;
color: ComponentProps<typeof Button>['color'];
};
significant?: boolean;
@@ -20,13 +17,8 @@ export interface Tool {
export const tankopediaTool: Tool = {
id: 'tankopedia',
title: 'Tankopedia',
description: 'Blitz tank encyclopedia',
pageDescription:
'Statistics, armor, and more for all tanks in World of Tanks Blitz',
image: 'D8j6GZx',
button: {
text: 'Find tanks',
color: 'purple',
},
significant: true,
@@ -35,13 +27,8 @@ export const tankopediaTool: Tool = {
// export const ratingTool: Tool = {
// id: 'rating',
// title: 'Rating',
// description: 'Full rating leaderboard',
// pageDescription:
// 'Live and archived full rating leaderboard for World of Tanks Blitz',
// image: 'WEyMZH3',
// button: {
// text: 'View leaderboard',
// color: 'orange',
// },
// branches: ['main', 'dev'],
@@ -49,13 +36,8 @@ export const tankopediaTool: Tool = {
export const compareTool: Tool = {
id: 'compare',
title: 'Compare',
description: 'Compare tank statistics',
pageDescription:
'Compare tanks statistics and loadouts in World of Tanks Blitz',
image: 'O6VNl6e',
button: {
text: 'Compare tanks',
color: 'crimson',
},
branches: ['main', 'dev', 'opentest', 'preview'],
@@ -64,13 +46,8 @@ export const compareTool: Tool = {
export const sessionTool: Tool = {
// TODO: rename to tracker
id: 'session',
title: 'Session',
description: 'Live session stats',
pageDescription:
'Track your stats in real time as you play World of Tanks Blitz',
image: 'HdG9sTf',
button: {
text: 'Start tracking',
color: 'blue',
},
branches: ['main', 'dev'],
@@ -78,35 +55,25 @@ export const sessionTool: Tool = {
export const discordTool: Tool = {
id: 'discord',
title: 'Discord bot',
description: 'Stats right in Discord',
href: 'https://discord.com/application-directory/1097673957865443370',
image: '0bWE5hC',
button: {
text: 'Install now',
color: 'indigo',
},
};
export const moreTool: Tool = {
id: 'more',
title: 'More coming soon',
description: "What we're making",
href: 'https://discord.gg/nDt7AjGJQH',
image: '1nPm6VI',
button: {
text: 'Join Discord',
color: 'plum',
},
};
export const playerStatsTool: Tool = {
id: 'player-stats',
title: 'Player stats',
description: 'Periodical player statistics',
pageDescription: 'Periodical player statistics in World of Tanks Blitz',
export const playersTool: Tool = {
id: 'players',
button: {
text: 'Lookup player',
color: 'blue',
},
image: 'YelSOfT',
@@ -116,11 +83,7 @@ export const playerStatsTool: Tool = {
export const performanceTool: Tool = {
id: 'performance',
title: 'Performance',
description: 'Tank performance statistics',
pageDescription: 'Best tanks and their performances in World of Tanks Blitz',
button: {
text: 'View tanks',
color: 'jade',
},
image: 'vOKFB03',
@@ -129,12 +92,7 @@ export const performanceTool: Tool = {
export const embedTool: Tool = {
id: 'embed',
title: 'Embed',
description: 'Embeds for streaming',
pageDescription:
'Create embeds for streaming World of Tanks Blitz with extreme customization',
button: {
text: 'Create now',
color: 'red',
},
image: 'Q0faRYg',
@@ -142,20 +100,14 @@ export const embedTool: Tool = {
};
export const homeTool: Tool = {
id: '',
title: 'Home',
button: { color: 'blue', text: '' },
description: '',
id: 'home',
button: { color: 'blue' },
image: 'rUPie9G',
};
export const galleryTool: Tool = {
id: 'gallery',
title: 'Gallery',
description: 'Blitz player avatars',
pageDescription: 'All World of Tanks Blitz player avatars',
button: {
text: 'Browse all',
color: 'gold',
},
image: 'VhKlknk',
@@ -164,12 +116,8 @@ export const galleryTool: Tool = {
export const tierListTool: Tool = {
id: 'tier-list',
title: 'Tier list',
description: 'Rank Blitz tanks',
pageDescription:
'Explore tier lists ranking tanks from best to worst, based on player ratings and statistical performance in World of Tanks Blitz',
stringsId: 'tier_list',
button: {
text: 'Rank tanks',
color: 'orange',
},
image: 'oKKc8Xv',
@@ -178,12 +126,7 @@ export const tierListTool: Tool = {
export const chartsTool: Tool = {
id: 'charts',
title: 'Charts',
description: 'Lots of graphs',
pageDescription:
'Charts and graphs on anything and everything about tanks, players, and events in World of Tanks Blitz',
button: {
text: 'Nerd out',
color: 'bronze',
},
image: '6fhkljA',
@@ -12,6 +12,7 @@ export function applyPitchYawLimits(
pitchLimits: PitchLimits, // deg
yawLimits?: YawLimits, // deg
verticalStabilizer = false,
downVerticalStabilizer = false,
) {
let newPitch = pitch;
let newYaw = yaw;
@@ -22,7 +23,8 @@ export function applyPitchYawLimits(
);
}
const depressionBuff = verticalStabilizer ? degToRad(3) : 0;
const depressionBuff =
verticalStabilizer || downVerticalStabilizer ? degToRad(3) : 0;
const elevationBuff = verticalStabilizer ? degToRad(4) : 0;
let lowerPitch = -degToRad(pitchLimits.max) - depressionBuff;
let upperPitch = -degToRad(pitchLimits.min) + elevationBuff;
@@ -1,6 +1,10 @@
import { assertSecret, type BlitzGlossary } from '@blitzkit/core';
export async function fetchGlossary() {
const response = await fetch(assertSecret(import.meta.env.WOTB_GLOSSARY));
export async function fetchGlossary(locale: string) {
const response = await fetch(
`${assertSecret(
import.meta.env.WOTB_GLOSSARY_DOMAIN,
)}/${locale}${assertSecret(import.meta.env.WOTB_GLOSSARY_PATH)}`,
);
return (await response.json()) as BlitzGlossary;
}
@@ -1,19 +1,20 @@
import { assertSecret } from '@blitzkit/core';
import { DEFAULT_LOCALE, STRINGS, type SupportedLocale } from '@blitzkit/i18n';
const BRANCH_NAMES: Record<string, string> = {
dev: 'beta',
opentest: 'opentest',
preview: 'preview',
};
export function resolveBranchName(locale: string = DEFAULT_LOCALE) {
if (!(locale in STRINGS)) throw new Error(`Unsupported locale: ${locale}`);
export function resolveBranchName() {
const strings = STRINGS[locale as SupportedLocale];
const secret = assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH);
if (
assertSecret(import.meta.env.MODE) === 'development' &&
secret === 'dev'
) {
return 'dev';
return strings.common.branches.local;
} else {
return BRANCH_NAMES[secret] as string | undefined;
return (strings.common.branches as Record<string, string>)[secret] as
| string
| undefined;
}
}
@@ -109,6 +109,7 @@ export function tankCharacteristics(
const hasEngineAccelerator = equipment(117);
const hasCamouflageNet = equipment(115);
const hasImprovedVerticalStabilizer = equipment(122);
const hasDownImprovedVerticalStabilizer = equipment(124);
const hasImprovedSuspension = equipment(123);
const hasTungsten = consumable(45);
@@ -425,7 +426,8 @@ export function tankCharacteristics(
const gunDepression =
gunModelDefinition.pitch.max +
gunDefaultPitch +
(hasImprovedVerticalStabilizer ? 3 : 0);
(hasImprovedVerticalStabilizer ? 3 : 0) +
(hasDownImprovedVerticalStabilizer ? 3 : 0);
const gunElevation =
-gunModelDefinition.pitch.min -
gunDefaultPitch +
@@ -0,0 +1,15 @@
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from '@blitzkit/i18n';
export function getBestLocale() {
const desiredLocale =
localStorage.getItem('preferred-locale') ??
navigator.language.split('-')[0];
const isSupported = (SUPPORTED_LOCALES as unknown as string[]).includes(
desiredLocale,
);
const isDefault = desiredLocale === DEFAULT_LOCALE;
if (!isSupported || isDefault) return undefined;
return desiredLocale;
}
@@ -0,0 +1,6 @@
import { DEFAULT_LOCALE, STRINGS, type SupportedLocale } from '@blitzkit/i18n';
export function getStrings(locale: string = DEFAULT_LOCALE) {
if (locale in STRINGS) return STRINGS[locale as SupportedLocale];
throw new Error(`Unsupported locale: ${locale}`);
}
+39
View File
@@ -0,0 +1,39 @@
import { DEFAULT_LOCALE, unwrapper } from '@blitzkit/i18n';
import { createContext, useContext, type ReactNode } from 'react';
import { getStrings } from '../core/i18n/getStrings';
const LocaleContext = createContext<{
locale: string;
localeRaw: string | undefined;
} | null>(null);
interface LocaleProviderProps extends LocaleAcceptorProps {
children: ReactNode;
}
export function LocaleProvider({ locale, children }: LocaleProviderProps) {
return (
<LocaleContext.Provider
value={{ locale: locale ?? DEFAULT_LOCALE, localeRaw: locale }}
>
{children}
</LocaleContext.Provider>
);
}
export function useLocale() {
const context = useContext(LocaleContext);
if (!context) {
throw new Error('useLocale must be used within a LocaleProvider');
}
const strings = getStrings(context.locale);
const unwrap = unwrapper(context.locale);
return { locale: context.locale, strings, unwrap };
}
export interface LocaleAcceptorProps {
locale: string | undefined;
}
+30 -15
View File
@@ -6,29 +6,18 @@ import Footer from '../components/Footer.astro';
import Navbar from '../components/Navbar.astro';
import Party3 from '../components/Party3.astro';
import './base.css';
import { DEFAULT_LOCALE } from '@blitzkit/i18n';
export interface Props {
noFooter?: boolean;
}
const { noFooter } = Astro.props;
const { pathname } = Astro.url;
const isRoot = pathname === '/';
const locale = Astro.params.locale ?? DEFAULT_LOCALE;
---
<html lang="en">
<html lang={locale}>
<head>
{
isRoot && (
<>
<title>BlitzKit</title>
<meta
name="description"
content="🎉 Tools for everything World of Tanks Blitz"
/>
</>
)
}
<meta property="og:site_name" content="BlitzKit" />
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.png" />
@@ -36,6 +25,32 @@ const isRoot = pathname === '/';
<link rel="sitemap" href="/sitemap-index.xml" />
<Party3 />
<script>
import { SUPPORTED_LOCALES } from '@blitzkit/i18n';
import { getBestLocale } from '../core/i18n/getBestLocale';
const isAPI = window.location.pathname.startsWith('/api');
const isI18nPath = SUPPORTED_LOCALES.some((locale) =>
window.location.pathname.startsWith(`/${locale}`),
);
const bestLocale = getBestLocale();
const currentPathLocale = SUPPORTED_LOCALES.find((locale) =>
window.location.pathname.startsWith(`/${locale}`),
);
if (
!isAPI && // not an api, duh!
localStorage.getItem('preferred-locale') !== null && // no preference, so probably google's crawler
bestLocale !== currentPathLocale // we need to redirect
) {
window.location.pathname = isI18nPath
? window.location.pathname.replace(
`/${currentPathLocale}`,
bestLocale === undefined ? '' : `/${bestLocale}`,
)
: `/${bestLocale}${window.location.pathname}`;
}
</script>
</head>
<body>
@@ -43,7 +58,7 @@ const isRoot = pathname === '/';
<Flex direction="column" minHeight="100vh">
<Navbar />
<Checks client:only="react" />
<Checks {locale} client:only="react" />
<slot />
@@ -3,6 +3,7 @@ import { imgur, ImgurSize } from '@blitzkit/core';
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
import ToolOpenGraph from '../components/ToolOpenGraph.astro';
import { type Tool } from '../constants/tools';
import { getStrings } from '../core/i18n/getStrings';
import BaseLayout from './BaseLayout.astro';
interface Props {
@@ -10,6 +11,12 @@ interface Props {
}
const { tool } = Astro.props;
const { locale } = Astro.params;
const strings = getStrings(locale);
const toolStrings =
strings.website.tools[
(tool.stringsId ?? tool.id) as keyof typeof strings.website.tools
];
---
<ToolOpenGraph tool={tool} />
@@ -33,8 +40,8 @@ const { tool } = Astro.props;
WebkitBackdropFilter: 'blur(4rem)',
}}
>
<Heading size="7">{tool?.title}</Heading>
<Text>{tool?.description}</Text>
<Heading size="7">{toolStrings.name}</Heading>
<Text>{toolStrings.description}</Text>
</Flex>
</Box>
+5 -2
View File
@@ -1,8 +1,11 @@
---
import { DiscordLogoIcon, HomeIcon } from '@radix-ui/react-icons';
import { Button, Flex, Heading, Link, Text } from '@radix-ui/themes';
import { LinkI18n } from '../components/LinkI18n';
import { PageWrapper } from '../components/PageWrapper';
import BaseLayout from '../layouts/BaseLayout.astro';
const { locale } = Astro.params;
---
<BaseLayout>
@@ -12,11 +15,11 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<Text color="gray">Where would you like to go?</Text>
<Flex mt="4" gap="2">
<Link href="/">
<LinkI18n {locale} href="/">
<Button>
<HomeIcon /> Home
</Button>
</Link>
</LinkI18n>
<Link href="https://discord.gg/nDt7AjGJQH">
<Button variant="outline">
<DiscordLogoIcon /> Discord
Loaded 100 of 300 files, more files were not shown because too many files have changed in this diff. Show more