mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-23 19:49:50 +02:00
feat: search page
This commit is contained in:
1 parent
408b561b75
commit
ffa1395b50
70 files changed
+492
-468
No files matched your search
@@ -9,11 +9,7 @@ import {
|
||||
} from '@radix-ui/themes';
|
||||
import { Html } from '@react-three/drei';
|
||||
import { useRef } from 'react';
|
||||
import { radToDeg } from 'three/src/math/MathUtils';
|
||||
import { resolveArmorIndex } from '../../../../../website-legacy/src/core/blitzkit/resolveArmorIndex';
|
||||
import * as App from '../../../../../website-legacy/src/stores/app';
|
||||
import * as Duel from '../../../../../website-legacy/src/stores/duel';
|
||||
import * as TankopediaEphemeral from '../../../../../website-legacy/src/stores/tankopediaEphemeral';
|
||||
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral';
|
||||
import { layerTypeNames } from './ShotDisplayCard';
|
||||
import { ArmorType } from './SpacedArmorScene';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GroupProps } from '@react-three/fiber';
|
||||
import type { GroupProps } from '@react-three/fiber';
|
||||
import { forwardRef } from 'react';
|
||||
import { Group } from 'three';
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { memo, useRef } from 'react';
|
||||
import { Group } from 'three';
|
||||
import { correctZYTuple } from '../../../../../website-legacy/src/core/blitz/correctZYTuple';
|
||||
import { nameToArmorId } from '../../../../../website-legacy/src/core/blitzkit/nameToArmorId';
|
||||
import { resolveArmor } from '../../../../../website-legacy/src/core/blitzkit/resolveThickness';
|
||||
import { useArmor } from '../../../../../website-legacy/src/hooks/useArmor';
|
||||
import { useTankModelDefinition } from '../../../../../website-legacy/src/hooks/useTankModelDefinition';
|
||||
import { useTankTransform } from '../../../../../website-legacy/src/hooks/useTankTransform';
|
||||
import * as Duel from '../../../../../website-legacy/src/stores/duel';
|
||||
import { correctZYTuple } from '../../../core/blitz/correctZYTuple';
|
||||
import { nameToArmorId } from '../../../core/blitzkit/nameToArmorId';
|
||||
import { resolveArmor } from '../../../core/blitzkit/resolveThickness';
|
||||
import { useArmor } from '../../../hooks/useArmor';
|
||||
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition';
|
||||
import { useTankTransform } from '../../../hooks/useTankTransform';
|
||||
import { ModelTankWrapper } from './ModelTankWrapper';
|
||||
import { PrimaryArmorSceneComponent } from './PrimaryArmorSceneComponent';
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { memo, useRef } from 'react';
|
||||
import { Group, Plane, Scene, Vector3 } from 'three';
|
||||
import { correctZYTuple } from '../../../../../website-legacy/src/core/blitz/correctZYTuple';
|
||||
import { nameToArmorId } from '../../../../../website-legacy/src/core/blitzkit/nameToArmorId';
|
||||
import { resolveArmor } from '../../../../../website-legacy/src/core/blitzkit/resolveThickness';
|
||||
import { useArmor } from '../../../../../website-legacy/src/hooks/useArmor';
|
||||
import { useModel } from '../../../../../website-legacy/src/hooks/useModel';
|
||||
import { useTankModelDefinition } from '../../../../../website-legacy/src/hooks/useTankModelDefinition';
|
||||
import { useTankTransform } from '../../../../../website-legacy/src/hooks/useTankTransform';
|
||||
import * as Duel from '../../../../../website-legacy/src/stores/duel';
|
||||
import { correctZYTuple } from '../../../core/blitz/correctZYTuple';
|
||||
import { nameToArmorId } from '../../../core/blitzkit/nameToArmorId';
|
||||
import { resolveArmor } from '../../../core/blitzkit/resolveThickness';
|
||||
import { useArmor } from '../../../hooks/useArmor';
|
||||
import { useModel } from '../../../hooks/useModel';
|
||||
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition';
|
||||
import { useTankTransform } from '../../../hooks/useTankTransform';
|
||||
import { ModelTankWrapper } from './ModelTankWrapper';
|
||||
import { SpacedArmorSceneComponent } from './SpacedArmorSceneComponent';
|
||||
|
||||
|
||||
+7
-9
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
ShellDefinition,
|
||||
resolveNearPenetration,
|
||||
resolvePenetrationCoefficient,
|
||||
type ShellDefinition,
|
||||
} from '@blitzkit/core';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
Plane,
|
||||
ShaderMaterial,
|
||||
} from 'three';
|
||||
import { ArmorUserData, ExternalModuleVariant } from '../..';
|
||||
import { hasEquipment } from '../../../../../../../../website-legacy/src/core/blitzkit/hasEquipment';
|
||||
import { jsxTree } from '../../../../../../../../website-legacy/src/core/blitzkit/jsxTree';
|
||||
import * as Duel from '../../../../../../../../website-legacy/src/stores/duel';
|
||||
import type { ArmorUserData, ExternalModuleVariant } from '../..';
|
||||
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
|
||||
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
|
||||
import { Duel, type EquipmentMatrix } from '../../../../../../stores/duel';
|
||||
import { ArmorType } from '../../../SpacedArmorScene';
|
||||
import fragmentShader from './shaders/fragment.glsl';
|
||||
import vertexShader from './shaders/vertex.glsl';
|
||||
@@ -56,7 +56,7 @@ export function SpacedArmorSubExternal({
|
||||
);
|
||||
}
|
||||
async function handleProtagonistEquipmentChange(
|
||||
equipment: Duel.EquipmentMatrix,
|
||||
equipment: EquipmentMatrix,
|
||||
) {
|
||||
const duel = duelStore.getState();
|
||||
const hasEnhancedArmor = await hasEquipment(
|
||||
@@ -68,9 +68,7 @@ export function SpacedArmorSubExternal({
|
||||
? thickness * 1.03
|
||||
: thickness;
|
||||
}
|
||||
async function handleAntagonistEquipmentChange(
|
||||
equipment: Duel.EquipmentMatrix,
|
||||
) {
|
||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||
const duel = duelStore.getState();
|
||||
const shell = duel.antagonist.shell;
|
||||
const penetration = resolveNearPenetration(shell.penetration);
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { normalizeAngleRad } from '@blitzkit/core';
|
||||
import { ThreeEvent, useThree } from '@react-three/fiber';
|
||||
import { useThree, type ThreeEvent } from '@react-three/fiber';
|
||||
import { memo, useRef } from 'react';
|
||||
import { Group, Plane, Vector2, Vector3 } from 'three';
|
||||
import { applyPitchYawLimits } from '../../../../../website-legacy/src/core/blitz/applyPitchYawLimits';
|
||||
import { correctZYTuple } from '../../../../../website-legacy/src/core/blitz/correctZYTuple';
|
||||
import { hasEquipment } from '../../../../../website-legacy/src/core/blitzkit/hasEquipment';
|
||||
import { modelTransformEvent } from '../../../../../website-legacy/src/core/blitzkit/modelTransform';
|
||||
import { nameToArmorId } from '../../../../../website-legacy/src/core/blitzkit/nameToArmorId';
|
||||
import { resolveArmor } from '../../../../../website-legacy/src/core/blitzkit/resolveThickness';
|
||||
import { useArmor } from '../../../../../website-legacy/src/hooks/useArmor';
|
||||
import { useModel } from '../../../../../website-legacy/src/hooks/useModel';
|
||||
import { useTankModelDefinition } from '../../../../../website-legacy/src/hooks/useTankModelDefinition';
|
||||
import { useTankTransform } from '../../../../../website-legacy/src/hooks/useTankTransform';
|
||||
import * as Duel from '../../../../../website-legacy/src/stores/duel';
|
||||
import * as TankopediaEphemeral from '../../../../../website-legacy/src/stores/tankopediaEphemeral';
|
||||
import * as TankopediaPersistent from '../../../../../website-legacy/src/stores/tankopediaPersistent';
|
||||
import { applyPitchYawLimits } from '../../../core/blitz/applyPitchYawLimits';
|
||||
import { correctZYTuple } from '../../../core/blitz/correctZYTuple';
|
||||
import { hasEquipment } from '../../../core/blitzkit/hasEquipment';
|
||||
import { modelTransformEvent } from '../../../core/blitzkit/modelTransform';
|
||||
import { nameToArmorId } from '../../../core/blitzkit/nameToArmorId';
|
||||
import { resolveArmor } from '../../../core/blitzkit/resolveThickness';
|
||||
import { useArmor } from '../../../hooks/useArmor';
|
||||
import { useModel } from '../../../hooks/useModel';
|
||||
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition';
|
||||
import { useTankTransform } from '../../../hooks/useTankTransform';
|
||||
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral';
|
||||
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent';
|
||||
import { ModelTankWrapper } from './ModelTankWrapper';
|
||||
import { ArmorType } from './SpacedArmorScene';
|
||||
import { StaticArmorSceneComponent } from './StaticArmorSceneComponent';
|
||||
@@ -56,13 +55,13 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
? undefined
|
||||
: gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y;
|
||||
const showPrimaryArmor = TankopediaPersistent.use(
|
||||
(state) => state.model.visual.showPrimaryArmor,
|
||||
(state) => state.showPrimaryArmor,
|
||||
);
|
||||
const showSpacedArmor = TankopediaPersistent.use(
|
||||
(state) => state.model.visual.showSpacedArmor,
|
||||
(state) => state.showSpacedArmor,
|
||||
);
|
||||
const showExternalModules = TankopediaPersistent.use(
|
||||
(state) => state.model.visual.showExternalModules,
|
||||
(state) => state.showExternalModules,
|
||||
);
|
||||
const isDynamicArmorActive = Duel.use((state) =>
|
||||
state.protagonist.consumables.includes(73),
|
||||
|
||||
@@ -2,11 +2,10 @@ import {
|
||||
asset,
|
||||
permanentSkills,
|
||||
skillDefinitions,
|
||||
Tier,
|
||||
TIER_ROMAN_NUMERALS,
|
||||
type Tier,
|
||||
} from '@blitzkit/core';
|
||||
import { Flex } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { GenericTankComponentButton } from './ModuleButtons/GenericTankComponentButton';
|
||||
|
||||
interface CrewSkillManagerProps {
|
||||
@@ -14,12 +13,12 @@ interface CrewSkillManagerProps {
|
||||
onChange?: (skillLevels: Record<string, number>) => void;
|
||||
}
|
||||
|
||||
const awaitedSkillDefinitions = await skillDefinitions;
|
||||
|
||||
export function CrewSkillManager({
|
||||
skillLevels,
|
||||
onChange,
|
||||
}: CrewSkillManagerProps) {
|
||||
const awaitedSkillDefinitions = use(skillDefinitions);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2">
|
||||
{Object.entries(awaitedSkillDefinitions.classes).map(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { asset, consumableDefinitions } from '@blitzkit/core';
|
||||
import { ClockIcon, ReloadIcon } from '@radix-ui/react-icons';
|
||||
import { Flex, Text } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { useDelta } from '../../../../website-legacy/src/hooks/useDelta';
|
||||
import { GenericTankComponentButton } from './GenericTankComponentButton';
|
||||
import { TankComponentButtonProps } from './TankComponentButton';
|
||||
@@ -12,13 +11,14 @@ interface ConsumableButtonProps extends TankComponentButtonProps {
|
||||
cooldown?: number;
|
||||
}
|
||||
|
||||
const awaitedConsumableDefinitions = await consumableDefinitions;
|
||||
|
||||
export function ConsumableButton({
|
||||
consumable,
|
||||
cooldown,
|
||||
duration,
|
||||
...props
|
||||
}: ConsumableButtonProps) {
|
||||
const awaitedConsumableDefinitions = use(consumableDefinitions);
|
||||
const cooldownDelta = useDelta(cooldown ?? 0);
|
||||
const durationDelta = useDelta(duration ?? 0);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { asset, equipmentDefinitions } from '@blitzkit/core';
|
||||
import { use } from 'react';
|
||||
import { GenericTankComponentButton } from './GenericTankComponentButton';
|
||||
import { TankComponentButtonProps } from './TankComponentButton';
|
||||
|
||||
@@ -7,9 +6,9 @@ interface EquipmentButtonProps extends TankComponentButtonProps {
|
||||
equipment: number;
|
||||
}
|
||||
|
||||
export function EquipmentButton({ equipment, ...props }: EquipmentButtonProps) {
|
||||
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
|
||||
const awaitedEquipmentDefinitions = await equipmentDefinitions;
|
||||
|
||||
export function EquipmentButton({ equipment, ...props }: EquipmentButtonProps) {
|
||||
return (
|
||||
<GenericTankComponentButton
|
||||
tooltip={awaitedEquipmentDefinitions.equipments[equipment].name}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import {
|
||||
TankComponentButton,
|
||||
TankComponentButtonProps,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { asset, provisionDefinitions } from '@blitzkit/core';
|
||||
import { use } from 'react';
|
||||
import { GenericTankComponentButton } from './GenericTankComponentButton';
|
||||
import { TankComponentButtonProps } from './TankComponentButton';
|
||||
|
||||
@@ -7,9 +6,9 @@ interface ProvisionButtonProps extends TankComponentButtonProps {
|
||||
provision: number;
|
||||
}
|
||||
|
||||
export function ProvisionButton({ provision, ...props }: ProvisionButtonProps) {
|
||||
const awaitedProvisionDefinitions = use(provisionDefinitions);
|
||||
const awaitedProvisionDefinitions = await provisionDefinitions;
|
||||
|
||||
export function ProvisionButton({ provision, ...props }: ProvisionButtonProps) {
|
||||
return (
|
||||
<GenericTankComponentButton
|
||||
tooltip={awaitedProvisionDefinitions[provision].name}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { patreonLoginUrl } from '@blitzkit/core';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { PersonIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
IconButton,
|
||||
Inset,
|
||||
Link,
|
||||
Popover,
|
||||
Separator,
|
||||
Skeleton,
|
||||
Text,
|
||||
} from '@radix-ui/themes';
|
||||
import { Suspense } from 'react';
|
||||
import { $patreonLogin } from '../stores/patreonLogin';
|
||||
import { $wargamingLogin } from '../stores/wargamingLogin';
|
||||
import { BlitzKitTheme } from './BlitzKitTheme';
|
||||
import { PatreonIcon } from './PatreonIcon';
|
||||
import { WargamingAccountName } from './WargamingAccountName';
|
||||
import { WargamingIcon } from './WargamingIcon';
|
||||
import { WargamingLoginButton } from './WargamingLoginButton';
|
||||
|
||||
export function NavbarAccount() {
|
||||
const patreonLogin = useStore($patreonLogin);
|
||||
const wargamingLogin = useStore($wargamingLogin);
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="ghost" color="gray">
|
||||
<PersonIcon />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
<BlitzKitTheme>
|
||||
<Popover.Content
|
||||
align="end"
|
||||
width="320px"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{(patreonLogin.token || wargamingLogin.token) && (
|
||||
<Flex direction="column" gap="2">
|
||||
<Text align="center" color="gray">
|
||||
Logged in accounts
|
||||
</Text>
|
||||
|
||||
{patreonLogin.token && (
|
||||
<Flex align="center" gap="2" justify="center">
|
||||
<PatreonIcon width={15} height={15} />
|
||||
<Text>Patreon</Text>
|
||||
<Button
|
||||
color="red"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
$patreonLogin.set({ token: undefined });
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{wargamingLogin.token && (
|
||||
<Flex align="center" gap="2" justify="center">
|
||||
<WargamingIcon width={15} height={15} />
|
||||
<Text>
|
||||
<Suspense fallback={<Skeleton height="1em" width="5rem" />}>
|
||||
<WargamingAccountName />
|
||||
</Suspense>
|
||||
</Text>
|
||||
<Button
|
||||
color="red"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
$wargamingLogin.set({ token: undefined });
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{!patreonLogin.token !== !wargamingLogin.token && (
|
||||
<Inset side="x">
|
||||
<Separator my="4" size="4" />
|
||||
</Inset>
|
||||
)}
|
||||
|
||||
{(!patreonLogin.token || !wargamingLogin.token) && (
|
||||
<Flex direction="column" gap="2">
|
||||
<Text align="center" color="gray">
|
||||
Log in with...
|
||||
</Text>
|
||||
|
||||
{!patreonLogin.token && (
|
||||
<Link style={{ width: '100%' }} href={patreonLoginUrl()}>
|
||||
<Button style={{ width: '100%' }} color="tomato">
|
||||
<PatreonIcon width={15} height={15} /> Patreon
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!wargamingLogin.token && <WargamingLoginButton />}
|
||||
</Flex>
|
||||
)}
|
||||
</Popover.Content>
|
||||
</BlitzKitTheme>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { patreonLoginUrl } from '@blitzkit/core';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { PersonIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
IconButton,
|
||||
Inset,
|
||||
Link,
|
||||
Popover,
|
||||
Separator,
|
||||
Skeleton,
|
||||
Text,
|
||||
} from '@radix-ui/themes';
|
||||
import { Suspense } from 'react';
|
||||
import { App } from '../../stores/app';
|
||||
import { $patreonLogin } from '../../stores/patreonLogin';
|
||||
import { $wargamingLogin } from '../../stores/wargamingLogin';
|
||||
import { BlitzKitTheme } from '../BlitzKitTheme';
|
||||
import { PatreonIcon } from '../PatreonIcon';
|
||||
import { WargamingAccountName } from '../WargamingAccountName';
|
||||
import { WargamingIcon } from '../WargamingIcon';
|
||||
import { WargamingLoginButton } from '../WargamingLoginButton';
|
||||
|
||||
export function NavbarAccount() {
|
||||
const patreon = useStore($patreonLogin);
|
||||
const wargaming = useStore($wargamingLogin);
|
||||
|
||||
return (
|
||||
<App.Provider>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="ghost" color="gray">
|
||||
<PersonIcon />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
<BlitzKitTheme>
|
||||
<Popover.Content
|
||||
align="end"
|
||||
width="320px"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{(patreon.token || wargaming.token) && (
|
||||
<Flex direction="column" gap="2">
|
||||
<Text align="center" color="gray">
|
||||
Logged in accounts
|
||||
</Text>
|
||||
|
||||
{patreon.token && (
|
||||
<Flex align="center" gap="2" justify="center">
|
||||
<PatreonIcon width={15} height={15} />
|
||||
<Text>Patreon</Text>
|
||||
<Button
|
||||
color="red"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
$patreonLogin.setKey('token', undefined);
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{wargaming.token && (
|
||||
<Flex align="center" gap="2" justify="center">
|
||||
<WargamingIcon width={15} height={15} />
|
||||
<Text>
|
||||
<Suspense
|
||||
fallback={<Skeleton height="1em" width="5rem" />}
|
||||
>
|
||||
<WargamingAccountName />
|
||||
</Suspense>
|
||||
</Text>
|
||||
<Button
|
||||
color="red"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
$wargamingLogin.setKey('token', undefined);
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{!patreon.token !== !wargaming.token && (
|
||||
<Inset side="x">
|
||||
<Separator my="4" size="4" />
|
||||
</Inset>
|
||||
)}
|
||||
|
||||
{(!patreon.token || !wargaming.token) && (
|
||||
<Flex direction="column" gap="2">
|
||||
<Text align="center" color="gray">
|
||||
Log in with...
|
||||
</Text>
|
||||
|
||||
{!patreon.token && (
|
||||
<Link style={{ width: '100%' }} href={patreonLoginUrl()}>
|
||||
<Button style={{ width: '100%' }} color="tomato">
|
||||
<PatreonIcon width={15} height={15} /> Patreon
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!wargaming.token && <WargamingLoginButton />}
|
||||
</Flex>
|
||||
)}
|
||||
</Popover.Content>
|
||||
</BlitzKitTheme>
|
||||
</Popover.Root>
|
||||
</App.Provider>
|
||||
);
|
||||
}
|
||||
@@ -10,19 +10,13 @@ import {
|
||||
Separator,
|
||||
Text,
|
||||
} from '@radix-ui/themes';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import { useAdExempt } from '../hooks/useAdExempt';
|
||||
import { $patreonLogin } from '../stores/patreonLogin';
|
||||
import { PatreonIcon } from './PatreonIcon';
|
||||
|
||||
export function Plugs() {
|
||||
const [hasPatreon, setHasPatreon] = useState(false);
|
||||
const exempt = useAdExempt();
|
||||
const patreonLogin = useStore($patreonLogin);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setHasPatreon(patreonLogin !== undefined);
|
||||
}, [patreonLogin]);
|
||||
const hasPatreon = useStore($patreonLogin).token !== undefined;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MinusCircledIcon } from '@radix-ui/react-icons';
|
||||
import { Callout, Flex } from '@radix-ui/themes';
|
||||
import { ExperimentIcon } from '../../../../../components/ExperimentIcon';
|
||||
import * as Duel from '../../../../../stores/duel';
|
||||
|
||||
export function CalloutsSection() {
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
|
||||
if (!tank.testing && !tank.deprecated) return null;
|
||||
|
||||
return (
|
||||
<Flex direction="column" align="center" gap="2" mb="4">
|
||||
{tank.deprecated && (
|
||||
<Callout.Root color="red">
|
||||
<Callout.Icon>
|
||||
<MinusCircledIcon />
|
||||
</Callout.Icon>
|
||||
<Callout.Text>
|
||||
This tank is deprecated and may display undefined behavior.
|
||||
</Callout.Text>
|
||||
</Callout.Root>
|
||||
)}
|
||||
|
||||
{tank.testing && (
|
||||
<Callout.Root color="red">
|
||||
<Callout.Icon>
|
||||
<ExperimentIcon style={{ width: '1em', height: '1em' }} />
|
||||
</Callout.Icon>
|
||||
<Callout.Text>
|
||||
Tanks in testing are subject to change and may not represent the
|
||||
final product.
|
||||
</Callout.Text>
|
||||
</Callout.Root>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Flex, Heading } from '@radix-ui/themes';
|
||||
import { Characteristics } from '../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Characteristics';
|
||||
import { Consumables } from '../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Characteristics/components/Consumables';
|
||||
import { Equipment } from '../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Characteristics/components/Equipment';
|
||||
import { Miscellaneous } from '../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Characteristics/components/Miscellaneous';
|
||||
import { Modules } from '../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Characteristics/components/Modules';
|
||||
import { Provisions } from '../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Characteristics/components/Provisions';
|
||||
import { Skills } from '../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Characteristics/components/Skills';
|
||||
|
||||
export function CharacteristicsSection() {
|
||||
return (
|
||||
<Flex mt="4" px="4" style={{ boxSizing: 'border-box' }} gap="6">
|
||||
<Flex
|
||||
flexGrow="1"
|
||||
gap="9"
|
||||
justify="center"
|
||||
align={{ initial: 'center', sm: 'start' }}
|
||||
direction={{ initial: 'column', sm: 'row' }}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="6"
|
||||
maxWidth="21rem"
|
||||
align={{ initial: 'center', sm: 'start' }}
|
||||
>
|
||||
<Heading>Configuration</Heading>
|
||||
<Modules />
|
||||
<Equipment />
|
||||
<Provisions />
|
||||
<Consumables />
|
||||
<Skills />
|
||||
<Miscellaneous />
|
||||
</Flex>
|
||||
<Flex
|
||||
flexGrow="1"
|
||||
maxWidth={{ initial: '20rem', md: '40rem' }}
|
||||
direction="column"
|
||||
gap="6"
|
||||
width="100%"
|
||||
>
|
||||
<Heading align={{ initial: 'center', sm: 'left' }}>
|
||||
Characteristics
|
||||
</Heading>
|
||||
<Characteristics />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
asset,
|
||||
consumableDefinitions,
|
||||
gameDefinitions,
|
||||
provisionDefinitions,
|
||||
} from '@blitzkit/core';
|
||||
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
|
||||
import * as Duel from '../../../../../../stores/duel';
|
||||
|
||||
const awaitedGameDefinitions = await gameDefinitions;
|
||||
const awaitedConsumableDefinitions = await consumableDefinitions;
|
||||
const awaitedProvisionDefinitions = await provisionDefinitions;
|
||||
|
||||
export function GameModeSection() {
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
const roles = Object.entries(tank.roles);
|
||||
|
||||
if (roles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="4" align="center">
|
||||
<Heading size="6">Game mode abilities</Heading>
|
||||
|
||||
<Flex justify="center" gap="4" wrap="wrap" px="4">
|
||||
{roles.map(([key, value]) => {
|
||||
const id = Number(key);
|
||||
const gameMode = awaitedGameDefinitions.gameModes[id];
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={id}
|
||||
width="16rem"
|
||||
height="20rem"
|
||||
overflow="hidden"
|
||||
direction="column"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-3)',
|
||||
background: `url(${asset(
|
||||
`icons/game_mode_banners/${id}.webp`,
|
||||
)})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
flexGrow="1"
|
||||
style={{
|
||||
background: `url(${asset(
|
||||
`icons/game_mode_banners/${id}.webp`,
|
||||
)})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex
|
||||
p="3"
|
||||
direction="column"
|
||||
height="98px"
|
||||
flexShrink="0"
|
||||
style={{
|
||||
backdropFilter: 'blur(4rem)',
|
||||
WebkitBackdropFilter: 'blur(4rem)',
|
||||
}}
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text size="4" weight="bold">
|
||||
{gameMode.name}
|
||||
</Text>
|
||||
|
||||
<Flex direction="column">
|
||||
{awaitedGameDefinitions.roles[value].provisions.map((id) => {
|
||||
const provisions = awaitedProvisionDefinitions[id];
|
||||
|
||||
return (
|
||||
<Text color="gray" wrap="nowrap" key={id}>
|
||||
<Flex align="center" gap="1">
|
||||
<img
|
||||
style={{
|
||||
width: '1.5em',
|
||||
height: '1.5em',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
src={asset(`icons/provisions/${id}.webp`)}
|
||||
alt={provisions.name}
|
||||
/>
|
||||
{provisions.name}
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
{awaitedGameDefinitions.roles[value].consumables.map((id) => {
|
||||
const consumable = awaitedConsumableDefinitions[id];
|
||||
|
||||
return (
|
||||
<Text color="gray" wrap="nowrap" key={id}>
|
||||
<Flex align="center" gap="1">
|
||||
<img
|
||||
style={{
|
||||
width: '1.5em',
|
||||
height: '1.5em',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
src={asset(`icons/consumables/${id}.webp`)}
|
||||
alt={consumable.name}
|
||||
/>
|
||||
{consumable.name}
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { asset } from '@blitzkit/core';
|
||||
import { IconButton } from '@radix-ui/themes';
|
||||
import { invalidate } from '@react-three/fiber';
|
||||
import { useArmor } from '../../../../../../hooks/useArmor';
|
||||
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
|
||||
|
||||
export function DynamicArmorSwitcher() {
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
const mutateDuel = Duel.useMutation();
|
||||
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
|
||||
const isDynamicArmorActive = Duel.use((state) =>
|
||||
state.protagonist.consumables.includes(73),
|
||||
);
|
||||
const { hasDynamicArmor } = useArmor(tank.id);
|
||||
|
||||
if (!hasDynamicArmor) return null;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
color={isDynamicArmorActive ? undefined : 'gray'}
|
||||
variant="soft"
|
||||
size={{ initial: '2', sm: '3' }}
|
||||
onClick={() => {
|
||||
invalidate();
|
||||
mutateDuel((draft) => {
|
||||
if (draft.protagonist.consumables.includes(73)) {
|
||||
draft.protagonist.consumables =
|
||||
draft.protagonist.consumables.filter((id) => id !== 73);
|
||||
} else {
|
||||
if (draft.protagonist.consumables.length === tank.consumables) {
|
||||
draft.protagonist.consumables[tank.consumables - 1] = 73;
|
||||
} else {
|
||||
draft.protagonist.consumables.push(73);
|
||||
}
|
||||
}
|
||||
});
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="Dynamic Armor"
|
||||
src={asset('icons/consumables/73.webp')}
|
||||
style={{
|
||||
width: '50%',
|
||||
height: '50%',
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const container = style({
|
||||
display: 'none',
|
||||
|
||||
':focus-within': {
|
||||
display: 'flex',
|
||||
},
|
||||
|
||||
selectors: {
|
||||
'.tank-sandbox-container:hover &': {
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
});
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import { HeightIcon, WidthIcon } from '@radix-ui/react-icons';
|
||||
import { Flex, TextField } from '@radix-ui/themes';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { degToRad, radToDeg } from 'three/src/math/MathUtils';
|
||||
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { modelTransformEvent, ModelTransformEventData } from '../../../../../../core/blitzkit/modelTransform';
|
||||
import { useEquipment } from '../../../../../../hooks/useEquipment';
|
||||
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
|
||||
import * as Duel from '../../../../../../stores/duel';
|
||||
import * as styles from './index.css';
|
||||
|
||||
export function QuickInputs() {
|
||||
const mutateDuel = Duel.useMutation();
|
||||
const protagonist = Duel.use((state) => state.protagonist);
|
||||
const yawInput = useRef<HTMLInputElement>(null);
|
||||
const pitchInput = useRef<HTMLInputElement>(null);
|
||||
const tankModelDefinition = useTankModelDefinition();
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
const initialGunPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
|
||||
const hasImprovedVerticalStabilizer = useEquipment(122);
|
||||
|
||||
useEffect(() => {
|
||||
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
|
||||
}, [protagonist.yaw]);
|
||||
useEffect(() => {
|
||||
pitchInput.current!.value = (
|
||||
-radToDeg(protagonist.pitch) + initialGunPitch
|
||||
).toFixed(1);
|
||||
}, [protagonist.pitch]);
|
||||
useEffect(() => {
|
||||
function handleTransformEvent({ pitch, yaw }: ModelTransformEventData) {
|
||||
if (!pitchInput.current || !yawInput.current) return;
|
||||
|
||||
pitchInput.current.value = (
|
||||
-radToDeg(pitch) + (tankModelDefinition.turretRotation?.pitch ?? 0)
|
||||
).toFixed(1);
|
||||
|
||||
if (yaw === undefined) return;
|
||||
|
||||
yawInput.current.value = radToDeg(yaw).toFixed(1);
|
||||
}
|
||||
|
||||
modelTransformEvent.on(handleTransformEvent);
|
||||
|
||||
return () => {
|
||||
modelTransformEvent.off(handleTransformEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
gap="2"
|
||||
width="10rem"
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: 16,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
className={styles.container}
|
||||
>
|
||||
<TextField.Root
|
||||
size="1"
|
||||
radius="full"
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
onBlur={() => {
|
||||
const value = Number(yawInput.current!.value);
|
||||
if (isNaN(value)) {
|
||||
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
|
||||
return;
|
||||
}
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
protagonist.pitch,
|
||||
degToRad(value),
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
mutateDuel((state) => {
|
||||
state.protagonist.pitch = pitch;
|
||||
state.protagonist.yaw = yaw;
|
||||
});
|
||||
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
yawInput.current?.blur();
|
||||
}
|
||||
}}
|
||||
onFocus={() => yawInput.current?.focus()}
|
||||
ref={yawInput}
|
||||
>
|
||||
<TextField.Slot
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<WidthIcon />
|
||||
</TextField.Slot>
|
||||
<TextField.Slot style={{ userSelect: 'none' }}>°</TextField.Slot>
|
||||
</TextField.Root>
|
||||
|
||||
<TextField.Root
|
||||
size="1"
|
||||
radius="full"
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
onBlur={() => {
|
||||
const value = Number(pitchInput.current!.value);
|
||||
if (isNaN(value)) {
|
||||
pitchInput.current!.value = (
|
||||
-radToDeg(protagonist.pitch) + initialGunPitch
|
||||
).toFixed(1);
|
||||
return;
|
||||
}
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
degToRad(-value + initialGunPitch),
|
||||
protagonist.yaw,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
mutateDuel((state) => {
|
||||
state.protagonist.pitch = pitch;
|
||||
state.protagonist.yaw = yaw;
|
||||
});
|
||||
pitchInput.current!.value = (
|
||||
-radToDeg(protagonist.pitch) + initialGunPitch
|
||||
).toFixed(1);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
pitchInput.current?.blur();
|
||||
}
|
||||
}}
|
||||
onFocus={() => pitchInput.current?.focus()}
|
||||
ref={pitchInput}
|
||||
>
|
||||
<TextField.Slot
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<HeightIcon />
|
||||
</TextField.Slot>
|
||||
<TextField.Slot
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
°
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import {
|
||||
asset,
|
||||
imgur,
|
||||
resolveNearPenetration,
|
||||
resolvePenetrationCoefficient,
|
||||
TIER_ROMAN_NUMERALS,
|
||||
uniqueGuns,
|
||||
} from '@blitzkit/core';
|
||||
import {
|
||||
CameraIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EnterFullScreenIcon,
|
||||
ExitFullScreenIcon,
|
||||
EyeOpenIcon,
|
||||
GearIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DropdownMenu,
|
||||
Flex,
|
||||
IconButton,
|
||||
Popover,
|
||||
SegmentedControl,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@radix-ui/themes';
|
||||
import { invalidate } from '@react-three/fiber';
|
||||
import { type RefObject, useState } from 'react';
|
||||
import { ENVIRONMENTS } from '../../../../../constants/lightingEnvironments';
|
||||
import { Pose, poseEvent } from '../../../../../core/blitzkit/pose';
|
||||
import { useEquipment } from '../../../../../hooks/useEquipment';
|
||||
import { useFullScreen } from '../../../../../hooks/useFullScreen';
|
||||
import { useFullscreenAvailability } from '../../../../../hooks/useFullscreenAvailability';
|
||||
import { useStoreBoolean } from '../../../../../hooks/useStoreBoolean';
|
||||
import { useStoreKey } from '../../../../../hooks/useStoreKey';
|
||||
import { $app } from '../../../../../stores/app';
|
||||
import { $tankopediaPersistent } from '../../../../../stores/tankopediaPersistent';
|
||||
import { TankopediaDisplay } from '../../../../../stores/tankopediaPersistent/constants';
|
||||
import type { ThicknessRange } from '../../../../Armor/components/StaticArmor';
|
||||
import { ModuleButton } from '../../../../ModuleButtons/ModuleButton';
|
||||
import { SmallTankIcon } from '../../../../SmallTankIcon';
|
||||
import { TankSearch } from '../../../../TankSearch';
|
||||
import { DynamicArmorSwitcher } from './components/DynamicArmorSwitcher';
|
||||
|
||||
interface OptionsProps {
|
||||
thicknessRange: ThicknessRange;
|
||||
canvas: RefObject<HTMLCanvasElement>;
|
||||
}
|
||||
|
||||
export function Options({ thicknessRange, canvas }: OptionsProps) {
|
||||
// const { display } = useStore($tankopediaPersistent, { keys: ['display'] });
|
||||
const display = useStoreKey($tankopediaPersistent, 'display');
|
||||
const isFullScreen = useFullScreen();
|
||||
const showGrid = useStoreBoolean($tankopediaPersistent, 'showGrid');
|
||||
const showEnvironment = useStoreBoolean(
|
||||
$tankopediaPersistent,
|
||||
'showEnvironment',
|
||||
);
|
||||
const greenPenetration = useStoreBoolean(
|
||||
$tankopediaPersistent,
|
||||
'greenPenetration',
|
||||
);
|
||||
const wireframe = useStoreBoolean($tankopediaPersistent, 'wireframe');
|
||||
const opaque = useStoreBoolean($tankopediaPersistent, 'opaque');
|
||||
const fullScreenAvailable = useFullscreenAvailability();
|
||||
const environment = useStoreKey($tankopediaPersistent, 'environment');
|
||||
const developerMode = useStoreBoolean($app, 'developerMode');
|
||||
const protagonistTank = Duel.use((state) => state.protagonist.tank);
|
||||
const antagonistGun = Duel.use((state) => state.antagonist.gun);
|
||||
const antagonistShell = Duel.use((state) => state.antagonist.shell);
|
||||
const [antagonistSelectorOpen, setAntagonistSelectorOpen] = useState(false);
|
||||
const antagonistTank = Duel.use((state) => state.antagonist.tank);
|
||||
const hasCalibratedShells = useEquipment(103, true);
|
||||
const [tab, setTab] = useState('search');
|
||||
const mutateDuel = Duel.useMutation();
|
||||
const hasEnhancedArmor = useEquipment(110);
|
||||
const antagonistUniqueGuns = uniqueGuns(antagonistTank.turrets);
|
||||
|
||||
return (
|
||||
<>
|
||||
<QuickInputs />
|
||||
|
||||
{display === TankopediaDisplay.StaticArmor && (
|
||||
<Thicknesses thicknessRange={thicknessRange} />
|
||||
)}
|
||||
|
||||
{display === TankopediaDisplay.DynamicArmor && (
|
||||
<Flex
|
||||
gap="2"
|
||||
direction="column"
|
||||
top="50%"
|
||||
right="3"
|
||||
style={{ position: 'absolute', transform: 'translateY(-50%)' }}
|
||||
align="end"
|
||||
>
|
||||
<Text color="gray" size={{ initial: '1', sm: '2' }}>
|
||||
{(
|
||||
resolvePenetrationCoefficient(
|
||||
hasCalibratedShells,
|
||||
antagonistShell.type,
|
||||
) * resolveNearPenetration(antagonistShell.penetration)
|
||||
).toFixed(0)}
|
||||
mm
|
||||
</Text>
|
||||
{antagonistUniqueGuns.length > 1 && (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton
|
||||
radius="full"
|
||||
variant="soft"
|
||||
color="gray"
|
||||
size={{ initial: '2', sm: '3' }}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<Text
|
||||
size="1"
|
||||
color="gray"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
mr="2"
|
||||
mb="1"
|
||||
>
|
||||
{TIER_ROMAN_NUMERALS[antagonistGun.tier]}
|
||||
</Text>
|
||||
|
||||
<img
|
||||
alt="Antagonist Gun"
|
||||
src={asset('icons/modules/gun.webp')}
|
||||
style={{
|
||||
width: '65%',
|
||||
height: '65%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content side="left" align="center">
|
||||
<Flex gap="2">
|
||||
{[...antagonistUniqueGuns]
|
||||
.reverse()
|
||||
.map(({ gun, turret }, index) => (
|
||||
<ModuleButton
|
||||
module="gun"
|
||||
key={gun.id}
|
||||
onClick={() =>
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist.turret = turret;
|
||||
draft.antagonist.gun = gun;
|
||||
draft.antagonist.shell = gun.shells[0];
|
||||
})
|
||||
}
|
||||
selected={gun.id === antagonistGun.id}
|
||||
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
)}
|
||||
<Flex
|
||||
direction="column"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-full)',
|
||||
}}
|
||||
overflow="hidden"
|
||||
>
|
||||
{antagonistGun.shells.map((thisShell, shellIndex) => (
|
||||
<IconButton
|
||||
color={thisShell.id === antagonistShell.id ? undefined : 'gray'}
|
||||
variant="soft"
|
||||
key={thisShell.id}
|
||||
size={{ initial: '2', sm: '3' }}
|
||||
radius="none"
|
||||
style={{
|
||||
marginTop: shellIndex === 0 ? 0 : -1,
|
||||
}}
|
||||
onClick={() => {
|
||||
invalidate();
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist.shell = thisShell;
|
||||
});
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={thisShell.name}
|
||||
src={asset(`icons/shells/${thisShell.icon}.webp`)}
|
||||
style={{
|
||||
width: '50%',
|
||||
height: '50%',
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
direction="column"
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
}}
|
||||
overflow="hidden"
|
||||
>
|
||||
<IconButton
|
||||
color={hasCalibratedShells ? undefined : 'gray'}
|
||||
variant="soft"
|
||||
size={{ initial: '2', sm: '3' }}
|
||||
radius="none"
|
||||
onClick={() => {
|
||||
invalidate();
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist.equipmentMatrix[0][0] = hasCalibratedShells
|
||||
? 0
|
||||
: 1;
|
||||
});
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="Calibrated Shells"
|
||||
src={asset('icons/equipment/103.webp')}
|
||||
style={{
|
||||
width: '50%',
|
||||
height: '50%',
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color={hasEnhancedArmor ? undefined : 'gray'}
|
||||
variant="soft"
|
||||
size={{ initial: '2', sm: '3' }}
|
||||
style={{ marginTop: -1 }}
|
||||
radius="none"
|
||||
onClick={() => {
|
||||
invalidate();
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist.equipmentMatrix[1][1] = hasEnhancedArmor
|
||||
? 0
|
||||
: -1;
|
||||
});
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="Enhanced Armor"
|
||||
src={asset('icons/equipment/110.webp')}
|
||||
style={{
|
||||
width: '50%',
|
||||
height: '50%',
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</Flex>
|
||||
|
||||
<DynamicArmorSwitcher />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
position="absolute"
|
||||
bottom="4"
|
||||
left="50%"
|
||||
style={{ transform: 'translateX(-50%)' }}
|
||||
>
|
||||
{display === TankopediaDisplay.DynamicArmor && (
|
||||
<Flex align="center" gap="2">
|
||||
<Text color="gray" size="2">
|
||||
Shooter:
|
||||
</Text>
|
||||
<Dialog.Root
|
||||
open={antagonistSelectorOpen}
|
||||
onOpenChange={setAntagonistSelectorOpen}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="ghost">
|
||||
<Flex gap="2" align="center">
|
||||
<SmallTankIcon id={antagonistTank.id} size={16} />
|
||||
{antagonistTank.name}
|
||||
</Flex>
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content>
|
||||
<Dialog.Title align="center">Select tank</Dialog.Title>
|
||||
|
||||
<Tabs.Root
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist.tank = tank;
|
||||
draft.antagonist.engine = tank.engines.at(-1)!;
|
||||
draft.antagonist.track = tank.tracks.at(-1)!;
|
||||
draft.antagonist.turret = tank.turrets.at(-1)!;
|
||||
draft.antagonist.gun =
|
||||
draft.antagonist.turret.guns.at(-1)!;
|
||||
draft.antagonist.shell = draft.antagonist.gun.shells[0];
|
||||
});
|
||||
setAntagonistSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Tabs.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex gap="3" align="center" mt="2">
|
||||
<SegmentedControl.Root
|
||||
value={`${display}`}
|
||||
onValueChange={(value) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.display = Number(value);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SegmentedControl.Item value={`${TankopediaDisplay.Model}`}>
|
||||
<Tooltip content="Model">
|
||||
<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">
|
||||
<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">
|
||||
<Flex height="100%" align="center">
|
||||
<img src={imgur('VQ4uDno')} style={{ height: '1.25em' }} />
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
</SegmentedControl.Item>
|
||||
</SegmentedControl.Root>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<IconButton variant="ghost" color="gray">
|
||||
<EyeOpenIcon />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
|
||||
Hull down
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
|
||||
Face hug
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
|
||||
Default
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<IconButton variant="ghost" color="gray">
|
||||
<GearIcon />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
{display === TankopediaDisplay.DynamicArmor && (
|
||||
<>
|
||||
<DropdownMenu.Label>Armor</DropdownMenu.Label>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={greenPenetration}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.greenPenetration = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Green penetration
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={opaque}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.opaque = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Opaque
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
{developerMode && (
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={wireframe}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.wireframe = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<b>DEV:</b> Wireframe
|
||||
</DropdownMenu.CheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenu.Label>Environment</DropdownMenu.Label>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={showGrid}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.showGrid = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Show grid
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={showEnvironment}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.showEnvironment = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
View environment
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent>
|
||||
<DropdownMenu.RadioGroup value={environment}>
|
||||
{ENVIRONMENTS.map((environment) => (
|
||||
<DropdownMenu.RadioItem
|
||||
key={environment}
|
||||
value={environment}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.environment = environment;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{environment[0].toUpperCase() + environment.slice(1)}
|
||||
</DropdownMenu.RadioItem>
|
||||
))}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton color="gray" variant="ghost">
|
||||
<CameraIcon />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<Flex direction="column" gap="2">
|
||||
<Popover.Close>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!canvas.current) return;
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
anchor.setAttribute(
|
||||
'download',
|
||||
`${protagonistTank.name}.png`,
|
||||
);
|
||||
anchor.setAttribute(
|
||||
'href',
|
||||
canvas.current.toDataURL('image/png'),
|
||||
);
|
||||
anchor.click();
|
||||
}}
|
||||
>
|
||||
<DownloadIcon />
|
||||
Download
|
||||
</Button>
|
||||
</Popover.Close>
|
||||
<Popover.Close>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!canvas.current) return;
|
||||
|
||||
canvas.current.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
|
||||
navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob }),
|
||||
]);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CopyIcon />
|
||||
Copy
|
||||
</Button>
|
||||
</Popover.Close>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
{fullScreenAvailable && (
|
||||
<IconButton
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (isFullScreen) {
|
||||
document.exitFullscreen();
|
||||
} else document.body.requestFullscreen();
|
||||
}}
|
||||
>
|
||||
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import {
|
||||
forwardRef,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { Armor } from '../../../../../../components/Armor';
|
||||
import { ArmorPlateDisplay } from '../../../../../../components/Armor/components/ArmorPlateDisplay';
|
||||
import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay';
|
||||
import {
|
||||
StaticArmor,
|
||||
ThicknessRange,
|
||||
} from '../../../../../../components/Armor/components/StaticArmor';
|
||||
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform';
|
||||
import { Pose, poseEvent } from '../../../../../../core/blitzkit/pose';
|
||||
import { useEquipment } from '../../../../../../hooks/useEquipment';
|
||||
import { useOnScreen } from '../../../../../../hooks/useOnScreen';
|
||||
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
|
||||
import * as Duel from '../../../../../../stores/duel';
|
||||
import * as TankopediaEphemeral from '../../../../../../stores/tankopediaEphemeral';
|
||||
import * as TankopediaPersistent from '../../../../../../stores/tankopediaPersistent';
|
||||
import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants';
|
||||
import { Controls } from '../../../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Control';
|
||||
import { Lighting } from '../../../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Lighting';
|
||||
import { SceneProps } from '../../../../../../website-legacy/src/app/tools/tankopedia/[id]/components/SceneProps';
|
||||
import { AutoClear } from '../../../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Model/AutoClear';
|
||||
import { ModelLoader } from '../../../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Model/components/ModelLoader';
|
||||
import { TankModel } from '../../../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Model/components/TankModel';
|
||||
|
||||
interface TankSandboxProps {
|
||||
thicknessRange: ThicknessRange;
|
||||
}
|
||||
|
||||
export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
|
||||
({ thicknessRange }, ref) => {
|
||||
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const hasImprovedVerticalStabilizer = useEquipment(122);
|
||||
const protagonist = Duel.use((state) => state.protagonist);
|
||||
const mutateDuel = Duel.useMutation();
|
||||
const tankModelDefinition = useTankModelDefinition();
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
const display = TankopediaPersistent.use((state) => state.display);
|
||||
const onScreen = useOnScreen(canvas);
|
||||
|
||||
useImperativeHandle(ref, () => canvas.current!, []);
|
||||
|
||||
function handlePointerDown() {
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handlePoseEvent(pose: Pose) {
|
||||
switch (pose) {
|
||||
case Pose.HullDown: {
|
||||
mutateDuel((draft) => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
-Infinity,
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
draft.protagonist.pitch = pitch;
|
||||
draft.protagonist.yaw = yaw;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Pose.FaceHug: {
|
||||
mutateDuel((draft) => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
Infinity,
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
draft.protagonist.pitch = pitch;
|
||||
draft.protagonist.yaw = yaw;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Pose.Default:
|
||||
mutateDuel((draft) => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
0,
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
draft.protagonist.pitch = pitch;
|
||||
draft.protagonist.yaw = yaw;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
poseEvent.on(handlePoseEvent);
|
||||
|
||||
return () => {
|
||||
poseEvent.off(handlePoseEvent);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
mutateDuel((draft) => {
|
||||
[draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits(
|
||||
draft.protagonist.pitch,
|
||||
draft.protagonist.yaw,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
});
|
||||
}, [protagonist.gun, protagonist.turret]);
|
||||
|
||||
useEffect(() => {
|
||||
if (display !== TankopediaDisplay.DynamicArmor) {
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
if (display !== TankopediaDisplay.StaticArmor) {
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.highlightArmor = undefined;
|
||||
});
|
||||
}
|
||||
}, [display]);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
frameloop={onScreen ? 'demand' : 'never'}
|
||||
gl={{
|
||||
clippingPlanes: [],
|
||||
localClippingEnabled: true,
|
||||
preserveDrawingBuffer: true,
|
||||
}}
|
||||
shadows
|
||||
ref={canvas}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMissed={() => {
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.shot = undefined;
|
||||
draft.highlightArmor = undefined;
|
||||
});
|
||||
}}
|
||||
style={{ userSelect: 'none' }}
|
||||
camera={{ fov: 25, far: 32 }}
|
||||
>
|
||||
<Controls />
|
||||
<SceneProps />
|
||||
{display !== TankopediaDisplay.StaticArmor && <TankModel />}
|
||||
|
||||
<ShotDisplay />
|
||||
<ArmorPlateDisplay />
|
||||
<AutoClear />
|
||||
|
||||
{/* idk why the shot display doesn't work without suspense here lol */}
|
||||
<Suspense fallback={<ModelLoader />}>
|
||||
{display === TankopediaDisplay.DynamicArmor && <Armor />}
|
||||
{display === TankopediaDisplay.StaticArmor && (
|
||||
<StaticArmor thicknessRange={thicknessRange} />
|
||||
)}
|
||||
<Lighting />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
);
|
||||
},
|
||||
);
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { tankIcon } from '@blitzkit/core';
|
||||
import { Flex, FlexProps, Progress } from '@radix-ui/themes';
|
||||
import { useProgress } from '@react-three/drei';
|
||||
|
||||
type TankSandboxLoaderProps = Omit<FlexProps, 'id'> & {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export function TankSandboxLoader({ id, ...props }: TankSandboxLoaderProps) {
|
||||
const data = useProgress();
|
||||
|
||||
return (
|
||||
<Flex width="100%" height="100%" align="center" justify="center" {...props}>
|
||||
<img src={tankIcon(id)} style={{ height: '60%', filter: 'blur(4px)' }} />
|
||||
<Progress
|
||||
size="3"
|
||||
value={data.progress}
|
||||
style={{ position: 'absolute', width: 128 }}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { resolveNearPenetration, tankDefinitions } from '@blitzkit/core';
|
||||
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NAVBAR_HEIGHT } from '../../../constants/navbar';
|
||||
import { Var } from '../../../core/radix/var';
|
||||
import { useFullScreen } from '../../../hooks/useFullScreen';
|
||||
import type { ThicknessRange } from '../../Armor/components/StaticArmor';
|
||||
import { classIcons } from '../../ClassIcon';
|
||||
import { Options } from './components/Options';
|
||||
import { TankSandbox } from './components/TankSandbox';
|
||||
import { TankSandboxLoader } from './components/TankSandboxLoader';
|
||||
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
|
||||
interface HeroSectionProps {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export function HeroSection({ id }: HeroSectionProps) {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const isFullScreen = useFullScreen();
|
||||
const protagonist = awaitedTankDefinitions[id];
|
||||
const Icon = classIcons[protagonist.class];
|
||||
const [dummyLoader, setDummyLoader] = useState(true);
|
||||
const treeColor =
|
||||
protagonist.treeType === 'collector'
|
||||
? 'blue'
|
||||
: protagonist.treeType === 'premium'
|
||||
? 'amber'
|
||||
: undefined;
|
||||
const thicknessRange = useMemo(() => {
|
||||
const entries = Object.values(awaitedTankDefinitions);
|
||||
const filtered = entries.filter(
|
||||
(thisTank) => thisTank.tier === protagonist.tier,
|
||||
);
|
||||
const value =
|
||||
(filtered.reduce((accumulator, thisTank) => {
|
||||
return (
|
||||
accumulator +
|
||||
resolveNearPenetration(
|
||||
thisTank.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration,
|
||||
)
|
||||
);
|
||||
}, 0) /
|
||||
filtered.length) *
|
||||
(3 / 4);
|
||||
|
||||
return { value } satisfies ThicknessRange;
|
||||
}, [protagonist]);
|
||||
|
||||
useEffect(() => {
|
||||
setDummyLoader(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex justify="center" style={{ backgroundColor: Var('color-surface') }}>
|
||||
<Flex
|
||||
direction={{ initial: 'column', md: 'row' }}
|
||||
style={{
|
||||
backgroundColor: isFullScreen
|
||||
? Var('color-background')
|
||||
: Var('color-surface'),
|
||||
zIndex: isFullScreen ? 2 : undefined,
|
||||
}}
|
||||
height={
|
||||
isFullScreen ? '100vh' : `calc(100vh - ${NAVBAR_HEIGHT}px - 8rem)`
|
||||
}
|
||||
maxHeight={isFullScreen ? undefined : '40rem'}
|
||||
maxWidth={isFullScreen ? undefined : '120rem'}
|
||||
flexGrow="1"
|
||||
width={isFullScreen ? '100vw' : undefined}
|
||||
position={isFullScreen ? 'fixed' : 'relative'}
|
||||
top={isFullScreen ? '0' : undefined}
|
||||
left={isFullScreen ? '0' : undefined}
|
||||
>
|
||||
<Flex
|
||||
style={{ userSelect: 'none' }}
|
||||
direction="column"
|
||||
align={{ initial: 'center', md: 'start' }}
|
||||
justify="center"
|
||||
pl={{ initial: '0', md: '9' }}
|
||||
pt={{ initial: '5', md: '0' }}
|
||||
>
|
||||
<Heading
|
||||
weight="bold"
|
||||
size={{ initial: '8', lg: '9' }}
|
||||
wrap="nowrap"
|
||||
color={treeColor}
|
||||
>
|
||||
<Flex align="center" gap="3">
|
||||
<Icon width="0.8em" height="0.8em" />
|
||||
{protagonist.name}
|
||||
</Flex>
|
||||
</Heading>
|
||||
|
||||
<Text
|
||||
color="gray"
|
||||
size="3"
|
||||
weight="light"
|
||||
ml={{ initial: '0', md: '9' }}
|
||||
>
|
||||
BlitzKit Tankopedia
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Box
|
||||
className="tank-sandbox-container"
|
||||
flexGrow="1"
|
||||
flexBasis="0"
|
||||
flexShrink="0"
|
||||
position="relative"
|
||||
>
|
||||
<Box position="absolute" width="100%" height="100%">
|
||||
<Box width="100%" height="100%">
|
||||
<TankSandboxLoader
|
||||
id={protagonist.id}
|
||||
display={dummyLoader ? 'flex' : 'none'}
|
||||
/>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<TankSandboxLoader
|
||||
id={protagonist.id}
|
||||
display={dummyLoader ? 'none' : 'flex'}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TankSandbox ref={canvas} thicknessRange={thicknessRange} />
|
||||
</Suspense>
|
||||
</Box>
|
||||
|
||||
<Options canvas={canvas} thicknessRange={thicknessRange} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Heading, Text } from '@radix-ui/themes';
|
||||
import { PageWrapper } from '../../../../../components/PageWrapper';
|
||||
import * as Duel from '../../../../../stores/duel';
|
||||
|
||||
export function HistorySection() {
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
|
||||
if (!tank.description) return null;
|
||||
|
||||
return (
|
||||
<PageWrapper noFlex1 align="center">
|
||||
<Heading>History of the {tank.name}</Heading>
|
||||
<Text>{tank.description}</Text>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Flex, Text, TextProps } from '@radix-ui/themes';
|
||||
|
||||
type ListingProps = TextProps & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function Listing({ label, ...props }: ListingProps) {
|
||||
return (
|
||||
<Flex minWidth="15rem" justify="between" gap="4">
|
||||
<Text color="gray">{label}</Text>
|
||||
<Text align="right" {...props} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { StarFilledIcon, StarIcon } from '@radix-ui/react-icons';
|
||||
import { Box, BoxProps } from '@radix-ui/themes';
|
||||
|
||||
type PartialStarProps = BoxProps & {
|
||||
fill: number;
|
||||
};
|
||||
|
||||
export function PartialStar({ fill, ...props }: PartialStarProps) {
|
||||
return (
|
||||
<Box height="1em" width="1em" position="relative" {...props}>
|
||||
<StarIcon width="1em" height="1em" />
|
||||
{fill > 0 && (
|
||||
<Box
|
||||
top="0"
|
||||
left="0"
|
||||
width={`${fill * 100}%`}
|
||||
position="absolute"
|
||||
overflow="hidden"
|
||||
>
|
||||
<StarFilledIcon width="1em" height="1em" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { StarsInt } from '@blitzkit/core';
|
||||
import { Flex, Text, TextProps } from '@radix-ui/themes';
|
||||
import { times } from 'lodash-es';
|
||||
import { PartialStar } from './components/PartialStar';
|
||||
|
||||
type StarsProps = TextProps & {
|
||||
stars: number | null;
|
||||
onCast?: (value: StarsInt) => void;
|
||||
};
|
||||
|
||||
export function Stars({ stars, onCast, ...props }: StarsProps) {
|
||||
let color: TextProps['color'] = 'green';
|
||||
|
||||
if (stars === null) color = 'gray';
|
||||
else if (stars < 1.5) color = 'tomato';
|
||||
else if (stars < 2.5) color = 'orange';
|
||||
else if (stars < 3.5) color = 'amber';
|
||||
else if (stars < 4.5) color = 'blue';
|
||||
|
||||
return (
|
||||
<Text color={color} {...props}>
|
||||
<Flex style={{ cursor: onCast ? 'pointer' : 'unset' }}>
|
||||
{stars !== null &&
|
||||
times(Math.floor(stars), (index) => (
|
||||
<PartialStar
|
||||
fill={1}
|
||||
key={index}
|
||||
onClick={() => onCast?.((index + 1) as StarsInt)}
|
||||
/>
|
||||
))}
|
||||
{stars !== null && stars % 1 !== 0 && <PartialStar fill={stars % 1} />}
|
||||
{times(5 - Math.ceil(stars ?? 0), (index) => (
|
||||
<PartialStar
|
||||
fill={0}
|
||||
key={index}
|
||||
onClick={() =>
|
||||
onCast?.((Math.floor(stars ?? 0) + index + 1) as StarsInt)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { Flex, Text } from '@radix-ui/themes';
|
||||
import { Stars } from '../../Stars';
|
||||
|
||||
interface StarRowProps {
|
||||
stars: number | null;
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function StarRow({ stars, children }: StarRowProps) {
|
||||
return (
|
||||
<Text color={stars === null ? 'gray' : undefined}>
|
||||
<Flex align="center" justify="between" width="15rem">
|
||||
{children}
|
||||
<Stars stars={stars} />
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import { BlitzkitResponse, StarsInt } from '@blitzkit/core';
|
||||
import { PaperPlaneIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
AlertDialog,
|
||||
Button,
|
||||
Dialog,
|
||||
Flex,
|
||||
Spinner,
|
||||
Text,
|
||||
} from '@radix-ui/themes';
|
||||
import { useState } from 'react';
|
||||
import * as App from '../../../../../../../../../../stores/app';
|
||||
import * as Duel from '../../../../../../../../../../stores/duel';
|
||||
import { Stars } from '../../Stars';
|
||||
|
||||
export function VoteCaster() {
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
const [easiness, setEasiness] = useState<StarsInt | null>(null);
|
||||
const [firepower, setFirepower] = useState<StarsInt | null>(null);
|
||||
const [maneuverability, setManeuverability] = useState<StarsInt | null>(null);
|
||||
const [survivability, setSurvivability] = useState<StarsInt | null>(null);
|
||||
const appStore = App.useStore();
|
||||
// true = vote successful, false = vote failed, null = hide alert
|
||||
const [postVote, setPostVote] = useState<boolean | null>(null);
|
||||
// true = open, false = closed, null = casting
|
||||
const [showDialogue, setShowDialogue] = useState<boolean | null>(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog.Root open={showDialogue !== false} onOpenChange={setShowDialogue}>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="surface">
|
||||
<PaperPlaneIcon /> Vote
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content maxWidth="23rem">
|
||||
<Dialog.Title>Voting for {tank.name}</Dialog.Title>
|
||||
|
||||
<Flex direction="column" mt="4">
|
||||
<Flex align="center" justify="between">
|
||||
<Text>
|
||||
Easiness{' '}
|
||||
<Text color="gray" size="2">
|
||||
{easiness === null ? '(empty)' : `(${easiness})`}
|
||||
</Text>
|
||||
</Text>
|
||||
<Stars stars={easiness} size="5" onCast={setEasiness} />
|
||||
</Flex>
|
||||
<Flex align="center" justify="between">
|
||||
<Text>
|
||||
Firepower{' '}
|
||||
<Text color="gray" size="2">
|
||||
{firepower === null ? '(empty)' : `(${firepower})`}
|
||||
</Text>
|
||||
</Text>
|
||||
<Stars stars={firepower} size="5" onCast={setFirepower} />
|
||||
</Flex>
|
||||
<Flex align="center" justify="between">
|
||||
<Text>
|
||||
Maneuverability{' '}
|
||||
<Text color="gray" size="2">
|
||||
{maneuverability === null
|
||||
? '(empty)'
|
||||
: `(${maneuverability})`}
|
||||
</Text>
|
||||
</Text>
|
||||
<Stars
|
||||
stars={maneuverability}
|
||||
size="5"
|
||||
onCast={setManeuverability}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex align="center" justify="between">
|
||||
<Text>
|
||||
Survivability{' '}
|
||||
<Text color="gray" size="2">
|
||||
{survivability === null ? '(empty)' : `(${survivability})`}
|
||||
</Text>
|
||||
</Text>
|
||||
<Stars stars={survivability} size="5" onCast={setSurvivability} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="end" mt="4" gap="2">
|
||||
<Dialog.Close>
|
||||
<Button variant="outline" color="red">
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
|
||||
<Button
|
||||
disabled={
|
||||
showDialogue === null ||
|
||||
(easiness === null &&
|
||||
firepower === null &&
|
||||
maneuverability === null &&
|
||||
survivability === null)
|
||||
}
|
||||
onClick={() => {
|
||||
const wargaming = appStore.getState().logins.wargaming;
|
||||
|
||||
if (!wargaming) {
|
||||
return setShowDialogue(false);
|
||||
}
|
||||
|
||||
setShowDialogue(null);
|
||||
|
||||
fetch(
|
||||
`/api/tank-voting/${tank.id}/cast?${
|
||||
easiness === null ? '' : `easiness=${easiness}&`
|
||||
}${firepower === null ? '' : `firepower=${firepower}&`}${
|
||||
maneuverability === null
|
||||
? ''
|
||||
: `maneuverability=${maneuverability}&`
|
||||
}${
|
||||
survivability === null
|
||||
? ''
|
||||
: `survivability=${survivability}&`
|
||||
}player=${wargaming.id}&token=${wargaming.token}`,
|
||||
)
|
||||
.then(
|
||||
(response) => response.json() as Promise<BlitzkitResponse>,
|
||||
)
|
||||
.then((json) => {
|
||||
setShowDialogue(false);
|
||||
setPostVote(json.status === 'ok');
|
||||
|
||||
if (json.status === 'error') {
|
||||
console.error('vote failed', json);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{showDialogue === null ? <Spinner /> : <PaperPlaneIcon />} Vote
|
||||
</Button>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<AlertDialog.Root
|
||||
open={postVote !== null}
|
||||
onOpenChange={() => setPostVote(null)}
|
||||
>
|
||||
{postVote === true && (
|
||||
<AlertDialog.Content maxWidth="20rem">
|
||||
<AlertDialog.Title>Vote casted</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
It may take a few minutes for it to show up.
|
||||
</AlertDialog.Description>
|
||||
|
||||
<Flex justify="end" mt="4">
|
||||
<AlertDialog.Cancel>
|
||||
<Button>Okay</Button>
|
||||
</AlertDialog.Cancel>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
)}
|
||||
|
||||
{postVote === false && (
|
||||
<AlertDialog.Content maxWidth="20rem">
|
||||
<AlertDialog.Title>Voting failed</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
So sorry about this! If you're a nerd, feel free to check out the
|
||||
console.
|
||||
</AlertDialog.Description>
|
||||
|
||||
<Flex justify="end" mt="4">
|
||||
<AlertDialog.Cancel>
|
||||
<Button>Gotcha</Button>
|
||||
</AlertDialog.Cancel>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
)}
|
||||
</AlertDialog.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { formatCompact } from '@blitzkit/core';
|
||||
import { Flex, Heading, Skeleton, Text } from '@radix-ui/themes';
|
||||
import { memo } from 'react';
|
||||
import { WargamingLoginButton } from '../../../../../../../../../components/Navbar/components/WargamingLoginButton';
|
||||
import { useTankVotes } from '../../../../../../../../../hooks/useTankVotes';
|
||||
import * as App from '../../../../../../../../../stores/app';
|
||||
import * as Duel from '../../../../../../../../../stores/duel';
|
||||
import { StarRow } from './components/StarRow';
|
||||
import { VoteCaster } from './components/VoteCaster';
|
||||
|
||||
export const Votes = memo(
|
||||
() => {
|
||||
const id = Duel.use((state) => state.protagonist.tank.id);
|
||||
const votes = useTankVotes(id);
|
||||
const wargaming = App.useDeferred(
|
||||
undefined,
|
||||
(state) => state.logins.wargaming,
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="3">
|
||||
<Flex justify="between" align="center">
|
||||
<Heading size="4">
|
||||
{!votes && <Skeleton height="1em" width="4rem" />}
|
||||
{votes && formatCompact(votes.votes)}{' '}
|
||||
{votes && (votes.votes > 1 ? 'votes' : 'vote')}
|
||||
</Heading>
|
||||
|
||||
{!wargaming && (
|
||||
<WargamingLoginButton variant="surface">
|
||||
Login to vote
|
||||
</WargamingLoginButton>
|
||||
)}
|
||||
{wargaming && <VoteCaster />}
|
||||
</Flex>
|
||||
|
||||
<Flex gap="0" direction="column">
|
||||
<Flex
|
||||
gap={{ initial: '0', sm: '6' }}
|
||||
direction={{ initial: 'column', sm: 'row' }}
|
||||
>
|
||||
<StarRow
|
||||
stars={votes?.categories.easiness ?? null}
|
||||
children="Easiness"
|
||||
/>
|
||||
<StarRow
|
||||
stars={votes?.categories.firepower ?? null}
|
||||
children="Firepower"
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
gap={{ initial: '0', sm: '6' }}
|
||||
direction={{ initial: 'column', sm: 'row' }}
|
||||
>
|
||||
<StarRow
|
||||
stars={votes?.categories.maneuverability ?? null}
|
||||
children="Maneuverability"
|
||||
/>
|
||||
<StarRow
|
||||
stars={votes?.categories.survivability ?? null}
|
||||
children="Survivability"
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="center">
|
||||
<Text size="2" color="gray" mt="2">
|
||||
<>
|
||||
{votes ? (
|
||||
wargaming ? (
|
||||
votes.last_updated ? (
|
||||
`You voted on ${new Date(
|
||||
votes.last_updated,
|
||||
).toLocaleDateString()}`
|
||||
) : (
|
||||
`You haven't voted yet`
|
||||
)
|
||||
) : (
|
||||
'Login to vote'
|
||||
)
|
||||
) : (
|
||||
<Skeleton height="1em" width="5rem" />
|
||||
)}
|
||||
</>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
assertSecret,
|
||||
asset,
|
||||
modelDefinitions,
|
||||
provisionDefinitions,
|
||||
} from '@blitzkit/core';
|
||||
import strings from '@blitzkit/core/lang/en-US.json';
|
||||
import { ChevronLeftIcon, MixIcon, UpdateIcon } from '@radix-ui/react-icons';
|
||||
import { Button, Code, Dialog, Flex } from '@radix-ui/themes';
|
||||
import { useState } from 'react';
|
||||
import { classIcons } from '../../../../../../../components/ClassIcon';
|
||||
import { Link } from '../../../../../../../components/Link';
|
||||
import { ScienceIcon } from '../../../../../../../components/ScienceIcon';
|
||||
import { tankToDuelMember } from '../../../../../../../core/blitzkit/tankToDuelMember';
|
||||
import * as App from '../../../../../../../stores/app';
|
||||
import * as Duel from '../../../../../../../stores/duel';
|
||||
import * as TankopediaEphemeral from '../../../../../../../stores/tankopediaEphemeral';
|
||||
import { TankSearch } from '../../../../components/TankSearch';
|
||||
import { Listing } from './components/Listing';
|
||||
|
||||
const awaitedProvisionDefinitions = await provisionDefinitions;
|
||||
const awaitedModelDefinitions = await modelDefinitions;
|
||||
|
||||
export function MetaSection() {
|
||||
const developerMode = App.useDeferred(false, (state) => state.developerMode);
|
||||
const protagonist = Duel.use((state) => state.protagonist.tank);
|
||||
const ClassIcon = classIcons[protagonist.class];
|
||||
const antagonist = Duel.use((state) => state.antagonist.tank);
|
||||
const compareTanks =
|
||||
protagonist.id === antagonist.id
|
||||
? [protagonist.id]
|
||||
: [protagonist.id, antagonist.id];
|
||||
const mutateDuel = Duel.useMutation();
|
||||
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
|
||||
const [showSwapDialog, setShowSwapDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<Flex justify="center" align="center">
|
||||
<Flex direction="column" align="center" gap="6">
|
||||
<Flex gap="2" wrap="wrap" justify="center" align="center">
|
||||
<Link href="/tools/tankopedia">
|
||||
<Button variant="outline">
|
||||
<ChevronLeftIcon />
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={`/tools/compare?tanks=${compareTanks.join('%2C')}`}>
|
||||
<Button variant="outline">
|
||||
<MixIcon />
|
||||
Compare
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{assertSecret(process.env.PUBLIC_PROMOTE_OPENTEST) === 'true' && (
|
||||
<Link
|
||||
href={`https://opentest.blitzkit.app/tools/tankopedia/${protagonist.id}`}
|
||||
>
|
||||
<Button variant="outline" color="green">
|
||||
<ScienceIcon height="1.25em" width="1.25em" />
|
||||
OpenTest
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Dialog.Root open={showSwapDialog} onOpenChange={setShowSwapDialog}>
|
||||
<Dialog.Trigger>
|
||||
<Button>
|
||||
<UpdateIcon />
|
||||
Swap
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content>
|
||||
<Dialog.Title>Swap tanks</Dialog.Title>
|
||||
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
setShowSwapDialog(false);
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.model = awaitedModelDefinitions[tank.id];
|
||||
});
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist = tankToDuelMember(
|
||||
tank,
|
||||
awaitedProvisionDefinitions,
|
||||
);
|
||||
});
|
||||
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`/tools/tankopedia/${tank.id}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
gap={{ initial: '0', sm: '6' }}
|
||||
direction={{ initial: 'column', sm: 'row' }}
|
||||
>
|
||||
<Flex direction="column" width="100%">
|
||||
{protagonist.nameFull && (
|
||||
<Listing label="Full-name">{protagonist.nameFull}</Listing>
|
||||
)}
|
||||
<Listing label="Nation">
|
||||
<Flex align="center" gap="1">
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt={protagonist.nation}
|
||||
src={asset(`flags/circle/${protagonist.nation}.webp`)}
|
||||
/>
|
||||
{
|
||||
strings.common.nations[
|
||||
protagonist.nation as keyof typeof strings.common.nations
|
||||
]
|
||||
}
|
||||
</Flex>
|
||||
</Listing>
|
||||
<Listing label="Class">
|
||||
<Flex align="center" gap="1">
|
||||
<ClassIcon width="1em" height="1em" />
|
||||
{strings.common.tank_class_short[protagonist.class]}
|
||||
</Flex>
|
||||
</Listing>
|
||||
<Listing label="Tier">{protagonist.tier}</Listing>
|
||||
<Listing
|
||||
label="Type"
|
||||
color={
|
||||
protagonist.treeType === 'collector'
|
||||
? 'blue'
|
||||
: protagonist.treeType === 'premium'
|
||||
? 'amber'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{strings.common.tree_type[protagonist.treeType]}
|
||||
</Listing>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" width="100%">
|
||||
{developerMode && (
|
||||
<Listing label="DEV: ID">
|
||||
<Code>{protagonist.id}</Code>
|
||||
</Listing>
|
||||
)}
|
||||
{protagonist.treeType === 'premium' && (
|
||||
<Listing label="Purchase price">
|
||||
<Flex align="center" gap="1">
|
||||
{protagonist.price.value / 400}
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt="gold"
|
||||
src={asset('icons/currencies/gold.webp')}
|
||||
/>
|
||||
</Flex>
|
||||
</Listing>
|
||||
)}
|
||||
<Listing
|
||||
label={`${
|
||||
protagonist.treeType === 'researchable'
|
||||
? 'Purchase'
|
||||
: 'Restoration'
|
||||
} price`}
|
||||
>
|
||||
<Flex align="center" gap="1">
|
||||
{protagonist.price.value.toLocaleString()}
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt={protagonist.price.type}
|
||||
src={asset(
|
||||
`icons/currencies/${
|
||||
protagonist.price.type === 'gold' ? 'gold' : 'silver'
|
||||
}.webp`,
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
</Listing>
|
||||
<Listing label="Sale price">
|
||||
<Flex align="center" gap="1">
|
||||
{(protagonist.price.value / 2).toLocaleString()}
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt={protagonist.price.type}
|
||||
src={asset(
|
||||
`icons/currencies/${
|
||||
protagonist.price.type === 'gold' ? 'gold' : 'silver'
|
||||
}.webp`,
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
</Listing>
|
||||
{protagonist.xp && (
|
||||
<Listing label="Research XP">
|
||||
<Flex align="center" gap="1">
|
||||
{protagonist.xp.toLocaleString()}
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt="xp"
|
||||
src={asset('icons/currencies/xp.webp')}
|
||||
/>
|
||||
</Flex>
|
||||
</Listing>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* <Votes /> */}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Flex } from '@radix-ui/themes';
|
||||
import { ShotDisplayCard } from '../../../../../components/Armor/components/ShotDisplayCard';
|
||||
import * as TankEphemeral from '../../../../../stores/tankopediaEphemeral';
|
||||
|
||||
export function ShotDisplaySection() {
|
||||
const shot = TankEphemeral.use((state) => state.shot);
|
||||
|
||||
if (!shot) return null;
|
||||
|
||||
return (
|
||||
<Flex justify="center" display={{ initial: 'flex', sm: 'none' }}>
|
||||
<ShotDisplayCard shot={shot} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CaretRightIcon } from '@radix-ui/react-icons';
|
||||
import { Text } from '@radix-ui/themes';
|
||||
|
||||
export function Arrow() {
|
||||
return (
|
||||
<Text color="gray">
|
||||
<CaretRightIcon />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
asset,
|
||||
averageDefinitions,
|
||||
formatCompact,
|
||||
tankDefinitions,
|
||||
TIER_ROMAN_NUMERALS,
|
||||
} from '@blitzkit/core';
|
||||
import { Box, Flex, Text } from '@radix-ui/themes';
|
||||
import Link from 'next/link';
|
||||
import * as TankopediaEphemeral from '../../../../../../../../stores/tankopediaEphemeral';
|
||||
|
||||
interface NodeProps {
|
||||
id: number;
|
||||
nextIds?: number[];
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const awaitedAverageDefinitions = await averageDefinitions;
|
||||
|
||||
export function Node({ id, highlight, nextIds }: NodeProps) {
|
||||
const xpMultiplier = TankopediaEphemeral.use((state) => state.xpMultiplier);
|
||||
const tank = awaitedTankDefinitions[id];
|
||||
const nextTanks = nextIds?.map((id) => awaitedTankDefinitions[id]);
|
||||
const thisTankXp =
|
||||
tank.xp === undefined || tank.tier === 1 ? 0 : tank.xp! / xpMultiplier;
|
||||
const nextTanksXp = nextTanks?.reduce(
|
||||
(xp, tank) =>
|
||||
xp +
|
||||
(tank.xp === undefined || tank.tier === 1 ? 0 : tank.xp! / xpMultiplier),
|
||||
0,
|
||||
);
|
||||
const averages = awaitedAverageDefinitions.averages[tank.id]?.mu;
|
||||
const averageXp = averages ? averages.xp / averages.battles : undefined;
|
||||
const games =
|
||||
averages && nextTanks ? Math.round(nextTanksXp! / averageXp!) : undefined;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/tools/tankopedia/${id}`}
|
||||
key={id}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
mx={highlight ? '2' : '0'}
|
||||
style={{
|
||||
background: highlight
|
||||
? `url(${asset(`flags/fade_small/${tank.nation}.webp`)})`
|
||||
: undefined,
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'cover',
|
||||
boxShadow: highlight ? 'var(--shadow-4)' : undefined,
|
||||
borderRadius: 'var(--radius-4)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
py="4"
|
||||
px={highlight ? '4' : '2'}
|
||||
style={{
|
||||
backdropFilter: highlight ? 'brightness(0.25)' : undefined,
|
||||
WebkitBackdropFilter: highlight ? 'brightness(0.25)' : undefined,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={tank.name}
|
||||
src={asset(`icons/tanks/big/${id}.webp`)}
|
||||
width={64}
|
||||
height={64}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex direction="column" align="center">
|
||||
<Flex gap="2" align="center">
|
||||
<Text color="gray" size="1">
|
||||
{TIER_ROMAN_NUMERALS[tank.tier]}
|
||||
</Text>
|
||||
<Text wrap="nowrap">{tank.name}</Text>
|
||||
</Flex>
|
||||
<Flex gap="2" align="center">
|
||||
<Text color="gray" size="1" wrap="nowrap">
|
||||
<Flex gap="1" align="center">
|
||||
<img
|
||||
alt="XP"
|
||||
src={asset('icons/currencies/xp.webp')}
|
||||
style={{
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
{formatCompact(thisTankXp)}
|
||||
</Flex>
|
||||
</Text>
|
||||
<Text color="gray" size="1" wrap="nowrap">
|
||||
<Flex gap="1" align="center">
|
||||
<img
|
||||
alt="Silver"
|
||||
src={asset('icons/currencies/silver.webp')}
|
||||
style={{
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
{formatCompact(tank.price.value)}
|
||||
</Flex>
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{averages && nextTanks && (
|
||||
<Text color="gray" size="1" mt="2">
|
||||
{games!} {games === 1 ? 'battle' : 'battles'}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { asset, imgur, tankDefinitions } from '@blitzkit/core';
|
||||
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 * as Duel from '../../../../../../../stores/duel';
|
||||
import * as TankopediaEphemeral from '../../../../../../../stores/tankopediaEphemeral';
|
||||
import { Arrow } from './components/Arrow';
|
||||
import { Node } from './components/Node';
|
||||
|
||||
type Line = number[];
|
||||
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
|
||||
export const XP_MULTIPLIERS = [1, 2, 3, 4, 5, 10];
|
||||
|
||||
export function TechTreeSection() {
|
||||
const xpMultiplier = TankopediaEphemeral.use((state) => state.xpMultiplier);
|
||||
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
|
||||
const master = Duel.use((state) => state.protagonist.tank);
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const lines = useMemo(() => {
|
||||
function extend(line: Line): Line[] {
|
||||
const root = awaitedTankDefinitions[line.at(-1)!];
|
||||
|
||||
if (root.ancestors === undefined || root.tier === 1) {
|
||||
return [line];
|
||||
} else {
|
||||
if (root.ancestors.length === 1 || root.tier === 2) {
|
||||
line.push(
|
||||
root.ancestors.find(
|
||||
(ancestor) => !awaitedTankDefinitions[ancestor].deprecated,
|
||||
) ?? root.ancestors[0],
|
||||
);
|
||||
return extend(line);
|
||||
} else {
|
||||
return root.ancestors
|
||||
.map((ancestor) => {
|
||||
const newLine = [...line, ancestor];
|
||||
return extend(newLine);
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return extend([master.id]);
|
||||
}, [master]);
|
||||
const [lineIndex, setLineIndex] = useState(0);
|
||||
const line = useMemo(
|
||||
() => [...lines[lineIndex]].reverse(),
|
||||
[master, lineIndex],
|
||||
);
|
||||
const totalXp = line.reduce(
|
||||
(xp, id) =>
|
||||
xp +
|
||||
(awaitedTankDefinitions[id].xp === undefined ||
|
||||
awaitedTankDefinitions[id].tier === 1
|
||||
? 0
|
||||
: awaitedTankDefinitions[id].xp),
|
||||
0,
|
||||
);
|
||||
const totalCredits = line.reduce(
|
||||
(credits, id) => credits + awaitedTankDefinitions[id].price.value,
|
||||
0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!container.current) return;
|
||||
container.current.scrollLeft = container.current.scrollWidth;
|
||||
});
|
||||
|
||||
if (
|
||||
master.treeType !== 'researchable' ||
|
||||
master.ancestors === undefined ||
|
||||
master.ancestors.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
gap="0"
|
||||
style={{ backgroundColor: 'var(--color-surface)' }}
|
||||
py="6"
|
||||
>
|
||||
<Flex direction="column" align="center">
|
||||
<Heading size="6">Tech tree</Heading>
|
||||
|
||||
{master.tier > 1 && (
|
||||
<Flex gap="4" align="center">
|
||||
<Text color="gray" wrap="nowrap">
|
||||
<Flex gap="1" align="center">
|
||||
<img
|
||||
alt="XP"
|
||||
src={asset('icons/currencies/xp.webp')}
|
||||
style={{
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
{Math.round(totalXp / xpMultiplier).toLocaleString()}
|
||||
</Flex>
|
||||
</Text>
|
||||
|
||||
<Text color="gray" wrap="nowrap">
|
||||
<Flex gap="1" align="center">
|
||||
<img
|
||||
alt="Silver"
|
||||
src={asset('icons/currencies/silver.webp')}
|
||||
style={{
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
{totalCredits.toLocaleString()}
|
||||
</Flex>
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex gap="1" mt="2">
|
||||
{XP_MULTIPLIERS.map((multiplier) => {
|
||||
const selected = xpMultiplier === multiplier;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={multiplier}
|
||||
style={{
|
||||
background: `url(${imgur('7hDltb4')})`,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
opacity: selected ? 1 : 0.5,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.xpMultiplier = multiplier;
|
||||
});
|
||||
}}
|
||||
width="2.5rem"
|
||||
justify="center"
|
||||
align="center"
|
||||
pt="1"
|
||||
pl="1"
|
||||
>
|
||||
<Text size="2">
|
||||
x<b>{multiplier}</b>
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<ScrollArea type="hover" scrollbars="horizontal" ref={container}>
|
||||
<Flex align="center" gap="2" justify="center" p="4">
|
||||
{line.map((id, index) => {
|
||||
const last = index === line.length - 1;
|
||||
const tank = awaitedTankDefinitions[id];
|
||||
|
||||
return (
|
||||
<Fragment key={id}>
|
||||
{index > 0 && <Arrow key={`${id}-arrow`} />}
|
||||
<Node
|
||||
key={`${id}-node`}
|
||||
id={id}
|
||||
nextIds={last ? tank.successors : [line[index + 1]]}
|
||||
highlight={last}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{master.tier < 10 && (
|
||||
<>
|
||||
<Arrow />
|
||||
{master.successors!.map((id, index) => (
|
||||
<Fragment key={id}>
|
||||
{index > 0 && (
|
||||
<Text color="gray" key={`${id}-plus`}>
|
||||
<PlusIcon />
|
||||
</Text>
|
||||
)}
|
||||
<Node key={`${id}-node}`} id={id} />
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
|
||||
<Flex>
|
||||
{lines.length > 1 && (
|
||||
<Flex align="center" gap="2">
|
||||
<IconButton
|
||||
size="2"
|
||||
variant="soft"
|
||||
onClick={() =>
|
||||
setLineIndex((lines.length + lineIndex - 1) % lines.length)
|
||||
}
|
||||
>
|
||||
<CaretLeftIcon />
|
||||
</IconButton>
|
||||
<Text>
|
||||
Route {lineIndex + 1} of {lines.length}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="2"
|
||||
variant="soft"
|
||||
onClick={() => setLineIndex((lineIndex + 1) % lines.length)}
|
||||
>
|
||||
<CaretRightIcon />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -6,21 +6,21 @@ import usePromise from 'react-promise-suspense';
|
||||
import { $wargamingLogin } from '../stores/wargamingLogin';
|
||||
|
||||
export function WargamingAccountName() {
|
||||
const wargamingLogin = useStore($wargamingLogin);
|
||||
const wargaming = useStore($wargamingLogin);
|
||||
const promise = useMemo(async () => {
|
||||
if (!wargamingLogin.token) return null;
|
||||
if (!wargaming.token) return null;
|
||||
|
||||
const data = await getAccountInfo(
|
||||
idToRegion(Number(wargamingLogin.id)),
|
||||
Number(wargamingLogin.id),
|
||||
idToRegion(Number(wargaming.id)),
|
||||
Number(wargaming.id),
|
||||
[],
|
||||
{
|
||||
access_token: wargamingLogin.token,
|
||||
access_token: wargaming.token,
|
||||
},
|
||||
);
|
||||
|
||||
return data?.nickname as string | undefined;
|
||||
}, [wargamingLogin.token]);
|
||||
}, [wargaming]);
|
||||
const nickname = usePromise(() => promise, []);
|
||||
|
||||
return nickname ?? <Text color="gray">Unknown Account</Text>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Vector3, Vector3Tuple } from 'three';
|
||||
import { Vector3, type Vector3Tuple } from 'three';
|
||||
import { rotateDavaVector } from './rotateDavaVector';
|
||||
|
||||
export function correctZYTuple(tuple: Vector3Tuple) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GroupProps, MeshProps } from '@react-three/fiber';
|
||||
import type { GroupProps, MeshProps } from '@react-three/fiber';
|
||||
import { Mesh, Object3D } from 'three';
|
||||
|
||||
export function jsxTree(
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ type ExtractState<S> = S extends {
|
||||
? T
|
||||
: never;
|
||||
|
||||
export function createContextualSafeStore<
|
||||
export function createContextualStore<
|
||||
Store extends StoreApi<unknown>,
|
||||
InitData,
|
||||
>(initialize: (data: InitData) => Store) {
|
||||
@@ -5,17 +5,17 @@ import { $patreonLogin } from '../stores/patreonLogin';
|
||||
let cache: Record<string, boolean | Promise<boolean>> = {};
|
||||
|
||||
export function useAdExempt() {
|
||||
const patreonLogin = useStore($patreonLogin);
|
||||
const patreon = useStore($patreonLogin);
|
||||
const [exempt, setExempt] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
(async () => {
|
||||
if (!patreonLogin.token) return setExempt(false);
|
||||
if (!patreon.token) return setExempt(false);
|
||||
|
||||
if (!(patreonLogin.token in cache)) {
|
||||
cache[patreonLogin.token] = new Promise<boolean>(async (resolve) => {
|
||||
if (!(patreon.token in cache)) {
|
||||
cache[patreon.token] = new Promise<boolean>(async (resolve) => {
|
||||
const response = await fetch(
|
||||
`/api/patreon/membership/${patreonLogin.token}`,
|
||||
`/api/patreon/membership/${patreon.token}`,
|
||||
{ cache: 'force-cache' },
|
||||
);
|
||||
|
||||
@@ -24,12 +24,12 @@ export function useAdExempt() {
|
||||
const json = await response.json();
|
||||
const exempt = typeof json === 'boolean' && json;
|
||||
|
||||
cache[patreonLogin.token] = exempt;
|
||||
cache[patreon.token] = exempt;
|
||||
resolve(exempt);
|
||||
});
|
||||
}
|
||||
|
||||
setExempt(await cache[patreonLogin.token]);
|
||||
setExempt(await cache[patreon.token]);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { asset } from '@blitzkit/core';
|
||||
import { ObjectMap, useLoader } from '@react-three/fiber';
|
||||
import { GLTF, GLTFLoader } from 'three-stdlib';
|
||||
import { type ObjectMap, useLoader } from '@react-three/fiber';
|
||||
import { type GLTF, GLTFLoader } from 'three-stdlib';
|
||||
|
||||
const cache: Record<
|
||||
number,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { averageDefinitions, discoveredIdsDefinitions } from '@blitzkit/core';
|
||||
import { use } from 'react';
|
||||
|
||||
const awaitedDiscoveredIdsDefinitions = await discoveredIdsDefinitions;
|
||||
const awaitedAverageDefinitions = await averageDefinitions;
|
||||
|
||||
export function useAveragesExclusionRatio() {
|
||||
const awaitedDiscoveredIdsDefinitions = use(discoveredIdsDefinitions);
|
||||
const awaitedAverageDefinitions = use(averageDefinitions);
|
||||
|
||||
return (
|
||||
awaitedDiscoveredIdsDefinitions.count /
|
||||
awaitedAverageDefinitions.samples.total
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TankDefinition, tankDefinitions } from '@blitzkit/core';
|
||||
import { sumBy } from 'lodash-es';
|
||||
import { use, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import * as Duel from '../stores/duel';
|
||||
|
||||
export enum UseEccentricityMode {
|
||||
@@ -8,8 +8,9 @@ export enum UseEccentricityMode {
|
||||
Class,
|
||||
}
|
||||
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
|
||||
export function useEccentricity(mode: UseEccentricityMode) {
|
||||
const awaitedTankDefinitions = use(tankDefinitions);
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
const similarTanks = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { equipmentDefinitions } from '@blitzkit/core';
|
||||
import { use, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import * as Duel from '../stores/duel';
|
||||
|
||||
const awaitedEquipmentDefinitions = await equipmentDefinitions;
|
||||
|
||||
export function useEquipment(id: number, antagonist = false) {
|
||||
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
|
||||
const member = Duel.use(
|
||||
(state) => state[antagonist ? 'antagonist' : 'protagonist']!,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { asset } from '@blitzkit/core';
|
||||
import { ObjectMap, useLoader } from '@react-three/fiber';
|
||||
import { type ObjectMap, useLoader } from '@react-three/fiber';
|
||||
import { Mesh, MeshStandardMaterial } from 'three';
|
||||
import { GLTF, GLTFLoader } from 'three-stdlib';
|
||||
import { type GLTF, GLTFLoader } from 'three-stdlib';
|
||||
|
||||
const cache: Record<
|
||||
number,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RefObject, useEffect, useRef, useState } from 'react';
|
||||
import { type RefObject, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Thanks GuCier!
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type StoreKeys } from '@nanostores/react';
|
||||
import type { Store } from 'nanostores';
|
||||
import { useStoreKey } from './useStoreKey';
|
||||
|
||||
export function useStoreBoolean<Type>(
|
||||
store: Store<Type>,
|
||||
key: StoreKeys<Store<Type>>,
|
||||
) {
|
||||
return Boolean(useStoreKey(store, key));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useStore, type StoreKeys } from '@nanostores/react';
|
||||
import type { Store } from 'nanostores';
|
||||
|
||||
export function useStoreKey<Type, Key extends StoreKeys<Store<Type>>>(
|
||||
store: Store<Type>,
|
||||
key: Key,
|
||||
) {
|
||||
const state = useStore(store, { keys: [key] });
|
||||
return state[key];
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { I_HAT, J_HAT, K_HAT } from '@blitzkit/core';
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import { type RefObject, useEffect } from 'react';
|
||||
import { Euler, Group, Vector3 } from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { correctZYTuple } from '../core/blitz/correctZYTuple';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import { tankDefinitions } from '@blitzkit/core';
|
||||
import type { GetStaticPaths } from 'astro';
|
||||
import { Page } from './index';
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
|
||||
return Object.values(awaitedTankDefinitions).map((tank) => ({
|
||||
params: { id: tank.id },
|
||||
}));
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<Page id={id} client:load />
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PageWrapper } from '../../../../components/PageWrapper';
|
||||
import { HeroSection } from '../../../../components/Tankopedia/HeroSection';
|
||||
|
||||
interface PageProps {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export function Page({ id }: PageProps) {
|
||||
return (
|
||||
<PageWrapper p="0" noMaxWidth color="purple" size={1600} gap="9">
|
||||
<HeroSection id={id} />
|
||||
{/* <ShotDisplaySection />
|
||||
<MetaSection />
|
||||
<TechTreeSection />
|
||||
{!exempt && <AdMidSectionResponsive />}
|
||||
<CalloutsSection />
|
||||
<CharacteristicsSection />
|
||||
{!exempt && <AdMidSectionResponsive />}
|
||||
<GameModeSection />
|
||||
<VideoSection />
|
||||
<HistorySection />
|
||||
{!exempt && <AdMidSectionResponsive mb="6" />} */}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,14 @@
|
||||
import lodash from 'lodash-es';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { createContextualSafeStore } from '../../core/zustand/createContextualSafeStore';
|
||||
import { createContextualStore } from '../../core/zustand/createContextualStore';
|
||||
|
||||
export interface App {
|
||||
interface AppStore {
|
||||
developerMode: boolean;
|
||||
policiesAgreementIndex: number;
|
||||
}
|
||||
|
||||
export const { Provider, use, useMutation, useStore, useDeferred } =
|
||||
createContextualSafeStore(() =>
|
||||
create<App>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
developerMode: false,
|
||||
policiesAgreementIndex: -1,
|
||||
logins: {},
|
||||
}),
|
||||
{ name: 'app', merge: (a, b) => lodash.merge(b, a) },
|
||||
),
|
||||
),
|
||||
);
|
||||
export const App = createContextualStore(() =>
|
||||
create<AppStore>()(() => ({
|
||||
developerMode: false,
|
||||
policiesAgreementIndex: 0,
|
||||
})),
|
||||
);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { DuelMember } from './duel';
|
||||
import { createContextualStore } from '../core/zustand/createContextualStore';
|
||||
import type { DuelMember } from './duel';
|
||||
|
||||
export interface CompareMember extends DuelMember {
|
||||
key: string;
|
||||
@@ -14,10 +15,10 @@ export interface CompareEphemeral {
|
||||
};
|
||||
}
|
||||
|
||||
export const { Provider, use, useMutation, useStore } =
|
||||
createContextualSafeStore(() =>
|
||||
export const { Provider, use, useMutation, useStore } = createContextualStore(
|
||||
() =>
|
||||
create<CompareEphemeral>()(() => ({
|
||||
crewSkills: {},
|
||||
members: [],
|
||||
})),
|
||||
);
|
||||
);
|
||||
@@ -1,15 +1,15 @@
|
||||
import lodash from 'lodash-es';
|
||||
import { create } from 'zustand';
|
||||
import { persist, subscribeWithSelector } from 'zustand/middleware';
|
||||
import { createContextualSafeStore } from '../core/zustand/createContextualSafeStore';
|
||||
import { createContextualStore } from '../core/zustand/createContextualStore';
|
||||
|
||||
export type DeltaMode = 'none' | 'percentage' | 'absolute';
|
||||
export interface ComparePersistent {
|
||||
deltaMode: DeltaMode;
|
||||
}
|
||||
|
||||
export const { Provider, use, useMutation, useStore } =
|
||||
createContextualSafeStore(() =>
|
||||
export const { Provider, use, useMutation, useStore } = createContextualStore(
|
||||
() =>
|
||||
create<ComparePersistent>()(
|
||||
persist(
|
||||
subscribeWithSelector<ComparePersistent>(() => ({
|
||||
@@ -21,4 +21,4 @@ export const { Provider, use, useMutation, useStore } =
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import { tankToDuelMember } from '../../core/blitzkit/tankToDuelMember';
|
||||
import { createContextualSafeStore } from '../../core/zustand/createContextualSafeStore';
|
||||
import { createContextualStore } from '../../core/zustand/createContextualStore';
|
||||
|
||||
type EquipmentMatrixItem = -1 | 0 | 1;
|
||||
type EquipmentMatrixRow = [
|
||||
@@ -41,27 +41,26 @@ export interface DuelMember {
|
||||
cooldownBooster: number;
|
||||
}
|
||||
|
||||
export interface Duel {
|
||||
export interface DuelStore {
|
||||
protagonist: DuelMember;
|
||||
antagonist: DuelMember;
|
||||
}
|
||||
|
||||
export const { Provider, use, useMutation, useStore } =
|
||||
createContextualSafeStore(
|
||||
({
|
||||
tank,
|
||||
provisionDefinitions,
|
||||
}: {
|
||||
tank: TankDefinition;
|
||||
provisionDefinitions: ProvisionDefinitions;
|
||||
}) => {
|
||||
const protagonist = tankToDuelMember(tank, provisionDefinitions);
|
||||
export const Duel = createContextualStore(
|
||||
({
|
||||
tank,
|
||||
provisionDefinitions,
|
||||
}: {
|
||||
tank: TankDefinition;
|
||||
provisionDefinitions: ProvisionDefinitions;
|
||||
}) => {
|
||||
const protagonist = tankToDuelMember(tank, provisionDefinitions);
|
||||
|
||||
return create<Duel>()(
|
||||
subscribeWithSelector<Duel>(() => ({
|
||||
protagonist,
|
||||
antagonist: protagonist,
|
||||
})),
|
||||
);
|
||||
},
|
||||
);
|
||||
return create<DuelStore>()(
|
||||
subscribeWithSelector<DuelStore>(() => ({
|
||||
protagonist,
|
||||
antagonist: protagonist,
|
||||
})),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as radixColors from '@radix-ui/colors';
|
||||
import { create } from 'zustand';
|
||||
import { createContextualSafeStore } from '../../core/zustand/createContextualSafeStore';
|
||||
import { createContextualStore } from '../../core/zustand/createContextualStore';
|
||||
import {
|
||||
type EmbedConfigItemType,
|
||||
EmbedItemType,
|
||||
@@ -104,7 +104,6 @@ export type EmbedConfigItem = (
|
||||
|
||||
export type EmbedState = Record<string, EmbedConfigItem['default']>;
|
||||
|
||||
export const { Provider, use, useMutation, useStore } =
|
||||
createContextualSafeStore((init: EmbedState) =>
|
||||
create<EmbedState>()(() => init),
|
||||
);
|
||||
export const { Provider, use, useMutation, useStore } = createContextualStore(
|
||||
(init: EmbedState) => create<EmbedState>()(() => init),
|
||||
);
|
||||
@@ -2,7 +2,7 @@ import { IndividualTankStats, Region, Stat } from '@blitzkit/core';
|
||||
import lodash from 'lodash-es';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { createContextualSafeStore } from '../core/zustand/createContextualSafeStore';
|
||||
import { createContextualStore } from '../core/zustand/createContextualStore';
|
||||
|
||||
interface SessionBase {
|
||||
columns: Stat[];
|
||||
@@ -24,8 +24,8 @@ interface SessionNotTracking extends SessionBase {
|
||||
|
||||
type Session = SessionTracking | SessionNotTracking;
|
||||
|
||||
export const { Provider, use, useMutation, useStore } =
|
||||
createContextualSafeStore(() =>
|
||||
export const { Provider, use, useMutation, useStore } = createContextualStore(
|
||||
() =>
|
||||
create<Session>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
@@ -35,4 +35,4 @@ export const { Provider, use, useMutation, useStore } =
|
||||
{ name: 'session-2', merge: (a, b) => lodash.merge(b, a) },
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
@@ -1,15 +1,15 @@
|
||||
import lodash from 'lodash-es';
|
||||
import { create } from 'zustand';
|
||||
import { persist, subscribeWithSelector } from 'zustand/middleware';
|
||||
import { createContextualSafeStore } from '../../core/zustand/createContextualSafeStore';
|
||||
import { createContextualStore } from '../../core/zustand/createContextualStore';
|
||||
import { PlayerCountPeriod } from './constants';
|
||||
|
||||
export interface TankPerformancePersistent {
|
||||
playerCountPeriod: PlayerCountPeriod;
|
||||
}
|
||||
|
||||
export const { Provider, use, useMutation, useStore } =
|
||||
createContextualSafeStore(() =>
|
||||
export const { Provider, use, useMutation, useStore } = createContextualStore(
|
||||
() =>
|
||||
create<TankPerformancePersistent>()(
|
||||
persist(
|
||||
subscribeWithSelector<TankPerformancePersistent>(() => ({
|
||||
@@ -18,4 +18,4 @@ export const { Provider, use, useMutation, useStore } =
|
||||
{ name: 'tank-performance', merge: (a, b) => lodash.merge(b, a) },
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { createContextualSafeStore } from '../../core/zustand/createContextualSafeStore';
|
||||
import { createContextualStore } from '../../core/zustand/createContextualStore';
|
||||
import { TankPerformanceSortTypeNames } from './constants';
|
||||
|
||||
export type TankPerformanceSortType = keyof typeof TankPerformanceSortTypeNames;
|
||||
@@ -9,10 +9,10 @@ export interface TankPerformanceSort {
|
||||
direction: -1 | 1;
|
||||
}
|
||||
|
||||
export const { Provider, use, useMutation, useStore } =
|
||||
createContextualSafeStore(() =>
|
||||
export const { Provider, use, useMutation, useStore } = createContextualStore(
|
||||
() =>
|
||||
create<TankPerformanceSort>()(() => ({
|
||||
type: 'winrate',
|
||||
direction: -1,
|
||||
})),
|
||||
);
|
||||
);
|
||||
@@ -1,34 +1,11 @@
|
||||
import { ModelDefinition } from '@blitzkit/core';
|
||||
import type { ModelDefinition } from '@blitzkit/core';
|
||||
import { Vector3 } from 'three';
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import { XP_MULTIPLIERS } from '../app/tools/tankopedia/[id]/components/Model/TechTreeSection';
|
||||
import { ArmorType } from '../components/Armor/components/SpacedArmorScene';
|
||||
import { ExternalModuleVariant } from '../components/Armor/components/SpacedArmorSceneComponent';
|
||||
import { createContextualSafeStore } from '../core/zustand/createContextualSafeStore';
|
||||
|
||||
interface TankopediaEphemeral {
|
||||
shot?: Shot;
|
||||
skills: Record<string, number>;
|
||||
controlsEnabled: boolean;
|
||||
model: ModelDefinition;
|
||||
editStatic: boolean;
|
||||
highlightArmor?: {
|
||||
editingPlate: boolean;
|
||||
name: string;
|
||||
point: Vector3;
|
||||
thickness: number;
|
||||
color: string;
|
||||
} & (
|
||||
| {
|
||||
type: ArmorType.Primary | ArmorType.Spaced;
|
||||
thicknessAngled: number;
|
||||
angle: number;
|
||||
}
|
||||
| { type: ArmorType.External }
|
||||
);
|
||||
xpMultiplier: (typeof XP_MULTIPLIERS)[number];
|
||||
}
|
||||
import type { ExternalModuleVariant } from '../components/Armor/components/SpacedArmorSceneComponent';
|
||||
import type { XP_MULTIPLIERS } from '../components/Tankopedia/TechTreeSection';
|
||||
import { createContextualStore } from '../core/zustand/createContextualStore';
|
||||
|
||||
export interface ShotLayerBase {
|
||||
index: number;
|
||||
@@ -89,8 +66,8 @@ export type ArmorPiercingLayer =
|
||||
gap: number;
|
||||
};
|
||||
|
||||
export const { Provider, use, useMutation, useStore } =
|
||||
createContextualSafeStore((model: ModelDefinition) =>
|
||||
export const TankopediaEphemeral = createContextualStore(
|
||||
(model: ModelDefinition) =>
|
||||
create<TankopediaEphemeral>()(
|
||||
subscribeWithSelector<TankopediaEphemeral>(() => ({
|
||||
editStatic: false,
|
||||
@@ -100,4 +77,27 @@ export const { Provider, use, useMutation, useStore } =
|
||||
xpMultiplier: 1,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
interface TankopediaEphemeral {
|
||||
shot?: Shot;
|
||||
skills: Record<string, number>;
|
||||
controlsEnabled: boolean;
|
||||
model: ModelDefinition;
|
||||
editStatic: boolean;
|
||||
highlightArmor?: {
|
||||
editingPlate: boolean;
|
||||
name: string;
|
||||
point: Vector3;
|
||||
thickness: number;
|
||||
color: string;
|
||||
} & (
|
||||
| {
|
||||
type: ArmorType.Primary | ArmorType.Spaced;
|
||||
thicknessAngled: number;
|
||||
angle: number;
|
||||
}
|
||||
| { type: ArmorType.External }
|
||||
);
|
||||
xpMultiplier: (typeof XP_MULTIPLIERS)[number];
|
||||
}
|
||||
@@ -1,76 +1,53 @@
|
||||
import type { TankClass, Tier, TreeType } from '@blitzkit/core';
|
||||
import lodash from 'lodash-es';
|
||||
import * as lodash from 'lodash-es';
|
||||
import { create } from 'zustand';
|
||||
import { persist, subscribeWithSelector } from 'zustand/middleware';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { ENVIRONMENTS } from '../../constants/lightingEnvironments';
|
||||
import { createContextualSafeStore } from '../../core/zustand/createContextualSafeStore';
|
||||
import { createContextualStore } from '../../core/zustand/createContextualStore';
|
||||
import { SORT_NAMES, TankopediaDisplay } from './constants';
|
||||
|
||||
export type TankopediaSortBy = keyof typeof SORT_NAMES;
|
||||
export type TankopediaSortDirection = 'ascending' | 'descending';
|
||||
export type TankopediaTestTankDisplay = 'include' | 'exclude' | 'only';
|
||||
export interface TankopediaPersistent {
|
||||
model: {
|
||||
visual: {
|
||||
wireframe: boolean;
|
||||
opaque: boolean;
|
||||
environment: (typeof ENVIRONMENTS)[number];
|
||||
showGrid: boolean;
|
||||
greenPenetration: boolean;
|
||||
showEnvironment: boolean;
|
||||
showSpacedArmor: boolean;
|
||||
showExternalModules: boolean;
|
||||
showPrimaryArmor: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export interface TankopediaPersistentStore {
|
||||
wireframe: boolean;
|
||||
opaque: boolean;
|
||||
environment: (typeof ENVIRONMENTS)[number];
|
||||
showGrid: boolean;
|
||||
greenPenetration: boolean;
|
||||
showEnvironment: boolean;
|
||||
showSpacedArmor: boolean;
|
||||
showExternalModules: boolean;
|
||||
showPrimaryArmor: boolean;
|
||||
|
||||
sort: {
|
||||
by: TankopediaSortBy;
|
||||
direction: TankopediaSortDirection;
|
||||
};
|
||||
filters: {
|
||||
tiers: Tier[];
|
||||
types: TankClass[];
|
||||
treeTypes: TreeType[];
|
||||
nations: string[];
|
||||
test: TankopediaTestTankDisplay;
|
||||
page: number;
|
||||
};
|
||||
|
||||
display: TankopediaDisplay;
|
||||
}
|
||||
|
||||
export const { Provider, use, useMutation, useStore } =
|
||||
createContextualSafeStore(() =>
|
||||
create<TankopediaPersistent>()(
|
||||
persist(
|
||||
subscribeWithSelector<TankopediaPersistent>(() => ({
|
||||
model: {
|
||||
visual: {
|
||||
showSpacedArmor: true,
|
||||
showExternalModules: true,
|
||||
showPrimaryArmor: true,
|
||||
wireframe: false,
|
||||
opaque: false,
|
||||
environment: ENVIRONMENTS[0],
|
||||
showGrid: true,
|
||||
greenPenetration: false,
|
||||
showEnvironment: false,
|
||||
},
|
||||
},
|
||||
sort: {
|
||||
by: 'meta.none',
|
||||
direction: 'descending',
|
||||
},
|
||||
filters: {
|
||||
tiers: [],
|
||||
types: [],
|
||||
treeTypes: [],
|
||||
nations: [],
|
||||
test: 'include',
|
||||
page: 0,
|
||||
},
|
||||
display: TankopediaDisplay.Model,
|
||||
})),
|
||||
{ name: 'tankopedia', merge: (a, b) => lodash.merge(b, a) },
|
||||
),
|
||||
export const TankopediaPersistent = createContextualStore(() =>
|
||||
create<TankopediaPersistentStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
wireframe: false,
|
||||
opaque: false,
|
||||
environment: ENVIRONMENTS[0],
|
||||
showGrid: true,
|
||||
greenPenetration: true,
|
||||
showEnvironment: true,
|
||||
showSpacedArmor: true,
|
||||
showExternalModules: true,
|
||||
showPrimaryArmor: true,
|
||||
sort: {
|
||||
by: 'meta.none',
|
||||
direction: 'ascending',
|
||||
},
|
||||
display: TankopediaDisplay.Model,
|
||||
}),
|
||||
{ name: 'tankopedia', merge: (a, b) => lodash.merge(b, a) },
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
Reference in new issue
Block a user