feat: legacy

This commit is contained in:
TrèsAbhi committed 2024-09-14 12:12:46 -05:00
1 parent 9640920863
commit 8d9ca3d716
271 files changed
+1 -1

No files matched your search

-75
View File
@@ -1,75 +0,0 @@
'use client';
import { imgur } from '@blitzkit/core';
import { Box, BoxProps, Text } from '@radix-ui/themes';
import { uniqueId } from 'lodash';
import { useEffect, useRef } from 'react';
import { Vector2Tuple } from 'three';
export enum AdType {
MediumRectangleHorizontalPurple = 738182777,
LeaderboardHorizontalPurple = 84051521,
WideSkyscraperVerticalPurple = 1755617140,
HalfPageVerticalPurple = 880185176,
}
const AD_DIMENSIONS: Record<AdType, Vector2Tuple> = {
[AdType.MediumRectangleHorizontalPurple]: [300, 250],
[AdType.LeaderboardHorizontalPurple]: [728, 90],
[AdType.WideSkyscraperVerticalPurple]: [160, 600],
[AdType.HalfPageVerticalPurple]: [300, 600],
};
type AdProps = BoxProps & {
type: AdType;
};
export function Ad({ type, style, ...props }: AdProps) {
const id = useRef(uniqueId());
const dimensions = AD_DIMENSIONS[type];
useEffect(() => {
(window as any).msAdsQueue.push(function () {
(window as any).mmnow.render({
adUnitId: type,
elementId: `${type}-${id.current}`,
});
});
}, [type]);
return (
<Box
position="relative"
width={`${dimensions[0]}px`}
height={`${dimensions[1]}px`}
style={{
outline: '1px solid var(--gray-3)',
borderRadius: 'var(--radius-1)',
background: `url(${imgur('DK21EHY')})`,
...style,
}}
{...props}
>
<Text
color="gray"
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
align="center"
>
Advertisement
</Text>
<Box
width={`${dimensions[0]}px`}
height={`${dimensions[1]}px`}
position="relative"
id={`${type}-${id.current}`}
/>
</Box>
);
}
@@ -1,19 +0,0 @@
import { Flex, FlexProps } from '@radix-ui/themes';
import { Ad, AdType } from './Ad';
export function AdMidSectionNarrowOnly(props: FlexProps) {
return (
<Flex
justify="center"
py="4"
gap="4"
display={{
initial: 'flex',
sm: 'none',
}}
{...props}
>
<Ad type={AdType.MediumRectangleHorizontalPurple} />
</Flex>
);
}
@@ -1,23 +0,0 @@
import { Flex, FlexProps } from '@radix-ui/themes';
import { Ad, AdType } from './Ad';
export function AdMidSectionResponsive(props: FlexProps) {
return (
<Flex justify="center" py="4" gap="4" {...props}>
<Ad
display={{
initial: 'none',
sm: 'block',
}}
type={AdType.LeaderboardHorizontalPurple}
/>
<Ad
display={{
initial: 'block',
sm: 'none',
}}
type={AdType.MediumRectangleHorizontalPurple}
/>
</Flex>
);
}
@@ -1,19 +0,0 @@
import { Flex, FlexProps } from '@radix-ui/themes';
import { Ad, AdType } from './Ad';
export function AdMidSectionWideOnly(props: FlexProps) {
return (
<Flex
justify="center"
py="4"
gap="4"
display={{
initial: 'none',
sm: 'flex',
}}
{...props}
>
<Ad type={AdType.LeaderboardHorizontalPurple} />
</Flex>
);
}
@@ -1,190 +0,0 @@
import { ReloadIcon } from '@radix-ui/react-icons';
import {
Card,
Code,
Flex,
IconButton,
Text,
TextField,
} from '@radix-ui/themes';
import { Html } from '@react-three/drei';
import { useRef } from 'react';
import { radToDeg } from 'three/src/math/MathUtils';
import { resolveArmorIndex } from '../../../core/blitzkit/resolveArmorIndex';
import * as App from '../../../stores/app';
import * as Duel from '../../../stores/duel';
import * as TankopediaEphemeral from '../../../stores/tankopediaEphemeral';
import { layerTypeNames } from './ShotDisplayCard';
import { ArmorType } from './SpacedArmorScene';
export function ArmorPlateDisplay() {
const highlightArmor = TankopediaEphemeral.use(
(state) => state.highlightArmor,
);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const developerMode = App.useDeferred(false, (state) => state.developerMode);
const duelStore = Duel.useStore();
const input = useRef<HTMLInputElement>(null);
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
if (highlightArmor === undefined) return null;
return (
<group position={highlightArmor.point}>
<Html
center
style={{ pointerEvents: highlightArmor.editingPlate ? 'auto' : 'none' }}
>
<Card
mb="4"
style={{
whiteSpace: 'nowrap',
color: highlightArmor.color,
transform: 'translateY(-50%)',
}}
>
<Flex direction="column">
<Text weight="bold">
{layerTypeNames[highlightArmor.type]}{' '}
{highlightArmor.thickness.toFixed(0)}
<Text size="1" weight="regular">
mm
</Text>
</Text>
{highlightArmor.type !== ArmorType.External && (
<Text size="2" color="gray">
{highlightArmor.thicknessAngled.toFixed(0)}
<Text size="1">
mm {radToDeg(highlightArmor.angle).toFixed(0)}°
</Text>
</Text>
)}
{developerMode && (
<>
<Text mt="2" color="gray" size="2">
<b>DEV:</b> <Code>{highlightArmor.name}</Code>
</Text>
</>
)}
{highlightArmor.editingPlate && (
<Flex gap="2" align="center">
<TextField.Root
style={{ width: 96 }}
mt="2"
ref={input}
key={highlightArmor.name}
type="number"
autoFocus
onFocus={(event) => event.target.select()}
onKeyDown={(event) => {
if (!highlightArmor.editingPlate) return;
const index = resolveArmorIndex(highlightArmor.name);
if (index === undefined) return;
const thickness = event.currentTarget.valueAsNumber;
if (isNaN(thickness) || thickness < 0) return;
if (event.key === 'Enter') {
input.current?.blur();
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
const { protagonist } = duelStore.getState();
const tank = draft.model;
const turret = tank.turrets[protagonist.turret.id];
const gun = turret.guns[protagonist.gun.id];
const track = tank.tracks[protagonist.track.id];
console.log(highlightArmor.name);
if (highlightArmor.name.startsWith('hull_')) {
tank.armor.thickness[index] = thickness;
} else if (highlightArmor.name.startsWith('turret_')) {
turret.armor.thickness[index] = thickness;
} else if (highlightArmor.name.startsWith('gun_')) {
if (highlightArmor.name.includes('_armor_')) {
gun.armor.thickness[index] = thickness;
} else {
gun.thickness = thickness;
}
} else if (highlightArmor.name.startsWith('chassis_')) {
track.thickness = thickness;
}
});
} else if (event.key === 'Escape') {
input.current?.blur();
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
});
}
}}
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
}}
defaultValue={highlightArmor.thickness.toFixed(0)}
>
<TextField.Slot side="right">mm</TextField.Slot>
</TextField.Root>
<IconButton
mt="2"
color="red"
onClick={() => {
mutateTankopediaEphemeral((draft) => {
if (!highlightArmor.editingPlate) return;
const index = resolveArmorIndex(highlightArmor.name);
if (index === undefined) return;
draft.highlightArmor = undefined;
const initialState =
tankopediaEphemeralStore.getInitialState();
const { protagonist } = duelStore.getState();
const tank = draft.model;
const initialTank = initialState.model;
const turret = tank.turrets[protagonist.turret.id];
const initialTurret =
initialTank.turrets[protagonist.turret.id];
const gun = turret.guns[protagonist.gun.id];
const initialGun = initialTurret.guns[protagonist.gun.id];
const track = tank.tracks[protagonist.track.id];
const initialTrack =
initialTank.tracks[protagonist.track.id];
console.log(highlightArmor.name);
if (highlightArmor.name.startsWith('hull_')) {
tank.armor.thickness[index] =
initialTank.armor.thickness[index];
} else if (highlightArmor.name.startsWith('turret_')) {
turret.armor.thickness[index] =
initialTurret.armor.thickness[index];
} else if (highlightArmor.name.startsWith('gun_')) {
if (highlightArmor.name.includes('_armor_')) {
gun.armor.thickness[index] =
initialGun.armor.thickness[index];
} else {
gun.thickness = initialGun.thickness;
}
} else if (highlightArmor.name.startsWith('chassis_')) {
track.thickness = initialTrack.thickness;
}
});
}}
>
<ReloadIcon />
</IconButton>
</Flex>
)}
</Flex>
</Card>
</Html>
</group>
);
}
@@ -1,7 +0,0 @@
import { GroupProps } from '@react-three/fiber';
import { forwardRef } from 'react';
import { Group } from 'three';
export const ModelTankWrapper = forwardRef<Group, GroupProps>((props, ref) => {
return <group rotation={[-Math.PI / 2, 0, 0]} ref={ref} {...props} />;
});
@@ -1,141 +0,0 @@
import { memo, useRef } from 'react';
import { Group } from 'three';
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 * as Duel from '../../../stores/duel';
import { ModelTankWrapper } from './ModelTankWrapper';
import { PrimaryArmorSceneComponent } from './PrimaryArmorSceneComponent';
export const PrimaryArmorScene = memo(() => {
const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null);
const protagonist = Duel.use((draft) => draft.protagonist);
const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const armorGltf = useArmor(tank.id).gltf;
const armorNodes = Object.values(armorGltf.nodes);
const tankModelDefinition = useTankModelDefinition();
const trackModelDefinition = tankModelDefinition.tracks[track.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turretOrigin);
const gunOrigin = correctZYTuple(turretModelDefinition.gunOrigin);
const isDynamicArmorActive = Duel.use((state) =>
state.protagonist.consumables.includes(73),
);
useTankTransform(protagonist, turretContainer, gunContainer);
return (
<ModelTankWrapper ref={wrapper}>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
if (
!isVisible ||
spaced ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_01')) ||
(!isDynamicArmorActive && node.name.includes('state_00'))
)
return null;
return (
<PrimaryArmorSceneComponent
key={node.uuid}
thickness={thickness}
node={node}
/>
);
})}
</group>
<group ref={turretContainer}>
{armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
turretModelDefinition.armor,
armorId,
);
if (
!isVisible ||
spaced ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_01')) ||
(!isDynamicArmorActive && node.name.includes('state_00'))
)
return null;
return (
<group position={hullOrigin}>
<group key={node.uuid} position={turretOrigin}>
<PrimaryArmorSceneComponent
key={node.uuid}
thickness={thickness}
node={node}
/>
</group>
</group>
);
})}
<group ref={gunContainer}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor,
armorId,
);
if (
!isVisible ||
spaced ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_01')) ||
(!isDynamicArmorActive && node.name.includes('state_00'))
)
return null;
return (
<group position={hullOrigin}>
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<PrimaryArmorSceneComponent
thickness={thickness}
node={node}
/>
</group>
</group>
);
})}
</group>
</group>
</ModelTankWrapper>
);
});
@@ -1,171 +0,0 @@
import {
ShellDefinition,
canSplash,
isExplosive,
resolveNearPenetration,
resolvePenetrationCoefficient,
} from '@blitzkit/core';
import { useFrame } from '@react-three/fiber';
import { useEffect } from 'react';
import { MeshBasicMaterial, Object3D, ShaderMaterial, Vector2 } from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../core/blitzkit/jsxTree';
import * as Duel from '../../../../stores/duel';
import * as TankopediaPersistent from '../../../../stores/tankopediaPersistent';
import fragmentShader from './shaders/fragment.glsl';
import vertexShader from './shaders/vertex.glsl';
import { spacedArmorRenderTarget } from './target';
interface PrimaryArmorSceneComponentProps {
thickness: number;
node: Object3D;
}
const excludeMaterial = new MeshBasicMaterial({
colorWrite: false,
});
export function PrimaryArmorSceneComponent({
node,
thickness,
}: PrimaryArmorSceneComponentProps) {
const tankopediaPersistentStore = TankopediaPersistent.useStore();
const duelStore = Duel.useStore();
const material = new ShaderMaterial({
fragmentShader,
vertexShader,
transparent: true,
uniforms: {
thickness: { value: null },
penetration: { value: null },
caliber: { value: null },
ricochet: { value: null },
normalization: { value: null },
isExplosive: { value: null },
canSplash: { value: null },
damage: { value: null },
explosionRadius: { value: null },
greenPenetration: { value: null },
opaque: { value: null },
inverseProjectionMatrix: { value: null },
resolution: { value: new Vector2() },
spacedArmorBuffer: { value: null },
spacedArmorDepth: { value: null },
},
});
useEffect(() => {
async function handleShellChange(shell: ShellDefinition) {
material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!,
);
material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0,
);
material.uniforms.isExplosive.value = isExplosive(shell.type);
material.uniforms.canSplash.value = canSplash(shell.type);
material.uniforms.damage.value = shell.damage.armor;
material.uniforms.explosionRadius.value = shell.explosionRadius;
const duel = duelStore.getState();
await handleProtagonistEquipmentChange(duel.protagonist.equipmentMatrix);
await handleAntagonistEquipmentChange(duel.antagonist.equipmentMatrix);
}
function handleVisualChange(
visual: TankopediaPersistent.TankopediaPersistent['model']['visual'],
) {
material.uniforms.greenPenetration.value = visual.greenPenetration;
material.uniforms.opaque.value = visual.opaque || visual.wireframe;
material.wireframe = visual.wireframe;
}
async function handleProtagonistEquipmentChange(
equipment: Duel.EquipmentMatrix,
) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist.tank.equipment,
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03
: thickness;
}
async function handleAntagonistEquipmentChange(
equipment: Duel.EquipmentMatrix,
) {
const duel = duelStore.getState();
const shell = duel.antagonist.shell;
const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist.tank.equipment,
equipment,
);
material.uniforms.penetration.value =
penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
}
handleShellChange(duelStore.getState().antagonist.shell);
handleVisualChange(tankopediaPersistentStore.getState().model.visual);
handleProtagonistEquipmentChange(
duelStore.getState().protagonist.equipmentMatrix,
);
handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix,
);
const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange),
tankopediaPersistentStore.subscribe(
(state) => state.model.visual,
handleVisualChange,
),
duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange,
),
duelStore.subscribe(
(state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange,
),
];
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
useFrame(({ gl, camera }) => {
gl.getSize(material.uniforms.resolution.value).multiplyScalar(
gl.getPixelRatio(),
);
material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
material.uniforms.spacedArmorDepth.value =
spacedArmorRenderTarget.depthTexture;
material.uniforms.inverseProjectionMatrix.value =
camera.projectionMatrixInverse;
});
return (
<>
{jsxTree(
node,
{ mesh: { renderOrder: 0, material: excludeMaterial } },
`${node.uuid}-exclude`,
)}
{jsxTree(
node,
{ mesh: { renderOrder: 1, material } },
`${node.uuid}-include`,
)}
</>
);
}
@@ -1,93 +0,0 @@
varying vec3 vNormal;
varying vec3 vViewPosition;
uniform float thickness;
uniform float penetration;
uniform float caliber;
uniform float ricochet;
uniform float normalization;
uniform vec2 resolution;
uniform bool isExplosive;
uniform bool canSplash;
uniform float damage;
uniform float explosionRadius;
uniform bool greenPenetration;
uniform bool opaque;
uniform mat4 inverseProjectionMatrix;
uniform highp sampler2D spacedArmorBuffer;
uniform highp sampler2D spacedArmorDepth;
float depthToDistance(float depth) {
vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
vec4 eyePosition = inverseProjectionMatrix * clipPosition;
return length(eyePosition.xyz / eyePosition.w);
}
void main() {
float penetrationChance = -1.0;
float splashChance = -1.0;
vec2 screenCoordinates = gl_FragCoord.xy / resolution;
float angle = acos(dot(vNormal, -vViewPosition) / length(vViewPosition));
vec4 spacedArmorBufferFragment = texture2D(spacedArmorBuffer, screenCoordinates);
bool isUnderSpacedArmor = spacedArmorBufferFragment.a != 0.0;
bool threeCalibersRule = caliber > thickness * 3.0 || isUnderSpacedArmor;
if (!threeCalibersRule && angle >= ricochet) {
penetrationChance = 0.0;
} else {
bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0;
float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization;
float finalThickness = thickness / cos(max(0.0, angle - finalNormalization));
// world space in meters
vec4 spacedArmorDepthFragment = texture2D(spacedArmorDepth, screenCoordinates);
float spacedArmorDistance = depthToDistance(spacedArmorDepthFragment.r);
float primaryArmorDistance = depthToDistance(gl_FragCoord.z);
float distanceFromSpacedArmor = primaryArmorDistance - spacedArmorDistance;
if (canSplash && isUnderSpacedArmor) {
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * (finalThickness + spacedArmorThickness);
penetrationChance = 0.0;
splashChance = finalDamage > 0.0 ? 1.0 : 0.0;
} else {
float remainingPenetration = penetration;
if (isUnderSpacedArmor) {
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
remainingPenetration -= spacedArmorThickness;
if (isExplosive && remainingPenetration > 0.0) {
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
}
}
remainingPenetration = max(0.0, remainingPenetration);
float delta = finalThickness - remainingPenetration;
float randomization = remainingPenetration * 0.05;
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
if (canSplash) {
float splashDamage = 0.5 * damage - 1.1 * finalThickness;
splashChance = splashDamage > 0.0 ? 1.0 : 0.0;
} else {
splashChance = 0.0;
}
}
}
float alpha = opaque ? 1.0 : 0.5;
vec3 color = vec3(1.0, splashChance * 0.392, 0.0);
if (greenPenetration) {
// non-trigonometric red-green mixing: https://www.desmos.com/calculator/ceo1oq7l67
float falling = -pow(penetrationChance, 2.0) + 1.0;
float gaining = -pow(penetrationChance - 1.0, 2.0) + 1.0;
gl_FragColor = vec4(falling * color + gaining * vec3(0.0, 1.0, 0.0), alpha);
} else {
gl_FragColor = vec4(color, (1.0 - penetrationChance) * alpha);
}
}
@@ -1,11 +0,0 @@
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
vViewPosition = vViewPosition4.xyz;
vNormal = normalMatrix * normal;
gl_Position = projectionMatrix * vViewPosition4;
}
@@ -1,3 +0,0 @@
import { WebGLRenderTarget } from 'three';
export const spacedArmorRenderTarget = new WebGLRenderTarget();
@@ -1,281 +0,0 @@
import { J_HAT, K_HAT } from '@blitzkit/core';
import { Box, Flex, Text } from '@radix-ui/themes';
import { Html } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import { clamp } from 'lodash';
import { ComponentProps, useEffect, useRef } from 'react';
import {
BufferGeometry,
DoubleSide,
Euler,
Group,
Line,
LineDashedMaterial,
Mesh,
MeshBasicMaterial,
Path,
Quaternion,
} from 'three';
import { useFullScreen } from '../../../hooks/useFullScreen';
import * as TankopediaEphemeral from '../../../stores/tankopediaEphemeral';
import { ShotDisplayCard } from './ShotDisplayCard';
export const shotStatusColors: Record<
TankopediaEphemeral.ShotStatus,
ComponentProps<typeof Text>['color']
> = {
blocked: 'red',
penetration: 'green',
ricochet: 'yellow',
splash: 'orange',
};
const SURFACE_POINT_SIZE = 12;
export const SHOT_DISPLAY_LENGTH_INFINITY = 4;
const TRACER_THICK = 1 / 32;
const TRACER_THIN = TRACER_THICK / 2;
export function ShotDisplay() {
const isFullscreen = useFullScreen();
const shot = TankopediaEphemeral.use((state) => state.shot);
const inTracer = useRef<Mesh>(null);
const outTracer = useRef<Mesh>(null);
const splashRadiusWrapper = useRef<Group>(null);
const splashRadiusMaterial = useRef<MeshBasicMaterial>(null);
let animationStartTime: number | null = null;
useEffect(() => {
if (shot?.splashRadius !== undefined) {
const outlineGeometry = new BufferGeometry().setFromPoints(
new Path().absarc(0, 0, 1, 0, Math.PI * 2).getSpacedPoints(50),
);
const outlineMaterial = new LineDashedMaterial({
color: 'orange',
dashSize: 0.05,
gapSize: 0.025,
});
const outline = new Line(
outlineGeometry,
outlineMaterial,
).computeLineDistances();
splashRadiusWrapper.current?.add(outline);
return () => {
splashRadiusWrapper.current?.remove(outline);
};
}
});
useEffect(() => {
const data = new Date();
const isHalloween = data.getMonth() === 9 && data.getDate() === 31;
if (
isHalloween &&
shot?.splashRadius !== undefined &&
shot.in.status === 'penetration'
) {
const audio = new Audio('/assets/audio/lotta-damage.mp3');
audio.currentTime = 0.7;
audio.volume = 0.25;
audio.play();
}
}, [shot]);
useFrame(({ clock }) => {
if (!shot) return;
const t = clock.elapsedTime;
if (inTracer.current) {
const tracerT1 = t % 1;
const tracerT2 = (t + 0.5) % 1;
inTracer.current.scale.set(1, 1 - 2 * Math.abs(tracerT1 - 0.5), 1);
inTracer.current.position.set(0, (1 - tracerT1) * inLength, 0);
if (outTracer.current) {
outTracer.current.scale.set(1, 1 - 2 * Math.abs(tracerT2 - 0.5), 1);
outTracer.current.position.set(0, tracerT2 * outLength, 0);
}
}
if (
shot.splashRadius !== undefined &&
splashRadiusWrapper.current &&
splashRadiusMaterial.current
) {
if (animationStartTime === null) animationStartTime = t;
const scale =
clamp(4 * (t - animationStartTime), 0, 1) * shot.splashRadius;
const opacity = 1 - clamp(2 * (t - animationStartTime), 0, 1);
splashRadiusWrapper.current.scale.set(scale, scale, scale);
splashRadiusMaterial.current.opacity = opacity;
}
});
if (!shot) return null;
const inLength =
shot.in.layers.reduce((accumulator, layer) => {
if (layer.type === null) return accumulator + layer.distance;
return accumulator;
}, 0) + SHOT_DISPLAY_LENGTH_INFINITY;
const outLength =
shot.out && shot.out.layers.length > 0
? (
shot.in.layers.at(-1) as TankopediaEphemeral.ShotLayerNonExternal
).point.distanceTo(
(shot.out.layers.at(-1) as TankopediaEphemeral.ShotLayerNonExternal)
.point,
)
: SHOT_DISPLAY_LENGTH_INFINITY;
const inLast = shot.in.layers.findLast(
(layer) => layer.type !== null,
) as TankopediaEphemeral.ShotLayerNonExternal;
const outTitleColor = shot.out
? shotStatusColors[shot.out.status]
: undefined;
const inTitleColor = shotStatusColors[shot.in.status];
const tracerGoingUp =
shot.in.surfaceNormal.y + (shot.out?.surfaceNormal.y ?? 0) > 0;
return (
<group>
{shot.splashRadius && (
<group
position={inLast.point}
ref={splashRadiusWrapper}
rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(K_HAT, shot.in.surfaceNormal),
)}
>
<mesh>
<circleGeometry />
<meshBasicMaterial
side={DoubleSide}
color="orange"
transparent
ref={splashRadiusMaterial}
/>
</mesh>
</group>
)}
<Html position={inLast.point} center>
<Box
display={isFullscreen ? undefined : { initial: 'none', sm: 'block' }}
style={{
pointerEvents: 'none',
transform: `translateY(${tracerGoingUp ? 50 : -50}%)`,
}}
mb={tracerGoingUp ? '0' : '9'}
pb={tracerGoingUp ? '0' : '4'}
mt={tracerGoingUp ? '9' : '0'}
pt={tracerGoingUp ? '4' : '0'}
>
<ShotDisplayCard shot={shot} />
</Box>
</Html>
{[...shot.in.layers, ...(shot.out?.layers ?? [])].map((layer, index) => {
if (layer.type === null) return null;
const shotStatusColor =
shot.in.status === 'splash'
? shotStatusColors.splash
: shotStatusColors[layer.status];
return (
<Html
position={layer.point}
key={index}
style={{
pointerEvents: 'none',
transform: 'translateY(-50%)',
}}
>
<Flex
style={{
marginLeft: -SURFACE_POINT_SIZE / 2,
}}
>
<div
style={{
width: SURFACE_POINT_SIZE,
height: SURFACE_POINT_SIZE,
backgroundColor: shotStatusColor,
opacity: 0.75,
borderRadius: SURFACE_POINT_SIZE / 2,
}}
/>
</Flex>
</Html>
);
})}
<group
position={inLast.point}
rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(J_HAT, shot.in.surfaceNormal),
)}
>
<mesh position={[0, inLength / 2, 0]} renderOrder={2}>
<cylinderGeometry
args={[TRACER_THIN / 2, TRACER_THIN / 2, inLength]}
/>
<meshBasicMaterial depthTest={false} transparent />
</mesh>
<mesh ref={inTracer} position={[0, inLength / 2, 0]} renderOrder={3}>
<cylinderGeometry
args={[TRACER_THICK / 2, TRACER_THICK / 2, inLength]}
/>
<meshBasicMaterial
color={inTitleColor}
depthTest={false}
transparent
/>
</mesh>
</group>
{shot.out && (
<group
position={inLast.point}
rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(
J_HAT,
shot.out.surfaceNormal.clone(),
),
)}
>
<mesh position={[0, outLength / 2, 0]} renderOrder={2}>
<cylinderGeometry
args={[TRACER_THIN / 2, TRACER_THIN / 2, outLength]}
/>
<meshBasicMaterial depthTest={false} transparent />
</mesh>
<mesh
ref={outTracer}
position={[0, outLength / 2, 0]}
renderOrder={3}
>
<cylinderGeometry
args={[TRACER_THICK / 2, TRACER_THICK / 2, outLength]}
/>
<meshBasicMaterial
color={outTitleColor}
depthTest={false}
transparent
/>
</mesh>
</group>
)}
</group>
);
}
@@ -1,229 +0,0 @@
import {
Card,
CardProps,
Flex,
Inset,
Separator,
Text,
} from '@radix-ui/themes';
import { ComponentProps } from 'react';
import { radToDeg } from 'three/src/math/MathUtils';
import * as TankopediaEphemeral from '../../../stores/tankopediaEphemeral';
import { shotStatusColors } from './ShotDisplay';
import { ArmorType } from './SpacedArmorScene';
import { ExternalModuleVariant } from './SpacedArmorSceneComponent';
export const layerTypeNames: Record<ArmorType | 'null', string> = {
[ArmorType.Primary]: 'Primary',
[ArmorType.Spaced]: 'Spaced',
[ArmorType.External]: 'External',
null: 'Gap',
};
const externalLayerNames: Record<ExternalModuleVariant, string> = {
gun: 'Gun',
track: 'Tracks',
};
const shotStatusNames: Record<TankopediaEphemeral.ShotStatus, string> = {
blocked: 'Blocked',
ricochet: 'Ricochet',
penetration: 'penetration',
splash: 'splash',
};
function LayerEntry({
layerIndex,
shotStatusColor,
layerName,
layer,
angle,
}: {
layerIndex?: string;
shotStatusColor: ComponentProps<typeof Text>['color'];
layerName: string;
layer: TankopediaEphemeral.ShotLayer;
angle?: number;
}) {
return (
<Flex align="center" gap="2">
<Text size="1" color="gray" style={{ width: 12 }}>
{layerIndex}
</Text>
<Text size="2" color={shotStatusColor} style={{ width: 48 }}>
{layerName}
</Text>
{layer.type === null && (
<>
<Text size="2" color={shotStatusColor} style={{ width: 64 }}>
{Math.round(layer.distance * 1000).toLocaleString()}
<Text size="1">mm</Text>
</Text>
<Text size="2" color="gray">
{Math.max(-100, Math.round(-50 * layer.distance))}% penetration
</Text>
</>
)}
{layer.type === ArmorType.External && (
<Text size="2" color={shotStatusColor} style={{ width: 64 }}>
{Math.round(layer.thickness).toLocaleString()}
<Text size="1">mm</Text>
</Text>
)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
<>
<Text size="2" color={shotStatusColor} style={{ width: 80 }}>
{Math.round(layer.thicknessAngled).toLocaleString()}
<Text size="1">{`mm${
angle === undefined ? '' : ` ${Math.round(radToDeg(angle))}°`
}`}</Text>
</Text>
<Text size="2" color="gray">
{Math.round(layer.thickness).toLocaleString()}
<Text size="1">mm</Text> nominal
</Text>
</>
)}
</Flex>
);
}
interface ShotDisplayCardProps extends CardProps {
shot: TankopediaEphemeral.Shot;
}
export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
if (!shot) return null;
const outTitleColor = shot.out
? shotStatusColors[shot.out.status]
: undefined;
const inTitleColor = shotStatusColors[shot.in.status];
return (
<Card style={{ width: 300 }} {...props}>
<Flex direction="column" gap="2">
<Flex direction="column" gap="1">
<Text color={inTitleColor} weight="bold">
{shot.in.status !== 'blocked' && shot.in.status !== 'ricochet' && (
<>
{Math.round(shot.damage).toLocaleString()}
<Text size="1">hp</Text>
</>
)}
{` ${shotStatusNames[shot.in.status]} `}
</Text>
<Flex direction="column">
{shot.in.layers.map((layer, index) => {
const layerName =
layer.type === ArmorType.External
? externalLayerNames[layer.variant]
: layerTypeNames[`${layer.type}`];
const layerIndex =
layer.type === null
? undefined
: `${(shot.containsGaps ? index / 2 : index) + 1}.`;
const shotStatusColor =
shot.in.status === 'splash'
? shotStatusColors.splash
: shotStatusColors[layer.status];
return (
<LayerEntry
key={index}
layer={layer}
layerIndex={layerIndex}
layerName={layerName}
shotStatusColor={shotStatusColor}
angle={
layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced
? layer.angle
: undefined
}
/>
);
})}
</Flex>
</Flex>
{shot.out && shot.out.layers.length > 0 && (
<>
<Inset side="x">
<Flex align="center" gap="2">
<Separator
style={{
flex: 1,
}}
/>
<Text size="1" color="gray">
ricochet (-25% penetration)
</Text>
<Separator
style={{
flex: 1,
}}
/>
</Flex>
</Inset>
<Text color={outTitleColor} weight="bold">
{shot.out.status !== 'blocked' &&
shot.out.status !== 'ricochet' && (
<>
{Math.round(shot.damage).toLocaleString()}
<Text size="1">hp</Text>
</>
)}
{` ${shotStatusNames[shot.out.status]} `}
</Text>
<Flex direction="column">
{shot.out.layers.map((layer, index) => {
const layerName =
layer.type === ArmorType.External
? externalLayerNames[layer.variant]
: layerTypeNames[`${layer.type}`];
const shiftedIndex = index + shot.out!.layers.length;
const layerIndex =
layer.type === null
? undefined
: `${
(shot.containsGaps ? shiftedIndex / 2 : shiftedIndex) +
1
}.`;
const shotStatusColor =
shot.out!.status === 'splash'
? shotStatusColors.splash
: shotStatusColors[layer.status];
return (
<LayerEntry
key={index}
layer={layer}
layerIndex={layerIndex}
layerName={layerName}
shotStatusColor={shotStatusColor}
angle={
layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced
? layer.angle
: undefined
}
/>
);
})}
</Flex>
</>
)}
</Flex>
</Card>
);
}
@@ -1,208 +0,0 @@
import { memo, useRef } from 'react';
import { Group, Plane, Scene, Vector3 } from 'three';
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 * as Duel from '../../../stores/duel';
import { ModelTankWrapper } from './ModelTankWrapper';
import { SpacedArmorSceneComponent } from './SpacedArmorSceneComponent';
export enum ArmorType {
Primary,
Spaced,
External,
}
interface SpacedArmorSceneProps {
scene: Scene;
}
export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null);
const protagonist = Duel.use((draft) => draft.protagonist);
const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const { gltf: armorGltf } = useArmor(tank.id);
const { gltf: modelGltf } = useModel(tank.id);
const armorNodes = Object.values(armorGltf.nodes);
const modelNodes = Object.values(modelGltf.nodes);
const tankModelDefinition = useTankModelDefinition();
const trackModelDefinition = tankModelDefinition.tracks[track.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turretOrigin);
const gunOrigin = correctZYTuple(turretModelDefinition.gunOrigin);
const maskOrigin =
gunModelDefinition.mask === undefined
? undefined
: gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y;
const isDynamicArmorActive = Duel.use((state) =>
state.protagonist.consumables.includes(73),
);
useTankTransform(protagonist, turretContainer, gunContainer);
return (
<ModelTankWrapper ref={wrapper}>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
if (
!isVisible ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_00')) ||
(!isDynamicArmorActive && node.name.includes('state_01'))
)
return null;
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
/>
);
})}
</group>
{modelNodes.map((node) => {
const isWheel = node.name.startsWith('chassis_wheel_');
const isTrack = node.name.startsWith('chassis_track_');
const isVisible = isWheel || isTrack;
if (!isVisible) return null;
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={ArmorType.External}
thickness={trackModelDefinition.thickness}
variant="track"
node={node}
/>
);
})}
<group ref={turretContainer}>
{armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
turretModelDefinition.armor,
armorId,
);
if (
!isVisible ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_00')) ||
(!isDynamicArmorActive && node.name.includes('state_01'))
)
return null;
return (
<group position={hullOrigin}>
<group key={node.uuid} position={turretOrigin}>
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
/>
</group>
</group>
);
})}
<group ref={gunContainer}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor,
armorId,
);
if (
!isVisible ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_00')) ||
(!isDynamicArmorActive && node.name.includes('state_01'))
)
return null;
return (
<group position={hullOrigin}>
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<SpacedArmorSceneComponent
scene={scene}
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
/>
</group>
</group>
);
})}
{modelNodes.map((node) => {
const gunString = `gun_${gunModelDefinition.model
.toString()
.padStart(2, '0')}`;
const isCurrentGun = gunModelDefinition.mask
? node.name.startsWith(gunString)
: node.name === gunString;
const isVisible = isCurrentGun;
if (!isVisible) return null;
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={ArmorType.External}
thickness={gunModelDefinition.thickness}
variant="gun"
node={node}
clip={
maskOrigin === undefined
? undefined
: new Plane(new Vector3(0, 0, -1), -maskOrigin)
}
/>
);
})}
</group>
</group>
</ModelTankWrapper>
);
});
@@ -1,144 +0,0 @@
import {
ShellDefinition,
resolveNearPenetration,
resolvePenetrationCoefficient,
} from '@blitzkit/core';
import { useEffect } from 'react';
import {
AdditiveBlending,
MeshBasicMaterial,
Object3D,
Plane,
ShaderMaterial,
} from 'three';
import { ArmorUserData, ExternalModuleVariant } from '../..';
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
import * as Duel from '../../../../../../stores/duel';
import { ArmorType } from '../../../SpacedArmorScene';
import fragmentShader from './shaders/fragment.glsl';
import vertexShader from './shaders/vertex.glsl';
interface SpacedArmorSubExternalProps {
node: Object3D;
thickness: number;
variant: ExternalModuleVariant;
clip?: Plane;
}
export function SpacedArmorSubExternal({
node,
thickness,
variant,
clip,
}: SpacedArmorSubExternalProps) {
const duelStore = Duel.useStore();
const material = new ShaderMaterial({
fragmentShader,
vertexShader,
depthTest: true,
depthWrite: false,
blending: AdditiveBlending,
clipping: clip !== undefined,
...(clip ? { clippingPlanes: [clip] } : {}),
uniforms: {
thickness: { value: null },
penetration: { value: null },
},
});
useEffect(() => {
function handleShellChange(shell: ShellDefinition) {
material.uniforms.penetration.value = resolveNearPenetration(
shell.penetration,
);
}
async function handleProtagonistEquipmentChange(
equipment: Duel.EquipmentMatrix,
) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist.tank.equipment,
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03
: thickness;
}
async function handleAntagonistEquipmentChange(
equipment: Duel.EquipmentMatrix,
) {
const duel = duelStore.getState();
const shell = duel.antagonist.shell;
const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist.tank.equipment,
equipment,
);
material.uniforms.penetration.value =
penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
}
handleShellChange(duelStore.getState().antagonist.shell);
handleProtagonistEquipmentChange(
duelStore.getState().protagonist.equipmentMatrix,
);
handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix,
);
const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange),
duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange,
),
duelStore.subscribe(
(state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange,
),
];
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
return (
<>
{jsxTree(
node,
{
mesh: {
renderOrder: 3,
material: new MeshBasicMaterial({
colorWrite: false,
depthTest: true,
depthWrite: true,
...(clip ? { clippingPlanes: [clip] } : {}),
}),
onClick() {},
userData: {
type: ArmorType.External,
thickness,
variant,
} satisfies ArmorUserData,
},
},
`${node.uuid}-exclude`,
)}
{jsxTree(
node,
{ mesh: { renderOrder: 4, material } },
`${node.uuid}-include`,
)}
</>
);
}
@@ -1,10 +0,0 @@
uniform float thickness;
uniform float penetration;
#include <clipping_planes_pars_fragment>
void main() {
#include <clipping_planes_fragment>
gl_FragColor = vec4(thickness / penetration, 0.0, 0.0, 1.0);
}
@@ -1,7 +0,0 @@
#include <clipping_planes_pars_vertex>
void main() {
#include <begin_vertex>
#include <project_vertex>
#include <clipping_planes_vertex>
}
@@ -1,153 +0,0 @@
import {
ShellDefinition,
isExplosive,
resolveNearPenetration,
resolvePenetrationCoefficient,
} from '@blitzkit/core';
import { useEffect } from 'react';
import {
AdditiveBlending,
MeshBasicMaterial,
Object3D,
ShaderMaterial,
} from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { ArmorUserData } from '../..';
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
import * as Duel from '../../../../../../stores/duel';
import { ArmorType } from '../../../SpacedArmorScene';
import fragmentShader from './shaders/fragment.glsl';
import vertexShader from './shaders/vertex.glsl';
interface SpacedArmorSubSpacedProps {
node: Object3D;
thickness: number;
}
const depthWriteMaterial = new MeshBasicMaterial({
depthWrite: true,
colorWrite: false,
});
export function SpacedArmorSubSpaced({
node,
thickness,
}: SpacedArmorSubSpacedProps) {
const duelStore = Duel.useStore();
const material = new ShaderMaterial({
fragmentShader,
vertexShader,
depthTest: true,
depthWrite: false,
blending: AdditiveBlending,
uniforms: {
thickness: { value: null },
penetration: { value: null },
caliber: { value: null },
ricochet: { value: null },
normalization: { value: null },
},
});
useEffect(() => {
function handleShellChange(shell: ShellDefinition) {
material.uniforms.penetration.value = resolveNearPenetration(
shell.penetration,
);
material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!,
);
material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0,
);
}
async function handleProtagonistEquipmentChange(
equipment: Duel.EquipmentMatrix,
) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist.tank.equipment,
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03
: thickness;
}
async function handleAntagonistEquipmentChange(
equipment: Duel.EquipmentMatrix,
) {
const duel = duelStore.getState();
const shell = duelStore.getState().antagonist.shell;
const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist.tank.equipment,
equipment,
);
material.uniforms.penetration.value =
penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
}
handleShellChange(duelStore.getState().antagonist.shell);
handleProtagonistEquipmentChange(
duelStore.getState().protagonist.equipmentMatrix,
);
handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix,
);
const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange),
duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange,
),
duelStore.subscribe(
(state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange,
),
];
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
return (
<>
{jsxTree(
node,
{
mesh: {
renderOrder: 2,
material,
onClick() {},
userData: {
type: ArmorType.Spaced,
thickness,
} satisfies ArmorUserData,
},
},
`${node.uuid}-exclude`,
)}
{jsxTree(
node,
{
mesh: {
renderOrder: 5,
material: depthWriteMaterial,
},
},
`${node.uuid}-include`,
)}
</>
);
}
@@ -1,24 +0,0 @@
varying vec3 vNormal;
varying vec3 vViewPosition;
uniform float thickness;
uniform float penetration;
uniform float caliber;
uniform float ricochet;
uniform float normalization;
void main() {
float angle = acos(dot(vNormal, -vViewPosition) / length(vViewPosition));
bool threeCalibersRule = caliber > thickness * 3.0;
if (!threeCalibersRule && angle >= ricochet) {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
return;
}
bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0;
float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization;
float finalThickness = thickness / cos(max(0.0, angle - finalNormalization));
gl_FragColor = vec4(finalThickness / penetration, 0.0, 0.0, 1.0);
}
@@ -1,11 +0,0 @@
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
vViewPosition = vViewPosition4.xyz;
vNormal = normalMatrix * normal;
gl_Position = projectionMatrix * vViewPosition4;
}
@@ -1,411 +0,0 @@
import {
ShellType,
isExplosive,
resolveNearPenetration,
resolvePenetrationCoefficient,
} from '@blitzkit/core';
import { useThree } from '@react-three/fiber';
import { useCallback } from 'react';
import {
Intersection,
MeshBasicMaterial,
Object3D,
Plane,
Quaternion,
Raycaster,
Scene,
Vector3,
} from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../core/blitzkit/jsxTree';
import { discardClippingPlane } from '../../../../core/three/discardClippingPlane';
import * as Duel from '../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../stores/tankopediaEphemeral';
import { ArmorType } from '../SpacedArmorScene';
import { SpacedArmorSubExternal } from './components/SpacedArmorSubExternal';
import { SpacedArmorSubSpaced } from './components/SpacedArmorSubSpaced';
type SpacedArmorSceneComponentProps = {
node: Object3D;
thickness: number;
clip?: Plane;
scene: Scene;
} & (
| {
type: Exclude<ArmorType, ArmorType.External>;
}
| {
type: ArmorType.External;
variant: ExternalModuleVariant;
}
);
export type ExternalModuleVariant = 'gun' | 'track';
export type ArmorUserData = {
thickness: number;
} & (
| {
type: Exclude<ArmorType, ArmorType.External>;
}
| {
type: ArmorType.External;
variant: ExternalModuleVariant;
}
);
const omitMaterial = new MeshBasicMaterial({
colorWrite: false,
depthWrite: true,
});
/**
* Render orders allocations:
* - 0 : core omission
* - 1-2: spaced without depth buffer write but do test to allow additive blending
* - 3-4: external with depth buffer write in first pass and read in second to simplify geometry
* - 5 : spaced depth write
*/
export function SpacedArmorSceneComponent({
node,
thickness,
clip,
...props
}: SpacedArmorSceneComponentProps) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const duelStore = Duel.useStore();
const camera = useThree((state) => state.camera);
const shoot = useCallback(
async (
point: Vector3,
intersections: Intersection[],
allowRicochet: boolean,
remainingPenetrationInput?: number,
) => {
const { antagonist, protagonist } = duelStore.getState();
const shell = antagonist.shell;
const cameraNormal = camera.position.clone().sub(point).normalize();
const shot: TankopediaEphemeral.Shot = {
splashRadius: shell.explosionRadius,
damage: -1,
containsGaps: shell.type === ShellType.HEAT,
in: {
surfaceNormal: cameraNormal,
layers: [],
status: 'blocked',
},
};
const hasCalibratedShells = await hasEquipment(
103,
antagonist.tank.equipment,
antagonist.equipmentMatrix,
);
const hasEnhancedArmor = await hasEquipment(
110,
protagonist.tank.equipment,
protagonist.equipmentMatrix,
);
const penetration =
resolveNearPenetration(shell.penetration) *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1;
const filteredIntersections = intersections.filter(
(intersection) =>
'type' in intersection.object.userData &&
!discardClippingPlane(intersection.object, intersection.point),
) as unknown as Intersection<Object3D & { userData: ArmorUserData }>[];
const encounteredExternalModuleVariants: ExternalModuleVariant[] = [];
const noDuplicateIntersections: typeof filteredIntersections = [];
for (const intersection of filteredIntersections) {
if (intersection.object.userData.type === ArmorType.External) {
if (
!encounteredExternalModuleVariants.includes(
intersection.object.userData.variant,
)
) {
noDuplicateIntersections.push(intersection);
encounteredExternalModuleVariants.push(
intersection.object.userData.variant,
);
}
} else {
noDuplicateIntersections.push(intersection);
if (intersection.object.userData.type === ArmorType.Primary) {
break;
}
}
}
if (
!allowRicochet &&
noDuplicateIntersections.every((intersection) => {
intersection.object.userData.type !== ArmorType.Primary;
})
) {
return null;
}
let remainingPenetration = remainingPenetrationInput ?? penetration;
let index = 0;
for (const intersection of noDuplicateIntersections) {
const layer = intersection.object.userData;
const surfaceNormal = intersection
.normal!.clone()
.applyQuaternion(
intersection.object.getWorldQuaternion(new Quaternion()),
);
if (shell.type === ShellType.HEAT && index > 0) {
const lastLayer = shot.in.layers.at(
-1,
) as TankopediaEphemeral.ShotLayerBase;
const distance = lastLayer.point.distanceTo(intersection.point);
remainingPenetration -= 0.5 * remainingPenetration * distance;
const blocked = remainingPenetration <= 0;
shot.in.layers.push({
type: null,
distance,
status: blocked ? 'blocked' : 'penetration',
});
if (blocked) break;
}
if (layer.type === ArmorType.External) {
const thickness = layer.thickness * thicknessCoefficient;
remainingPenetration -= thickness;
const blocked =
remainingPenetration <= 0 || shell.type === ShellType.HE;
shot.in.layers.push({
type: ArmorType.External,
index,
thickness,
point: intersection.point,
surfaceNormal,
status:
shell.type === ShellType.HE
? 'blocked'
: blocked
? 'blocked'
: 'penetration',
variant: layer.variant,
});
} else {
const thickness = layer.thickness;
const ricochet = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!,
);
const angle = surfaceNormal.angleTo(cameraNormal);
const threeCalibersRule =
shell.caliber > thickness * 3 || index > 0 || !allowRicochet;
const twoCalibersRule = shell.caliber > thickness * 2;
const normalization = degToRad(shell.normalization ?? 0);
const finalNormalization = twoCalibersRule
? (1.4 * normalization * shell.caliber) / (2.0 * thickness)
: normalization;
const finalThickness =
thickness / Math.cos(Math.max(0, angle - finalNormalization));
if (!threeCalibersRule && angle >= ricochet) {
remainingPenetration *= 0.75;
shot.in.layers.push({
type: layer.type,
index,
thickness,
thicknessAngled: finalThickness,
point: intersection.point,
surfaceNormal,
status: 'ricochet',
angle,
});
break;
} else {
remainingPenetration -= finalThickness;
const blocked = remainingPenetration <= 0;
shot.in.layers.push({
type: layer.type,
index,
thickness,
thicknessAngled: finalThickness,
point: intersection.point,
surfaceNormal: intersection.face!.normal,
status:
shell.type === ShellType.HE && layer.type !== ArmorType.Primary
? 'blocked'
: blocked
? 'blocked'
: 'penetration',
angle,
});
}
}
if (shell.type !== ShellType.HE && remainingPenetration <= 0) {
break;
}
index++;
}
const lastLayer = shot.in.layers.at(-1) as
| TankopediaEphemeral.ShotLayerNonExternal
| undefined;
const firstLayer = shot.in.layers[0] as
| TankopediaEphemeral.ShotLayerBase
| undefined;
if (!lastLayer || !firstLayer) {
return null;
} else {
if (shell.type === ShellType.HE) {
const totalSpacedArmorThickness = shot.in.layers.reduce(
(acc, layer) => {
if (layer.type === null || layer.type === ArmorType.Primary) {
return acc;
}
if (layer.type === ArmorType.External) {
return acc + layer.thickness;
}
return acc + layer.thicknessAngled;
},
0,
);
const distanceFromSpacedArmor = firstLayer.point.distanceTo(
lastLayer.point,
);
const finalDamage = Math.max(
0,
0.5 *
shell.damage.armor *
(1 - distanceFromSpacedArmor / shell.explosionRadius!) -
1.1 *
(lastLayer.thicknessAngled +
Math.min(penetration, totalSpacedArmorThickness)),
);
/**
* Above is some peculiar math. We're multiplying 1.1 by the
* thickness of the core plate plus the total spaced armor
* thickness capped to the penetration itself. This is because
* the shader is normalized by the penetration value; hence,
* the total spaced armor thickness cannot ever be thicker
* than the penetration. This is a precision error on the
* shader-side and I do not intend on fixing it. Instead,
* for parity's sake, I am capping the value here.
*/
if (shot.in.layers.length > 1 || lastLayer.status === 'blocked') {
shot.in.status = finalDamage > 0 ? 'splash' : 'blocked';
shot.damage = finalDamage;
} else {
shot.in.status = 'penetration';
shot.damage = shell.damage.armor;
}
} else {
if (lastLayer.status === 'blocked') {
shot.in.status = 'blocked';
shot.damage = 0;
} else if (lastLayer.status === 'penetration') {
shot.in.status = 'penetration';
shot.damage = shell.damage.armor;
} else {
const caster = new Raycaster();
// https://math.stackexchange.com/a/13263/1222875
const shellNormal = cameraNormal.clone().multiplyScalar(-1);
const ricochetNormal = shellNormal
.clone()
.sub(
lastLayer.surfaceNormal
.clone()
.multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal)),
);
caster.set(lastLayer.point, ricochetNormal);
const ricochetIntersections = caster.intersectObjects(
props.scene.children,
true,
);
const outShot = await shoot(
lastLayer.point,
ricochetIntersections,
false,
remainingPenetration,
);
shot.in.status = 'ricochet';
if (outShot === null) {
shot.damage = 0;
shot.out = {
surfaceNormal: ricochetNormal,
layers: [],
status: 'ricochet',
};
} else {
shot.damage = outShot.damage;
shot.out = outShot.in;
shot.out.surfaceNormal = ricochetNormal;
}
}
}
}
return shot;
},
[camera],
);
return (
<>
{props.type === ArmorType.Primary &&
jsxTree(
node,
{
mesh: {
renderOrder: 0,
material: omitMaterial,
userData: {
type: ArmorType.Primary,
thickness,
} satisfies ArmorUserData,
async onClick(event) {
event.stopPropagation();
const shot = (await shoot(
event.point,
event.intersections,
true,
))!;
tankopediaEphemeralStore.setState({ shot });
},
},
},
node.uuid,
)}
{props.type === ArmorType.Spaced && (
<SpacedArmorSubSpaced node={node} thickness={thickness} />
)}
{props.type === ArmorType.External && (
<SpacedArmorSubExternal
node={node}
thickness={thickness}
variant={props.variant}
clip={clip}
/>
)}
</>
);
}
@@ -1,412 +0,0 @@
import { normalizeAngleRad } from '@blitzkit/core';
import { ThreeEvent, useThree } from '@react-three/fiber';
import { memo, useRef } from 'react';
import { Group, Plane, Vector2, Vector3 } from 'three';
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 * as Duel from '../../../stores/duel';
import * as TankopediaEphemeral from '../../../stores/tankopediaEphemeral';
import * as TankopediaPersistent from '../../../stores/tankopediaPersistent';
import { ModelTankWrapper } from './ModelTankWrapper';
import { ArmorType } from './SpacedArmorScene';
import { StaticArmorSceneComponent } from './StaticArmorSceneComponent';
interface ArmorSceneProps {
thicknessRange: ThicknessRange;
}
export interface ThicknessRange {
value: number;
}
export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null);
const protagonist = Duel.use((draft) => draft.protagonist);
const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const { gltf: armorGltf } = useArmor(tank.id);
const { gltf: modelGltf } = useModel(tank.id);
const armorNodes = Object.values(armorGltf.nodes);
const modelNodes = Object.values(modelGltf.nodes);
const tankModelDefinition = useTankModelDefinition();
const trackModelDefinition = tankModelDefinition.tracks[track.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turretOrigin);
const gunOrigin = correctZYTuple(turretModelDefinition.gunOrigin);
const mutateDuel = Duel.useMutation();
const canvas = useThree((state) => state.gl.domElement);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const duelStore = Duel.useStore();
const maskOrigin =
gunModelDefinition.mask === undefined
? undefined
: gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y;
const showPrimaryArmor = TankopediaPersistent.use(
(state) => state.model.visual.showPrimaryArmor,
);
const showSpacedArmor = TankopediaPersistent.use(
(state) => state.model.visual.showSpacedArmor,
);
const showExternalModules = TankopediaPersistent.use(
(state) => state.model.visual.showExternalModules,
);
const isDynamicArmorActive = Duel.use((state) =>
state.protagonist.consumables.includes(73),
);
useTankTransform(protagonist, turretContainer, gunContainer);
return (
<ModelTankWrapper ref={wrapper}>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
if (
!isVisible ||
thickness === undefined ||
(!showPrimaryArmor && !spaced) ||
(!showSpacedArmor && spaced) ||
(isDynamicArmorActive && node.name.includes('state_00')) ||
(!isDynamicArmorActive && node.name.includes('state_01'))
)
return null;
return (
<StaticArmorSceneComponent
name={node.name}
thicknessRange={thicknessRange}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
onPointerDown={(event) => event.stopPropagation()}
/>
);
})}
</group>
{modelNodes.map((node) => {
const isWheel = node.name.startsWith('chassis_wheel_');
const isTrack = node.name.startsWith('chassis_track_');
const isVisible = isWheel || isTrack;
if (!isVisible || !showExternalModules) return null;
return (
<StaticArmorSceneComponent
name={node.name}
thicknessRange={thicknessRange}
key={node.uuid}
type={ArmorType.External}
thickness={trackModelDefinition.thickness}
variant="track"
node={node}
onPointerDown={(event) => event.stopPropagation()}
/>
);
})}
<group ref={turretContainer}>
{armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
turretModelDefinition.armor,
armorId,
);
if (
!isVisible ||
thickness === undefined ||
(!showPrimaryArmor && !spaced) ||
(!showSpacedArmor && spaced) ||
(isDynamicArmorActive && node.name.includes('state_00')) ||
(!isDynamicArmorActive && node.name.includes('state_01'))
) {
return null;
}
const position = new Vector2();
const delta = new Vector2();
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
position.set(event.clientX, event.clientY);
yaw = protagonist.yaw;
pitch = protagonist.pitch;
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = false;
});
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
draft.highlightArmor = undefined;
});
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
async function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
122,
duel.protagonist.tank.equipment,
duel.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits(
pitch,
yaw + delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
}
function handlePointerUp() {
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true;
});
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
}
return (
<group position={hullOrigin}>
<group key={node.uuid} position={turretOrigin}>
<StaticArmorSceneComponent
name={node.name}
thicknessRange={thicknessRange}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
onPointerDown={onPointerDown}
/>
</group>
</group>
);
})}
<group ref={gunContainer}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor,
armorId,
);
if (
!isVisible ||
thickness === undefined ||
(!showPrimaryArmor && !spaced) ||
(!showSpacedArmor && spaced) ||
(isDynamicArmorActive && node.name.includes('state_00')) ||
(!isDynamicArmorActive && node.name.includes('state_01'))
)
return null;
const position = new Vector2();
const delta = new Vector2();
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = false;
});
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
draft.highlightArmor = undefined;
});
position.set(event.clientX, event.clientY);
pitch = protagonist.pitch;
yaw = protagonist.yaw;
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
async function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
122,
duel.protagonist.tank.equipment,
duel.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits(
pitch - delta.y * (Math.PI / boundingRect.height),
yaw + delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
}
function handlePointerUp() {
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true;
});
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
}
return (
<group position={hullOrigin}>
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<StaticArmorSceneComponent
name={node.name}
thicknessRange={thicknessRange}
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
onPointerDown={onPointerDown}
/>
</group>
</group>
);
})}
{modelNodes.map((node) => {
const gunString = `gun_${gunModelDefinition.model
.toString()
.padStart(2, '0')}`;
const isCurrentGun = gunModelDefinition.mask
? node.name.startsWith(gunString)
: node.name === gunString;
const isVisible = isCurrentGun;
if (!isVisible || !showExternalModules) return null;
const position = new Vector2();
const delta = new Vector2();
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = false;
});
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
draft.highlightArmor = undefined;
});
position.set(event.clientX, event.clientY);
pitch = protagonist.pitch;
yaw = protagonist.yaw;
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
async function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
122,
duel.protagonist.tank.equipment,
duel.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits(
pitch - delta.y * (Math.PI / boundingRect.height),
yaw + delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
}
function handlePointerUp() {
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true;
});
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
}
return (
<StaticArmorSceneComponent
name={node.name}
thicknessRange={thicknessRange}
key={node.uuid}
type={ArmorType.External}
thickness={gunModelDefinition.thickness}
variant="gun"
node={node}
clip={
maskOrigin === undefined
? undefined
: new Plane(new Vector3(0, 0, -1), -maskOrigin)
}
hullOrigin={hullOrigin}
turretOrigin={turretOrigin}
gunOrigin={gunOrigin}
onPointerDown={onPointerDown}
/>
);
})}
</group>
</group>
</ModelTankWrapper>
);
});
@@ -1,272 +0,0 @@
import { I_HAT, J_HAT } from '@blitzkit/core';
import { MeshProps, useThree } from '@react-three/fiber';
import { clamp } from 'lodash';
import { useEffect, useMemo } from 'react';
import {
Box3,
Color,
DoubleSide,
FrontSide,
LineBasicMaterial,
Mesh,
MeshStandardMaterial,
Object3D,
Plane,
Quaternion,
Vector3,
} from 'three';
import { unrotateDavaVector } from '../../../core/blitz/unrotateDavaVector';
import { jsxTree } from '../../../core/blitzkit/jsxTree';
import {
ModelTransformEventData,
modelTransformEvent,
} from '../../../core/blitzkit/modelTransform';
import { discardClippingPlane } from '../../../core/three/discardClippingPlane';
import * as TankopediaEphemeral from '../../../stores/tankopediaEphemeral';
import { ArmorType } from './SpacedArmorScene';
import {
ArmorUserData,
ExternalModuleVariant,
} from './SpacedArmorSceneComponent';
import { ThicknessRange } from './StaticArmor';
type StaticArmorSceneComponentProps = {
name: string;
thickness: number;
thicknessRange: ThicknessRange;
node: Object3D;
onPointerDown?: MeshProps['onPointerDown'];
} & (
| {
type: Exclude<ArmorType, ArmorType.External>;
}
| {
type: ArmorType.External;
variant: ExternalModuleVariant;
}
) &
(
| {
clip?: undefined;
}
| {
clip: Plane;
hullOrigin: Vector3;
turretOrigin: Vector3;
gunOrigin: Vector3;
}
);
const unselectedColor = new Color(0x404040);
const externalModuleColor = new Color(0, 1 / 4, 1 / 2);
export function StaticArmorSceneComponent({
name,
thickness,
thicknessRange,
node,
onPointerDown,
...props
}: StaticArmorSceneComponentProps) {
const mutateTankopediaEphemeralStore = TankopediaEphemeral.useMutation();
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const camera = useThree((state) => state.camera);
const x = thickness / thicknessRange.value;
const xClamped = clamp(x, 0, 1);
let color: Color;
let opacity: number;
let depthWrite = true;
switch (props.type) {
case ArmorType.Primary:
if (x > 1) {
color = new Color(clamp(2 - x, 0.5, 1), 0, 0);
} else {
color = new Color(-((1 - x) ** 2) + 1, -(x ** 2) + 1, 0);
}
opacity = 1;
break;
case ArmorType.Spaced:
color = new Color(
clamp(1 - (7 / 8) * xClamped, 0, 1),
0,
clamp(1 - (1 / 8) * xClamped, 0, 1),
);
opacity = clamp(x + 1 / 2, 0, 1);
break;
case ArmorType.External:
color = externalModuleColor;
opacity = 1 / 8;
depthWrite = false;
break;
}
opacity = clamp(opacity, 0, 1);
const surfaceMaterial = useMemo(
() =>
new MeshStandardMaterial({
color,
opacity,
transparent: opacity < 1,
depthWrite,
...(props.clip ? { clippingPlanes: [props.clip] } : {}),
}),
[thickness],
);
const outlineMaterial = useMemo(
() =>
new LineBasicMaterial({
color: color
.clone()
.multiplyScalar(props.type === ArmorType.Spaced ? 2 ** 2 : 2 ** -1),
}),
[thickness],
);
useEffect(() => {
function handleHighlightArmor(selectedName?: string) {
if (selectedName === undefined) {
// nothing selected, go back to defaults
surfaceMaterial.opacity = opacity;
surfaceMaterial.transparent = opacity < 1;
surfaceMaterial.color = color;
surfaceMaterial.depthWrite = props.type !== ArmorType.External;
surfaceMaterial.side = FrontSide;
outlineMaterial.visible = true;
} else if (
selectedName === name ||
(name.startsWith('chassis_') && selectedName.startsWith('chassis_')) ||
(name.startsWith('gun_') &&
selectedName.startsWith('gun_') &&
!name.includes('_armor_') &&
!selectedName.includes('_armor_'))
) {
// this selected, stand out!
surfaceMaterial.opacity = 1;
surfaceMaterial.transparent = false;
surfaceMaterial.color = color;
surfaceMaterial.depthWrite = true;
surfaceMaterial.side = DoubleSide;
outlineMaterial.visible = true;
} else {
// something else selected, become background
surfaceMaterial.opacity = 1 / 4;
surfaceMaterial.transparent = true;
surfaceMaterial.color = unselectedColor;
surfaceMaterial.depthWrite = props.type !== ArmorType.External;
surfaceMaterial.side = FrontSide;
outlineMaterial.visible = false;
}
surfaceMaterial.needsUpdate = true;
}
const unsubscribes = [
tankopediaEphemeralStore.subscribe(
(state) => state.highlightArmor?.name,
handleHighlightArmor,
),
];
if (props.clip) {
/**
* hook inside an if statement?? don't panic! I assure you the clip prop
* never mutates :)
*/
const { clip } = props;
const gunOrigin = unrotateDavaVector(props.gunOrigin.clone());
const neckOrigin = unrotateDavaVector(props.hullOrigin.clone()).add(
unrotateDavaVector(props.turretOrigin.clone()),
);
const barrelOrigin = neckOrigin.clone().add(gunOrigin);
const distanceToBarrel = clip.distanceToPoint(barrelOrigin);
const point = new Vector3();
function handleModelTransform(data: ModelTransformEventData) {
clip.normal
.set(0, 0, -1)
.applyAxisAngle(I_HAT, data.pitch)
.applyAxisAngle(J_HAT, data.yaw ?? 0);
clip.constant = 0;
point
.copy(gunOrigin)
.applyAxisAngle(J_HAT, data.yaw ?? 0)
.add(clip.normal.multiplyScalar(-distanceToBarrel))
.add(neckOrigin);
clip.normal.normalize();
clip.constant -= clip.distanceToPoint(point);
}
modelTransformEvent.on(handleModelTransform);
unsubscribes.push(() => modelTransformEvent.off(handleModelTransform));
}
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
}, [thickness]);
return (
<>
{jsxTree(node, {
mesh: {
material: surfaceMaterial,
onPointerDown,
userData: {
type: props.type,
variant: props.type === ArmorType.External ? props.variant : 'gun',
thickness,
} satisfies ArmorUserData,
onClick(event) {
if (discardClippingPlane(event.object, event.point)) return;
event.stopPropagation();
const { editStatic } = tankopediaEphemeralStore.getState();
const bounds = new Box3().setFromObject(event.object);
const point = bounds.min
.clone()
.add(bounds.max)
.divideScalar(2)
.setY(bounds.max.y);
const cameraNormal = camera.position.clone().sub(point).normalize();
const surfaceNormal = event
.normal!.clone()
.applyQuaternion(
event.object.getWorldQuaternion(new Quaternion()),
);
const angle = surfaceNormal.angleTo(cameraNormal);
const thicknessAngled = thickness / Math.sin(Math.PI / 2 - angle);
mutateTankopediaEphemeralStore((draft) => {
draft.highlightArmor = {
type: props.type,
name,
point,
thickness,
thicknessAngled,
angle,
color: `#${color.getHexString()}`,
editingPlate: editStatic,
};
});
},
},
})}
{node instanceof Mesh && (
<lineSegments material={outlineMaterial}>
<edgesGeometry args={[node.geometry, 45]} />
</lineSegments>
)}
</>
);
}
@@ -1,55 +0,0 @@
import { createPortal, useFrame } from '@react-three/fiber';
import { memo, useState } from 'react';
import { DepthTexture, Scene, Vector2 } from 'three';
import { PrimaryArmorScene } from './components/PrimaryArmorScene';
import { spacedArmorRenderTarget } from './components/PrimaryArmorSceneComponent/target';
import { SpacedArmorScene } from './components/SpacedArmorScene';
export const Armor = memo(() => {
const [spacedArmorScene] = useState(() => new Scene());
const [primaryArmorScene] = useState(() => new Scene());
const spacedArmorPortal = createPortal(
<SpacedArmorScene scene={spacedArmorScene} />,
spacedArmorScene,
);
const primaryArmorPortal = createPortal(
<PrimaryArmorScene />,
primaryArmorScene,
);
const renderSize = new Vector2();
const newRenderSize = new Vector2();
useFrame(({ gl, camera, scene }) => {
gl.autoClear = true;
gl.getSize(newRenderSize).multiplyScalar(gl.getPixelRatio());
if (!newRenderSize.equals(renderSize)) {
renderSize.copy(newRenderSize);
spacedArmorRenderTarget.depthTexture = new DepthTexture(
renderSize.x,
renderSize.y,
);
}
spacedArmorRenderTarget.setSize(renderSize.x, renderSize.y);
gl.setRenderTarget(spacedArmorRenderTarget);
gl.render(spacedArmorScene, camera);
gl.autoClear = false;
gl.setRenderTarget(null);
gl.render(scene, camera);
gl.clearDepth();
gl.render(primaryArmorScene, camera);
}, 1);
return (
<>
{spacedArmorPortal}
{primaryArmorPortal}
</>
);
});
@@ -1,40 +0,0 @@
import { ComponentProps } from 'react';
export function BlitzkitButtonGrayIcon(props: ComponentProps<'svg'>) {
return (
<svg
width="426"
height="426"
viewBox="0 0 426 426"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clip-path="url(#clip0_600_256)">
<path
d="M416.628 244.997L235.625 372.974C223.13 381.809 202.87 381.809 190.375 372.974L9.37173 244.997C3.1239 240.58 -3.95076e-06 234.79 0 229V196.6L213 77.5L426 196.6V229C426 234.79 422.876 240.58 416.628 244.997Z"
fill="#9E9E9E"
/>
<path
d="M200.456 257.38L221.999 242.08C228.557 237.422 231.732 232.85 231.521 228.364C231.31 223.797 228.392 219.516 222.764 215.519C217.193 211.562 211.22 209.529 204.846 209.42C198.473 209.23 192.007 211.465 185.448 216.123L163.905 231.422L200.456 257.38Z"
fill="white"
/>
<path
d="M146.77 219.252L165.937 205.64C172.44 201.022 175.726 196.69 175.796 192.643C175.809 188.557 173.256 184.696 168.135 181.059C163.07 177.462 157.717 175.708 152.076 175.798C146.435 175.887 140.363 178.241 133.861 182.859L114.693 196.472L146.77 219.252Z"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M235.625 52.6262L416.628 180.603C429.124 189.438 429.124 203.762 416.628 212.597L235.625 340.574C223.13 349.409 202.87 349.409 190.375 340.574L9.37173 212.597C-3.12391 203.762 -3.12391 189.438 9.37172 180.603L190.375 52.6262C202.87 43.7913 223.13 43.7913 235.625 52.6262ZM290.596 213.945L197.056 150.108L217.292 135.762L265.867 167.722L263.968 133.871L286.355 118L288.049 158.111L359.102 165.378L335.236 182.298L288.86 177.31L289.153 184.23L310.832 199.599L290.596 213.945ZM70 201.714L192.905 289L241.672 254.366C254.96 244.929 262.551 235.142 264.446 225.004C266.398 214.825 261.803 205.779 250.66 197.866C242.613 192.151 234.247 188.9 225.563 188.113C216.935 187.286 208.978 188.301 201.692 191.158L201.354 190.918C204.302 186.506 205.165 181.658 203.941 176.372C202.718 171.006 198.702 165.905 191.893 161.069C180.919 153.275 168.971 150.211 156.048 151.875C143.124 153.539 129.906 159.17 116.393 168.767L70 201.714Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_600_256">
<rect width="426" height="426" fill="white" />
</clipPath>
</defs>
</svg>
);
}
@@ -1,58 +0,0 @@
import { ComponentProps } from 'react';
export function ClassHeavy(props: ComponentProps<'svg'>) {
return (
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_70_177)">
<g clipPath="url(#clip1_70_177)">
<rect
y="32"
width="45.2548"
height="12.4183"
rx="6.20914"
transform="rotate(-45 0 32)"
fill="currentColor"
/>
<rect
x="11.6095"
y="43.6095"
width="45.2548"
height="12.4183"
rx="6.20914"
transform="rotate(-45 11.6095 43.6095)"
fill="currentColor"
/>
<rect
x="23.2189"
y="55.219"
width="45.2548"
height="12.4183"
rx="6.20914"
transform="rotate(-45 23.2189 55.219)"
fill="currentColor"
/>
</g>
</g>
<defs>
<clipPath id="clip0_70_177">
<rect width="64" height="64" fill="white" />
</clipPath>
<clipPath id="clip1_70_177">
<rect
width="45.2548"
height="45.2548"
fill="white"
transform="translate(0 32) rotate(-45)"
/>
</clipPath>
</defs>
</svg>
);
}
@@ -1,23 +0,0 @@
import { ComponentProps } from 'react';
export function ClassLight(props: ComponentProps<'svg'>) {
return (
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect
y="32"
width="45.2548"
height="45.2548"
rx="8"
transform="rotate(-45 0 32)"
fill="currentColor"
/>
</svg>
);
}
@@ -1,49 +0,0 @@
import { ComponentProps } from 'react';
export function ClassMedium(props: ComponentProps<'svg'>) {
return (
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_70_149)">
<g clipPath="url(#clip1_70_149)">
<rect
y="32"
width="45.2548"
height="20.6274"
rx="8"
transform="rotate(-45 0 32)"
fill="currentColor"
/>
<rect
x="17.4142"
y="49.4142"
width="45.2548"
height="20.6274"
rx="8"
transform="rotate(-45 17.4142 49.4142)"
fill="currentColor"
/>
</g>
</g>
<defs>
<clipPath id="clip0_70_149">
<rect width="64" height="64" fill="white" />
</clipPath>
<clipPath id="clip1_70_149">
<rect
width="45.2548"
height="45.2548"
fill="white"
transform="translate(0 32) rotate(-45)"
/>
</clipPath>
</defs>
</svg>
);
}
@@ -1,19 +0,0 @@
import { ComponentProps } from 'react';
export function ClassTankDestroyer(props: ComponentProps<'svg'>) {
return (
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M36.6355 54.572C34.7073 57.886 29.9201 57.8861 27.992 54.572L4.68577 14.5145C2.74639 11.1812 5.15107 7 9.00751 7H55.6199C59.4764 7 61.881 11.1812 59.9417 14.5145L36.6355 54.572Z"
fill="currentColor"
/>
</svg>
);
}
@@ -1,16 +0,0 @@
import { TankClass } from '@blitzkit/core';
import { ComponentProps, ReactNode } from 'react';
import { ClassHeavy } from './components/ClassHeavy';
import { ClassLight } from './components/ClassLight';
import { ClassMedium } from './components/ClassMedium';
import { ClassTankDestroyer } from './components/ClassTankDestroyer';
export const classIcons: Record<
TankClass,
(props: ComponentProps<'svg'>) => ReactNode
> = {
'AT-SPG': ClassTankDestroyer,
heavyTank: ClassHeavy,
lightTank: ClassLight,
mediumTank: ClassMedium,
};
@@ -1,71 +0,0 @@
import { ConsumableEntry } from '@blitzkit/core';
import { Flex, FlexProps } from '@radix-ui/themes';
import { ConsumableButton } from './ModuleButtons/ConsumableButton';
type ConsumablesManagerProps = FlexProps & {
consumables: ConsumableEntry[];
selected: number[];
onConsumablesChange?: (consumables: number[]) => void;
disabled?: boolean;
timers?: boolean;
hasHighEndConsumables?: boolean;
hasConsumableDeliverySystem?: boolean;
cooldownBooster?: number;
};
export function ConsumablesManager({
consumables,
selected,
onConsumablesChange,
disabled,
timers,
hasConsumableDeliverySystem,
cooldownBooster,
hasHighEndConsumables,
...props
}: ConsumablesManagerProps) {
return (
<Flex wrap="wrap" gap="2" {...props}>
{consumables.map((consumable) => {
const isSelected = selected.some((id) => id === consumable.id);
return (
<ConsumableButton
special={consumable.duration === undefined}
duration={
timers
? consumable.duration === undefined
? undefined
: consumable.duration * (hasHighEndConsumables ? 1.7 : 1)
: undefined
}
cooldown={
timers
? consumable.cooldown *
(hasConsumableDeliverySystem ? 0.88 : 1) *
(1 - cooldownBooster! * 0.1)
: undefined
}
key={consumable.id}
disabled={disabled && !isSelected}
consumable={consumable.id}
selected={isSelected}
onClick={() => {
if (!onConsumablesChange) return;
const draft = [...selected];
if (draft.some((id) => id === consumable.id)) {
draft.splice(draft.indexOf(consumable.id), 1);
} else {
draft.push(consumable.id);
}
onConsumablesChange(draft);
}}
/>
);
})}
</Flex>
);
}
@@ -1,27 +0,0 @@
import { Button, ButtonProps, Tooltip } from '@radix-ui/themes';
import { useState } from 'react';
interface CopyButtonProps extends ButtonProps {
copy: () => string | undefined;
}
export function CopyButton({ copy, ...props }: CopyButtonProps) {
const [tooltipOpen, setTooltipOpen] = useState(false);
return (
<Tooltip open={tooltipOpen} content="Copied!">
<Button
{...props}
onClick={() => {
const content = copy();
if (content === undefined) return;
navigator.clipboard.writeText(content);
setTooltipOpen(true);
setTimeout(() => setTooltipOpen(false), 2000);
}}
/>
</Tooltip>
);
}
@@ -1,62 +0,0 @@
import {
asset,
permanentSkills,
skillDefinitions,
Tier,
TIER_ROMAN_NUMERALS,
} from '@blitzkit/core';
import { Flex } from '@radix-ui/themes';
import { use } from 'react';
import { GenericTankComponentButton } from './ModuleButtons/GenericTankComponentButton';
interface CrewSkillManagerProps {
skillLevels: Record<string, number>;
onChange?: (skillLevels: Record<string, number>) => void;
}
export function CrewSkillManager({
skillLevels,
onChange,
}: CrewSkillManagerProps) {
const awaitedSkillDefinitions = use(skillDefinitions);
return (
<Flex direction="column" gap="2">
{Object.entries(awaitedSkillDefinitions.classes).map(
([tankClass, skills]) => (
<Flex gap="2" key={tankClass}>
{skills.map((skill) => {
const level = skillLevels[skill];
return (
<GenericTankComponentButton
key={skill}
special={!permanentSkills.includes(skill)}
selected={level > 0}
discriminator={
level === 0 ? undefined : TIER_ROMAN_NUMERALS[level as Tier]
}
icon={asset(`icons/skills/${skill}.webp`)}
onClick={(event) => {
if (!onChange) return;
const draft = { ...skillLevels };
if (event.shiftKey) {
draft[skill] = draft[skill] === 0 ? 7 : 0;
} else {
draft[skill] = level - 1;
if (draft[skill] < 0) draft[skill] = 7;
}
onChange(draft);
}}
/>
);
})}
</Flex>
),
)}
</Flex>
);
}
@@ -1,233 +0,0 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronUpIcon,
ReloadIcon,
} from '@radix-ui/react-icons';
import {
Button,
ChevronDownIcon,
Flex,
Grid,
IconButton,
Link,
Popover,
Text,
} from '@radix-ui/themes';
import { times } from 'lodash';
import { useState } from 'react';
const DAY_TITLES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const YEAR_OPTIONS = 7;
interface DatePickerProps {
onDateChange?: (date: Date) => void;
defaultDate?: Date;
}
export function DatePicker({
onDateChange,
defaultDate = new Date(),
}: DatePickerProps) {
const [year, setYear] = useState(defaultDate.getFullYear());
const [month, setMonth] = useState(defaultDate.getMonth());
const [day, setDay] = useState(defaultDate.getDate());
const [yearScroll, setYearScroll] = useState(0);
const [yearOffset, setYearOffset] = useState(0);
const [monthOffset, setMonthOffset] = useState(0);
const date = new Date(year + yearOffset, month + monthOffset, day);
const daysInLastMonth = new Date(
year + yearOffset,
month + monthOffset,
0,
).getDate();
const daysInMonth = new Date(
year + yearOffset,
month + monthOffset + 1,
0,
).getDate();
const firstDayOfMonth = new Date(
year + yearOffset,
month + monthOffset,
1,
).getDay();
const lastDayOfMonth = new Date(
year + yearOffset,
month + monthOffset,
daysInMonth,
).getDay();
return (
<Flex direction="column" width="fit-content">
<Flex justify="between" mb="2" align="center" py="2">
<IconButton
size="3"
variant="ghost"
onClick={() => {
if (monthOffset === 0) {
setYearOffset((state) => state - 1);
setMonthOffset(11);
} else {
setMonthOffset((state) => state - 1);
}
}}
ml="2"
>
<ChevronLeftIcon />
</IconButton>
<Flex align="center" gap="2">
<IconButton
size="3"
variant="ghost"
onClick={() => {
setYear(defaultDate.getFullYear());
setMonth(defaultDate.getMonth());
setDay(defaultDate.getDate());
setYearOffset(0);
setMonthOffset(0);
}}
>
<ReloadIcon />
</IconButton>
<Flex gap="1">
<Text>
{date.toLocaleString(undefined, {
month: 'long',
})}
</Text>
<Popover.Root onOpenChange={() => setYearScroll(0)}>
<Popover.Trigger>
<Link underline="always" href="#">
{date.getFullYear()}
</Link>
</Popover.Trigger>
<Popover.Content side="right" align="center">
<Flex direction="column" gap="1">
<IconButton
variant="ghost"
onClick={() => setYearScroll((state) => state + 1)}
mb="2"
>
<ChevronUpIcon />
</IconButton>
{times(YEAR_OPTIONS, (index) => {
const thisYear =
year +
yearOffset -
index +
Math.floor(YEAR_OPTIONS / 2) +
yearScroll;
const selected = year + yearOffset === thisYear;
return (
<Popover.Close key={thisYear}>
<Button
variant={selected ? 'solid' : 'soft'}
radius="large"
onClick={() => {
setYearOffset(thisYear - year);
}}
>
{thisYear}
</Button>
</Popover.Close>
);
})}
<IconButton
variant="ghost"
onClick={() => setYearScroll((state) => state - 1)}
mt="2"
>
<ChevronDownIcon />
</IconButton>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
</Flex>
<IconButton
size="3"
variant="ghost"
onClick={() => {
if (monthOffset === 11) {
setYearOffset((state) => state + 1);
setMonthOffset(0);
} else {
setMonthOffset((state) => state + 1);
}
}}
mr="2"
>
<ChevronRightIcon />
</IconButton>
</Flex>
<Flex mb="1">
{times(7, (index) => (
<Text
style={{
flex: 1,
}}
size="2"
align="center"
key={index}
color="gray"
>
{DAY_TITLES[index]}
</Text>
))}
</Flex>
<Grid columns="7" gap="1" width="fit-content">
{times(firstDayOfMonth, (index) => (
<IconButton radius="large" key={index} disabled>
{daysInLastMonth - firstDayOfMonth + index + 1}
</IconButton>
))}
{times(daysInMonth, (index) => {
const selected =
index === day - 1 && monthOffset === 0 && yearOffset === 0;
return (
<IconButton
radius="large"
key={index}
variant={selected ? 'solid' : 'soft'}
onClick={() => {
const newYear =
year + yearOffset + Math.floor((month + monthOffset) / 12);
const newMonth = (month + monthOffset) % 12;
const newDay = index + 1;
setYear(newYear);
setMonth(newMonth);
setDay(newDay);
setYearOffset(0);
setMonthOffset(0);
onDateChange?.(new Date(newYear, newMonth, newDay));
}}
>
{index + 1}
</IconButton>
);
})}
{times(6 - lastDayOfMonth, (index) => (
<IconButton radius="large" key={index} disabled>
{index + 1}
</IconButton>
))}
</Grid>
</Flex>
);
}
@@ -1,7 +0,0 @@
import { Link, LinkProps } from '@radix-ui/themes';
export function DummyLink({ href = '#', onClick, ...props }: LinkProps) {
return (
<Link href={href} onClick={(event) => event.preventDefault()} {...props} />
);
}
@@ -1,68 +0,0 @@
import { EquipmentPreset } from '@blitzkit/core';
import { Flex } from '@radix-ui/themes';
import { cloneDeep } from 'lodash';
import { EquipmentMatrix } from '../stores/duel';
import { EquipmentButton } from './ModuleButtons/EquipmentButton';
interface EquipmentManagerProps {
preset: EquipmentPreset;
matrix: EquipmentMatrix;
onChange: (matrix: EquipmentMatrix) => void;
}
export function EquipmentManager({
preset,
matrix,
onChange,
}: EquipmentManagerProps) {
return (
<Flex direction="column" gap="2">
{preset.map((equipmentRow, rowIndex) => (
<Flex gap="2" key={rowIndex}>
{equipmentRow.map((equipment, columnIndex) => (
<Flex key={columnIndex}>
<EquipmentButton
equipment={equipment[0]}
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
selected={matrix[rowIndex][columnIndex] === -1}
onClick={() => {
const draft = cloneDeep(matrix);
if (draft[rowIndex][columnIndex] === -1) {
draft[rowIndex][columnIndex] = 0;
} else {
draft[rowIndex][columnIndex] = -1;
}
onChange(draft);
}}
/>
<EquipmentButton
equipment={equipment[1]}
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
selected={matrix[rowIndex][columnIndex] === 1}
onClick={() => {
const draft = cloneDeep(matrix);
if (draft[rowIndex][columnIndex] === 1) {
draft[rowIndex][columnIndex] = 0;
} else {
draft[rowIndex][columnIndex] = 1;
}
onChange(draft);
}}
/>
</Flex>
))}
</Flex>
))}
</Flex>
);
}
@@ -1,16 +0,0 @@
import { ComponentProps } from 'react';
export function ExperimentIcon(props: ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
{...props}
>
<path d="M200-120q-51 0-72.5-45.5T138-250l222-270v-240h-40q-17 0-28.5-11.5T280-800q0-17 11.5-28.5T320-840h320q17 0 28.5 11.5T680-800q0 17-11.5 28.5T640-760h-40v240l222 270q32 39 10.5 84.5T760-120H200Zm0-80h560L520-492v-268h-80v268L200-200Zm280-280Z" />
</svg>
);
}
@@ -1,13 +0,0 @@
import { gameDefinitions } from '@blitzkit/core';
import { Code } from '@radix-ui/themes';
import { use } from 'react';
export function WoTBVersion() {
const awaitedGameDefinitions = use(gameDefinitions);
return (
<Code color="gray" size="2">
{awaitedGameDefinitions.version}
</Code>
);
}
@@ -1,14 +0,0 @@
import { style } from '@vanilla-extract/css';
export const containerInner = style({
justifyContent: 'space-between',
width: '100%',
maxWidth: 1024,
padding: '0 16px',
'@media': {
'screen and (max-width: 640px)': {
justifyContent: 'center',
},
},
});
@@ -1,182 +0,0 @@
'use client';
import { gameDefinitions } from '@blitzkit/core';
import { Code, Flex, Heading, Text, Theme } from '@radix-ui/themes';
import { use } from 'react';
import packageJson from '../../../../../package.json';
import { Link } from '../Link';
export function Footer() {
const awaitedGameDefinitions = use(gameDefinitions);
return (
<Theme radius="none">
<Flex
gap="6"
p="6"
direction="column"
align="center"
style={{ backgroundColor: 'var(--color-panel)' }}
>
<Flex
align="center"
justify="between"
maxWidth="40rem"
width="100%"
flexGrow="1"
gap="5"
position="relative"
direction={{ initial: 'column', sm: 'row' }}
>
<Flex
direction="column"
align={{
initial: 'center',
sm: 'start',
}}
>
<Heading>BlitzKit </Heading>
<Text color="gray">Everything World of Tanks Blitz</Text>
<Text size="1" color="gray">
Unaffiliated with Wargaming or any service
</Text>
</Flex>
<Flex
gap="6"
width={{
initial: '100%',
sm: 'auto',
}}
>
<Flex
direction="column"
gap="1"
align={{
initial: 'end',
sm: 'start',
}}
flexGrow={{
initial: '1',
sm: '0',
}}
flexBasis={{
initial: '0',
sm: 'auto',
}}
>
<Heading size="3">About</Heading>
<Flex
direction="column"
align={{
initial: 'end',
sm: 'start',
}}
>
<Link href="/docs/about/credits" color="gray">
Credits
</Link>
<Link
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
color="gray"
>
Discord
</Link>
<Link
href="https://github.com/tresabhi/blitzkit"
target="_blank"
color="gray"
>
GitHub
</Link>
<Link href="/docs/patches" color="gray">
Patches
</Link>
{/* patreon supporters? */}
</Flex>
</Flex>
<Flex
direction="column"
gap="1"
flexGrow={{
initial: '1',
sm: '0',
}}
flexBasis={{
initial: '0',
sm: 'auto',
}}
>
<Heading size="3">Legal</Heading>
<Flex direction="column">
<Link href="/docs/legal/privacy-policy" color="gray">
Privacy policy
</Link>
<Link href="/docs/legal/terms-of-service" color="gray">
Terms of service
</Link>
<Link
href="https://www.google.com/settings/ads"
target="_blank"
color="gray"
>
Ad settings
</Link>
</Flex>
</Flex>
</Flex>
</Flex>
<Flex gap="5" display={{ initial: 'flex', sm: 'none' }}>
<Flex direction="column" justify="between" align="end">
<Text size="2" color="gray">
BlitzKit
</Text>
<Text size="2" color="gray">
WoT Blitz
</Text>
<Text size="2" color="gray">
CDN
</Text>
</Flex>
<Flex direction="column" justify="between" align="start">
<Code size="2" color="gray">
{packageJson.version}
</Code>
<Code size="2" color="gray">
{awaitedGameDefinitions.version}
</Code>
<Code size="2" color="gray">
{process.env.NEXT_PUBLIC_ASSET_BRANCH}
</Code>
</Flex>
</Flex>
<Flex gap="2" align="center" display={{ initial: 'none', sm: 'flex' }}>
<Text size="2" color="gray">
BlitzKit
</Text>
<Code size="2" color="gray">
{packageJson.version}
</Code>
<Text size="2" color="gray">
WoT Blitz
</Text>
<Code size="2" color="gray">
{awaitedGameDefinitions.version}
</Code>
<Text size="2" color="gray">
CDN
</Text>
<Code size="2" color="gray">
{process.env.NEXT_PUBLIC_ASSET_BRANCH}
</Code>
</Flex>
</Flex>
</Theme>
);
}
-44
View File
@@ -1,44 +0,0 @@
'use client';
import {
Link as RadixLink,
LinkProps as RadixLinkProps,
} from '@radix-ui/themes';
import { useRouter } from 'next/navigation';
import { forwardRef } from 'react';
type LinkProps = RadixLinkProps & {
disabled?: boolean;
};
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
({ href, onClick, target, disabled, style, ...props }, ref) => {
const router = useRouter();
return (
<RadixLink
style={{
cursor: disabled ? 'not-allowed' : 'pointer',
...style,
}}
ref={ref}
href={disabled ? undefined : href}
onClick={(event) => {
if (disabled) return;
event.preventDefault();
if (href) {
if (event.ctrlKey || event.metaKey || target === '_blank') {
window.open(href);
} else {
router.push(href);
}
}
onClick?.(event);
}}
{...props}
/>
);
},
);
@@ -1,72 +0,0 @@
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 '../../hooks/useDelta';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
interface ConsumableButtonProps extends TankComponentButtonProps {
consumable: number;
duration?: number;
cooldown?: number;
}
export function ConsumableButton({
consumable,
cooldown,
duration,
...props
}: ConsumableButtonProps) {
const awaitedConsumableDefinitions = use(consumableDefinitions);
const cooldownDelta = useDelta(cooldown ?? 0);
const durationDelta = useDelta(duration ?? 0);
return (
<GenericTankComponentButton
style={{ width: '6rem' }}
tooltip={awaitedConsumableDefinitions[consumable].name}
icon={asset(`icons/consumables/${consumable}.webp`)}
{...props}
>
{(cooldown || duration) && (
<Flex direction="column" gap="1" ml="-2" mr="2">
{duration && (
<Text
size="1"
color={
durationDelta > 0
? 'green'
: durationDelta < 0
? 'tomato'
: undefined
}
>
<Flex align="center" gap="1" style={{ marginBottom: -4 }}>
<ClockIcon width={12} height={12} />
{Math.round(duration)}s
</Flex>
</Text>
)}
{cooldown && (
<Text
size="1"
color={
cooldownDelta < 0
? 'green'
: cooldownDelta > 0
? 'tomato'
: undefined
}
>
<Flex align="center" gap="1">
<ReloadIcon width={12} height={12} />
{Math.round(cooldown)}s
</Flex>
</Text>
)}
</Flex>
)}
</GenericTankComponentButton>
);
}
@@ -1,20 +0,0 @@
import { asset, equipmentDefinitions } from '@blitzkit/core';
import { use } from 'react';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
interface EquipmentButtonProps extends TankComponentButtonProps {
equipment: number;
}
export function EquipmentButton({ equipment, ...props }: EquipmentButtonProps) {
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
return (
<GenericTankComponentButton
tooltip={awaitedEquipmentDefinitions.equipments[equipment].name}
icon={asset(`icons/equipment/${equipment}.webp`)}
{...props}
/>
);
}
@@ -1,48 +0,0 @@
import { HTMLAttributes } from 'react';
import {
TankComponentButton,
TankComponentButtonProps,
} from './TankComponentButton';
interface GenericTankComponentButtonProps extends TankComponentButtonProps {
icon: string;
iconStyles?: HTMLAttributes<HTMLImageElement>['style'];
}
export function GenericTankComponentButton({
icon,
iconStyles,
children,
...props
}: GenericTankComponentButtonProps) {
return (
<TankComponentButton {...props}>
<div
style={{
width: 48,
height: 40,
position: 'relative',
}}
>
<img
alt={icon}
draggable={false}
src={icon}
style={{
objectFit: 'contain',
width: 32,
height: 32,
position: 'absolute',
opacity: props.disabled ? 0.5 : 1,
transform: 'translate(-50%, -50%) scale(0.75)',
top: '50%',
left: '50%',
...iconStyles,
}}
/>
</div>
{children}
</TankComponentButton>
);
}
@@ -1,21 +0,0 @@
import { asset } from '@blitzkit/core';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
interface ModuleProps extends TankComponentButtonProps {
module: string;
}
export function ModuleButton({ module, ...props }: ModuleProps) {
return (
<GenericTankComponentButton
icon={asset(`icons/modules/${module}.webp`)}
iconStyles={{
top: '50%',
left: '50%',
transform: 'translate(calc(-50% + 2px), calc(-50% + 2px))',
}}
{...props}
/>
);
}
@@ -1,20 +0,0 @@
import { asset, provisionDefinitions } from '@blitzkit/core';
import { use } from 'react';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
interface ProvisionButtonProps extends TankComponentButtonProps {
provision: number;
}
export function ProvisionButton({ provision, ...props }: ProvisionButtonProps) {
const awaitedProvisionDefinitions = use(provisionDefinitions);
return (
<GenericTankComponentButton
tooltip={awaitedProvisionDefinitions[provision].name}
icon={asset(`icons/provisions/${provision}.webp`)}
{...props}
/>
);
}
@@ -1,16 +0,0 @@
import { asset } from '@blitzkit/core';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
interface ShellButtonProps extends TankComponentButtonProps {
shell: string;
}
export function ShellButton({ shell, ...props }: ShellButtonProps) {
return (
<GenericTankComponentButton
icon={asset(`icons/shells/${shell}.webp`)}
{...props}
/>
);
}
@@ -1,81 +0,0 @@
import { Button, Text, Tooltip } from '@radix-ui/themes';
import { ComponentProps, ReactNode } from 'react';
export interface TankComponentButtonProps
extends ComponentProps<typeof Button> {
selected?: boolean;
children?: ReactNode;
discriminator?: ReactNode;
disabled?: boolean;
banner?: string;
special?: boolean;
tooltip?: string;
}
export function TankComponentButton({
variant,
selected,
discriminator,
special,
children,
banner,
disabled,
style,
tooltip,
...props
}: TankComponentButtonProps) {
const button = (
<Button
radius="medium"
color={selected ? (special ? 'amber' : undefined) : 'gray'}
variant={variant ?? (selected ? 'surface' : 'soft')}
style={{
padding: 0,
minWidth: 48,
height: 40,
position: 'relative',
overflow: 'hidden',
cursor: 'unset',
pointerEvents: variant === 'ghost' ? 'none' : undefined,
...style,
}}
disabled={variant === 'ghost' || disabled}
{...props}
>
{banner !== undefined && (
<div
style={{
position: 'absolute',
width: '200%',
height: 12,
backgroundColor: banner,
top: '50%',
left: '-50%',
transform: 'translateY(-50%) rotate(-22.5deg)',
}}
/>
)}
{children}
{discriminator !== undefined && (
<Text
size="1"
style={{
textShadow: '0 0 4px black',
position: 'absolute',
top: '50%',
right: 8,
textAlign: 'right',
}}
color={variant === 'ghost' ? 'gray' : undefined}
>
{discriminator}
</Text>
)}
</Button>
);
if (tooltip) return <Tooltip content={tooltip}>{button}</Tooltip>;
return button;
}
@@ -1,201 +0,0 @@
import {
EngineDefinition,
GunDefinition,
ShellDefinition,
TankDefinition,
TIER_ROMAN_NUMERALS,
TrackDefinition,
TurretDefinition,
} from '@blitzkit/core';
import { DropdownMenu, Flex } from '@radix-ui/themes';
import { useState } from 'react';
import { ModuleButton } from './ModuleButtons/ModuleButton';
interface ModuleManagerProps {
tank: TankDefinition;
turret: TurretDefinition;
gun: GunDefinition;
shell: ShellDefinition;
engine: EngineDefinition;
track: TrackDefinition;
onChange?: (modules: {
turret: TurretDefinition;
gun: GunDefinition;
engine: EngineDefinition;
track: TrackDefinition;
}) => void;
}
export function ModuleManager({
tank,
onChange,
...modules
}: ModuleManagerProps) {
const [turretMenuOpen, setTurretMenuOpen] = useState(false);
const [gunMenuOpen, setGunMenuOpen] = useState(false);
const [engineMenuOpen, setEngineMenuOpen] = useState(false);
const [trackMenuOpen, setTrackMenuOpen] = useState(false);
return (
<Flex gap="1" wrap="wrap">
<ModuleButton
module="turret"
discriminator={TIER_ROMAN_NUMERALS[modules.turret.tier]}
onClick={() => setTurretMenuOpen(true)}
/>
<DropdownMenu.Root open={turretMenuOpen} onOpenChange={setTurretMenuOpen}>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${modules.turret.id}`}>
{[...tank.turrets].reverse().map((turret) => {
return (
<DropdownMenu.RadioItem
key={turret.id}
value={`${turret.id}`}
onClick={() => {
const draft = { ...modules };
draft.turret = turret;
draft.gun = draft.turret.guns.at(-1)!;
draft.shell = draft.gun.shells[0];
onChange?.(draft);
setTurretMenuOpen(false);
}}
>
<ModuleButton
variant="ghost"
module="turret"
discriminator={TIER_ROMAN_NUMERALS[turret.tier]}
/>{' '}
{turret.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<ModuleButton
module="gun"
discriminator={TIER_ROMAN_NUMERALS[modules.gun.tier]}
onClick={() => setGunMenuOpen(true)}
/>
<DropdownMenu.Root open={gunMenuOpen} onOpenChange={setGunMenuOpen}>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${modules.gun.id}`}>
{[...modules.turret.guns].reverse().map((gun) => {
return (
<DropdownMenu.RadioItem
key={gun.id}
value={`${gun.id}`}
onClick={() => {
const draft = { ...modules };
draft.gun = gun;
draft.shell = draft.gun.shells[0];
onChange?.(draft);
setGunMenuOpen(false);
}}
>
<ModuleButton
variant="ghost"
module="gun"
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
/>{' '}
{gun.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<ModuleButton
module="engine"
discriminator={TIER_ROMAN_NUMERALS[modules.engine.tier]}
onClick={() => setEngineMenuOpen(true)}
/>
<DropdownMenu.Root open={engineMenuOpen} onOpenChange={setEngineMenuOpen}>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${modules.engine.id}`}>
{[...tank.engines].reverse().map((engine) => {
return (
<DropdownMenu.RadioItem
key={engine.id}
value={`${engine.id}`}
onClick={() => {
const draft = { ...modules };
draft.engine = engine;
onChange?.(draft);
setEngineMenuOpen(false);
}}
>
<ModuleButton
variant="ghost"
module="engine"
discriminator={TIER_ROMAN_NUMERALS[engine.tier]}
/>{' '}
{engine.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<ModuleButton
module="chassis"
discriminator={TIER_ROMAN_NUMERALS[modules.track.tier]}
onClick={() => setTrackMenuOpen(true)}
/>
<DropdownMenu.Root open={trackMenuOpen} onOpenChange={setTrackMenuOpen}>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${modules.track.id}`}>
{[...tank.tracks].reverse().map((track) => {
return (
<DropdownMenu.RadioItem
key={track.id}
value={`${track.id}`}
onClick={() => {
const draft = { ...modules };
draft.track = track;
onChange?.(draft);
setTrackMenuOpen(false);
}}
>
<ModuleButton
variant="ghost"
module="chassis"
discriminator={TIER_ROMAN_NUMERALS[track.tier]}
/>{' '}
{track.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Flex>
);
}
@@ -1,21 +0,0 @@
import { getAccountInfo, idToRegion } from '@blitzkit/core';
import { Text } from '@radix-ui/themes';
import { use, useMemo } from 'react';
import * as App from '../../../stores/app';
export function WargamingAccountName() {
const wargaming = App.use((state) => state.logins.wargaming!);
const promise = useMemo(
() =>
getAccountInfo(idToRegion(wargaming.id), wargaming.id, [], {
access_token: wargaming.token,
}).then((data) => {
console.log(data);
return data?.nickname as string | undefined;
}),
[wargaming],
);
const nickname = use(promise);
return nickname ?? <Text color="gray">Unknown Account</Text>;
}
@@ -1,49 +0,0 @@
import { assertSecret, REGIONS } from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en-US.json';
import { Button, ButtonProps, Dialog, Flex } from '@radix-ui/themes';
import { Link } from '../../Link';
import { WargamingIcon } from '../../WargamingIcon';
export function WargamingLoginButton({ children, ...props }: ButtonProps) {
return (
<Dialog.Root>
<Dialog.Trigger>
<Button
color="red"
{...props}
children={
<>
<WargamingIcon width={15} height={15} /> {children ?? 'Wargaming'}
</>
}
/>
</Dialog.Trigger>
<Dialog.Content width="fit-content">
<Dialog.Title align="center">Choose your region</Dialog.Title>
<Flex gap="2" wrap="wrap" justify="center" align="center">
{REGIONS.map((region) => (
<Dialog.Close key={region}>
<Link
href={
typeof window !== 'undefined'
? `https://api.worldoftanks.${region}/wot/auth/login/?application_id=${assertSecret(
process.env.NEXT_PUBLIC_WARGAMING_APPLICATION_ID,
)}&redirect_uri=${encodeURIComponent(
`${location.origin}/auth/wargaming?return=${location.origin}${location.pathname}`,
)}`
: undefined
}
>
<Button color="red">
{strings.common.regions.normal[region]}
</Button>
</Link>
</Dialog.Close>
))}
</Flex>
</Dialog.Content>
</Dialog.Root>
);
}
@@ -1,69 +0,0 @@
import { style, styleVariants } from '@vanilla-extract/css';
const MIN_WIDTH = 640 + 128;
export const NAVBAR_HEIGHT = 48;
export const hamburger = style({
display: 'inline-flex',
'@media': {
[`screen and (min-width: ${MIN_WIDTH}px)`]: {
display: 'none',
},
},
});
export const tools = style({
display: 'none',
'@media': {
[`screen and (min-width: ${MIN_WIDTH}px)`]: {
display: 'flex',
},
},
});
export const navbar = style({
height: NAVBAR_HEIGHT,
marginBottom: -1, // 1px overlap with content to bleed color
position: 'sticky',
top: 0,
zIndex: 1,
});
const navbarExpanderBase = style({
transitionDuration: '250ms',
height: '1000px',
width: '100%',
overflow: 'hidden',
backdropFilter: 'blur(4rem) brightness(0.75)',
WebkitBackdropFilter: 'blur(4rem) brightness(0.75)',
'@media': {
[`screen and (min-width: ${MIN_WIDTH}px)`]: {
maxHeight: NAVBAR_HEIGHT,
},
},
});
export const navbarExpander = styleVariants({
false: [
navbarExpanderBase,
{
maxHeight: NAVBAR_HEIGHT,
},
],
true: [
navbarExpanderBase,
{
maxHeight: '100vh',
overscrollBehavior: 'contain',
},
],
});
export const navbarContent = style({
width: '100%',
maxHeight: '100vh',
});
@@ -1,335 +0,0 @@
'use client';
import {
assertSecret,
imgur,
ImgurSize,
patreonLoginUrl,
} from '@blitzkit/core';
import {
Cross1Icon,
DiscordLogoIcon,
GearIcon,
HamburgerMenuIcon,
PersonIcon,
} from '@radix-ui/react-icons';
import {
Box,
Button,
Code,
Flex,
Grid,
IconButton,
Inset,
Popover,
ScrollArea,
Separator,
Skeleton,
Text,
} from '@radix-ui/themes';
import { usePathname } from 'next/navigation';
import { Fragment, Suspense, useState } from 'react';
import { BRANCH_NAMES } from '../../app/components/Hero/constants';
import { homeTool, TOOLS } from '../../constants/tools';
import * as App from '../../stores/app';
import { Link } from '../Link';
import { PatreonIcon } from '../PatreonIcon';
import { WargamingIcon } from '../WargamingIcon';
import { WargamingAccountName } from './components/WargamingAccountName';
import { WargamingLoginButton } from './components/WargamingLoginButton';
import * as styles from './index.css';
export function Navbar() {
const [showHamburgerMenu, setShowHamburgerMenu] = useState(false);
const pathname = usePathname();
const logins = App.use((state) => state.logins);
const mutateApp = App.useMutation();
const toolsFiltered = TOOLS.filter((tool) => !('href' in tool));
const tools = [homeTool, ...toolsFiltered];
const isBranchNamed =
process.env.NEXT_PUBLIC_ASSET_BRANCH &&
BRANCH_NAMES[process.env.NEXT_PUBLIC_ASSET_BRANCH];
return (
<Flex className={styles.navbar}>
<Flex
className={styles.navbarExpander[`${showHamburgerMenu}`]}
onClick={() => setShowHamburgerMenu(false)}
direction="column"
gap="2"
>
<Flex justify="center">
<Flex flexGrow="1" maxWidth="1024px" p="3" align="center">
<Flex gap="3" align="center">
<IconButton
variant="ghost"
color="gray"
className={styles.hamburger}
onClick={(event) => {
event.stopPropagation();
setShowHamburgerMenu((state) => !state);
}}
>
{showHamburgerMenu ? <Cross1Icon /> : <HamburgerMenuIcon />}
</IconButton>
<Link
color="gray"
highContrast
href="/"
underline="hover"
weight="bold"
onClick={() => setShowHamburgerMenu(false)}
>
<Flex align="center" gap="1">
BlitzKit
{isBranchNamed && (
<Code color="gray" size="1" highContrast variant="outline">
{BRANCH_NAMES[process.env.NEXT_PUBLIC_ASSET_BRANCH!]}
</Code>
)}
</Flex>
</Link>
</Flex>
<Box flexGrow="1" />
<Flex
align="center"
gap="2"
justify="center"
className={styles.tools}
>
{toolsFiltered.map((tool, index) => {
const unavailableOnBranch = tool.branches?.every(
(branch) =>
branch !==
assertSecret(process.env.NEXT_PUBLIC_ASSET_BRANCH),
);
const selected = pathname.startsWith(`/tools/${tool.id}`);
if (unavailableOnBranch) return null;
return (
<Fragment key={tool.title}>
{index > 0 && <Separator orientation="vertical" size="1" />}
<Link
onClick={() => setShowHamburgerMenu(false)}
color="gray"
highContrast={selected}
size="2"
href={`/tools/${tool.id}`}
underline={selected ? 'always' : 'hover'}
>
{tool.title}
</Link>
</Fragment>
);
})}
</Flex>
<Box flexGrow="1" />
<Flex align="center" gap="3">
<Link
style={{ display: 'flex', alignContent: 'center' }}
color="gray"
href="https://discord.gg/nDt7AjGJQH"
underline="none"
target="_blank"
onClick={() => setShowHamburgerMenu(false)}
>
<DiscordLogoIcon />
</Link>
<Link
style={{ display: 'flex', alignContent: 'center' }}
color="gray"
href="https://www.patreon.com/tresabhi"
underline="none"
target="_blank"
onClick={() => setShowHamburgerMenu(false)}
>
<PatreonIcon width="0.75em" height="0.75em" />
</Link>
<Link
style={{ display: 'flex', alignContent: 'center' }}
color="gray"
href="/settings"
underline="none"
onClick={() => setShowHamburgerMenu(false)}
>
<GearIcon />
</Link>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost" color="gray">
<PersonIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content
align="end"
width="320px"
onOpenAutoFocus={(event) => event.preventDefault()}
>
{(logins.patreon || logins.wargaming) && (
<Flex direction="column" gap="2">
<Text align="center" color="gray">
Logged in accounts
</Text>
{logins.patreon && (
<Flex align="center" gap="2" justify="center">
<PatreonIcon width={15} height={15} />
<Text>Patreon</Text>
<Button
color="red"
variant="ghost"
onClick={() =>
mutateApp((draft) => {
draft.logins.patreon = undefined;
})
}
>
Logout
</Button>
</Flex>
)}
{logins.wargaming && (
<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={() =>
mutateApp((draft) => {
draft.logins.wargaming = undefined;
})
}
>
Logout
</Button>
</Flex>
)}
</Flex>
)}
{!logins.patreon !== !logins.wargaming && (
<Inset side="x">
<Separator my="4" size="4" />
</Inset>
)}
{(!logins.patreon || !logins.wargaming) && (
<Flex direction="column" gap="2">
<Text align="center" color="gray">
Log in with...
</Text>
{!logins.patreon && (
<Link
style={{ width: '100%' }}
href={patreonLoginUrl()}
>
<Button style={{ width: '100%' }} color="tomato">
<PatreonIcon width={15} height={15} /> Patreon
</Button>
</Link>
)}
{!logins.wargaming && <WargamingLoginButton />}
</Flex>
)}
</Popover.Content>
</Popover.Root>
</Flex>
</Flex>
</Flex>
<ScrollArea>
<Grid
columns="repeat(auto-fill, minmax(192px, 1fr))"
flow="dense"
gap="2"
width="100%"
p="3"
pt="0"
>
{tools.map((tool) => {
const unavailableOnBranch = tool.branches?.every(
(branch) =>
branch !== assertSecret(process.env.NEXT_PUBLIC_ASSET_BRANCH),
);
if (unavailableOnBranch) return null;
return (
<Flex
key={tool.id}
onClick={() => setShowHamburgerMenu(false)}
style={{
position: 'relative',
borderRadius: 'var(--radius-2)',
overflow: 'hidden',
backgroundImage: `url(${imgur(tool.image, {
format: 'jpeg',
size: ImgurSize.MediumThumbnail,
})})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
aspectRatio: '2 / 1',
}}
>
<Link
href={tool.id.length === 0 ? '/' : `/tools/${tool.id}`}
style={{
display: 'flex',
width: '100%',
height: '100%',
flexDirection: 'column',
textDecoration: 'none',
color: 'inherit',
}}
>
<Box
flexGrow="1"
style={{
backgroundImage: `url(${imgur(tool.image, {
format: 'jpeg',
size: ImgurSize.MediumThumbnail,
})})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<Flex
p="2"
align="center"
width="100%"
style={{
backgroundColor: 'var(--color-panel-translucent)',
backdropFilter: 'blur(4rem)',
WebkitBackdropFilter: 'blur(4rem)',
}}
>
<Text weight="medium">{tool.title}</Text>
</Flex>
</Link>
</Flex>
);
})}
</Grid>
</ScrollArea>
</Flex>
</Flex>
);
}
@@ -1,54 +0,0 @@
import { Flex, FlexProps, Theme } from '@radix-ui/themes';
import { ComponentProps } from 'react';
import { RadixSize } from '../stores/embedState';
type PageWrapperProps = FlexProps & {
color?: ComponentProps<typeof Theme>['accentColor'];
size?: number | string;
noMaxWidth?: boolean;
containerProps?: ComponentProps<typeof Theme>;
noFlex1?: boolean;
padding?: RadixSize;
};
export function PageWrapper({
style,
color,
size = 800,
children,
noMaxWidth = false,
noFlex1 = false,
containerProps,
padding = '4',
...props
}: PageWrapperProps) {
return (
<Theme
accentColor={color}
style={{
flex: noFlex1 ? undefined : 1,
display: 'flex',
flexDirection: 'column',
...containerProps?.style,
}}
{...containerProps}
>
<Flex
direction="column"
gap="4"
p={padding}
style={{
width: '100%',
maxWidth: noMaxWidth ? undefined : size,
margin: 'auto',
boxSizing: 'border-box',
flex: 1,
...style,
}}
{...props}
>
{children}
</Flex>
</Theme>
);
}
@@ -1,44 +0,0 @@
'use client';
import { useEffect } from 'react';
export function Party3() {
useEffect(() => {
(window as any).msAdsQueue = (window as any).msAdsQueue || [];
}, []);
return (
<>
{/* analytics */}
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-GL2JVHCGPQ"
/>
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-GL2JVHCGPQ');
`,
}}
/>
{/* adsense */}
{/* <script
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-1289406790225052"
crossOrigin="anonymous"
/> */}
{/* pub center */}
{/* <script
async
src="https://adsdk.microsoft.com/mmnow/sdk.js?siteId=364645&publisherId=253617120"
crossOrigin="anonymous"
/> */}
</>
);
}
@@ -1,13 +0,0 @@
import { ComponentProps } from 'react';
export function PatreonIcon(props: ComponentProps<'svg'>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 436 476" {...props}>
<path
fill="currentColor"
data-fill="1"
d="M436 143c-.084-60.778-47.57-110.591-103.285-128.565C263.528-7.884 172.279-4.649 106.214 26.424 26.142 64.089.988 146.596.051 228.883c-.77 67.653 6.004 245.841 106.83 247.11 74.917.948 86.072-95.279 120.737-141.623 24.662-32.972 56.417-42.285 95.507-51.929C390.309 265.865 436.097 213.011 436 143Z"
></path>
</svg>
);
}
@@ -1,46 +0,0 @@
import { Flex } from '@radix-ui/themes';
import { ProvisionButton } from './ModuleButtons/ProvisionButton';
interface ProvisionsManagerProps {
provisions: number[];
selected: number[];
disabled?: boolean;
onChange?: (provisions: number[]) => void;
}
export function ProvisionsManager({
provisions,
selected,
onChange,
disabled,
}: ProvisionsManagerProps) {
return (
<Flex wrap="wrap" gap="2">
{provisions.map((provision, index) => {
const isSelected = selected.includes(provision);
return (
<ProvisionButton
key={provision}
disabled={disabled && !isSelected}
provision={provision}
selected={isSelected}
onClick={() => {
if (!onChange) return;
const draft = [...selected];
if (isSelected) {
draft.splice(draft.indexOf(provision), 1);
} else {
draft.push(provision);
}
onChange(draft);
}}
/>
);
})}
</Flex>
);
}
@@ -1,25 +0,0 @@
import { ComponentProps } from 'react';
export function ResearchedIcon(props: ComponentProps<'svg'>) {
return (
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M63 25V43L48 51H60C58.5 53.6667 51.6 59 36 59L62 25H63Z"
fill="currentColor"
/>
<path d="M48 3L60 18L54 27L48 3Z" fill="currentColor" />
<path
d="M3 25V43L18 51H6C7.5 53.6667 14.4 59 30 59L4 25H3Z"
fill="currentColor"
/>
<path d="M18 3L6 18L12 27L18 3Z" fill="currentColor" />
</svg>
);
}
@@ -1,16 +0,0 @@
import { ComponentProps } from 'react';
export function ScienceIcon(props: ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
{...props}
>
<path d="M200-120q-51 0-72.5-45.5T138-250l222-270v-240h-40q-17 0-28.5-11.5T280-800q0-17 11.5-28.5T320-840h320q17 0 28.5 11.5T680-800q0 17-11.5 28.5T640-760h-40v240l222 270q32 39 10.5 84.5T760-120H200Zm80-120h400L544-400H416L280-240Zm-80 40h560L520-492v-268h-80v268L200-200Zm280-280Z" />
</svg>
);
}
@@ -1,16 +0,0 @@
import { ComponentProps } from 'react';
export function ScienceOffIcon(props: ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
{...props}
>
<path d="m600-473-80-80v-207h-80v127l-80-80v-47h-40q-17 0-28.5-11.5T280-800q0-17 11.5-28.5T320-840h320q17 0 28.5 11.5T680-800q0 17-11.5 28.5T640-760h-40v287ZM200-200h448L402-446 200-200ZM792-56l-64-64H200q-51 0-72.5-45.5T138-250l208-252L56-792l56-56 736 736-56 56ZM402-446Zm78-147Z" />
</svg>
);
}
@@ -1,13 +0,0 @@
import { slateDarkA } from '@radix-ui/colors';
import { keyframes, style } from '@vanilla-extract/css';
const animation = keyframes({
'0%': { backgroundColor: slateDarkA.slateA2 },
'50%': { backgroundColor: slateDarkA.slateA3 },
'100%': { backgroundColor: slateDarkA.slateA2 },
});
export const skeleton = style({
borderRadius: 8,
animation: `${animation} 2s ease-in-out infinite`,
});
@@ -1,6 +0,0 @@
import { ComponentProps } from 'react';
import { skeleton } from './index.css';
export function Skeleton({ className, ...props }: ComponentProps<'div'>) {
return <div {...props} className={`${className} ${skeleton}`} />;
}
@@ -1,30 +0,0 @@
import { asset } from '@blitzkit/core';
interface SmallTankIconProps {
id: number;
size?: number;
}
const WIDTH = 70;
const HEIGHT = 24;
export function SmallTankIcon({ id, size = HEIGHT }: SmallTankIconProps) {
return (
<div
style={{
height: size,
overflow: 'hidden',
width: WIDTH * (size / HEIGHT),
}}
>
<img
alt={`Tank ${id}`}
src={asset(`icons/tanks/small/${id}.webp`)}
style={{
transform: `scale(${(size / HEIGHT) * 100}%)`,
transformOrigin: 'top left',
}}
/>
</div>
);
}
@@ -1,60 +0,0 @@
import { Flex, FlexProps, Text } from '@radix-ui/themes';
import { ReactNode } from 'react';
type SpeedometerProps = FlexProps & {
value: ReactNode;
color: string;
fill: number;
label: ReactNode;
};
export function Speedometer({
value,
color,
fill,
label,
...props
}: SpeedometerProps) {
return (
<Flex direction="column" align="center" {...props}>
<svg
width={180}
height={98}
style={{
transform: 'scaleX(-1)',
}}
>
<circle
cx={90}
cy={90}
r={80}
strokeLinecap="round"
fill="none"
stroke={`var(--${color}-3)`}
strokeWidth={16}
strokeDasharray={80 * Math.PI}
strokeDashoffset={80 * Math.PI}
/>
<circle
cx={90}
cy={90}
r={80}
strokeLinecap="round"
fill="none"
stroke={`var(--${color}-indicator)`}
strokeWidth={16}
strokeDasharray={(160 - 80 * fill) * Math.PI}
strokeDashoffset={(160 - 80 * fill) * Math.PI}
/>
</svg>
<Flex direction="column" align="center" mt="-8">
<Text weight="bold" size="8" mt="-1">
{value}
</Text>
{label}
</Flex>
</Flex>
);
}
@@ -1,25 +0,0 @@
import { Table } from '@radix-ui/themes';
import { ColumnHeaderCellProps } from '@radix-ui/themes/dist/cjs/components/table';
interface StickyColumnHeaderCellProps extends ColumnHeaderCellProps {
top?: number | string;
}
export function StickyColumnHeaderCell({
style,
top = 0,
...props
}: StickyColumnHeaderCellProps) {
return (
<Table.ColumnHeaderCell
style={{
position: 'sticky',
top,
background: 'var(--gray-3)',
zIndex: 1,
...style,
}}
{...props}
/>
);
}
@@ -1,18 +0,0 @@
import { Table } from '@radix-ui/themes';
import { RowHeaderCellProps } from '@radix-ui/themes/dist/cjs/components/table';
export function StickyRowHeaderCell({ style, ...props }: RowHeaderCellProps) {
return (
<Table.RowHeaderCell
style={{
display: 'flex',
position: 'sticky',
left: 0,
backgroundColor: 'var(--color-panel)',
boxShadow: 'var(--shadow-6)',
...style,
}}
{...props}
/>
);
}
@@ -1,23 +0,0 @@
'use client';
import { Table } from '@radix-ui/themes';
import { RootProps } from '@radix-ui/themes/dist/cjs/components/table';
import { useEffect, useRef } from 'react';
export function StickyTableRoot({ style, ...props }: RootProps) {
const table = useRef<HTMLTableElement>(null);
useEffect(() => {
table.current
?.querySelector('table')
?.style.setProperty('overflow', 'auto');
}, []);
return (
<Table.Root
{...props}
ref={table}
style={{ overflow: 'hidden', ...style }}
/>
);
}
@@ -1,70 +0,0 @@
import { TankDefinition, tankIcon } from '@blitzkit/core';
import { classIcons } from './ClassIcon';
import { ExperimentIcon } from './ExperimentIcon';
import { Link } from './Link';
import { StickyRowHeaderCell } from './StickyRowHeaderCell';
interface TankRowHeaderCellProps {
tank: TankDefinition;
}
export function TankRowHeaderCell({ tank }: TankRowHeaderCellProps) {
const Icon = classIcons[tank.class];
return (
<StickyRowHeaderCell
width={{ initial: '144px', sm: '240px' }}
style={{ overflow: 'hidden' }}
>
<Link href={`/tools/tankopedia/${tank.id}`} tabIndex={-1}>
<img
alt={tank.name}
draggable={false}
src={tankIcon(tank.id)}
style={{
margin: 'calc(-1 * var(--table-cell-padding)) 0',
height: 'calc(100% + 2 * var(--table-cell-padding))',
aspectRatio: '4 / 3',
objectFit: 'cover',
}}
/>
</Link>
<Link
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: 'gray'
}
highContrast={tank.treeType === 'researchable'}
underline="hover"
wrap="nowrap"
href={`/tools/tankopedia/${tank.id}`}
style={{
paddingLeft: 'var(--space-2)',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-1)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
size={{ initial: '1', sm: '2' }}
>
<Icon
width="1em"
height="1em"
style={{ minWidth: '1em', minHeight: '1em' }}
/>
{tank.testing && (
<ExperimentIcon style={{ width: '1em', height: '1em' }} />
)}
{tank.name}
</Link>
</StickyRowHeaderCell>
);
}
@@ -1,126 +0,0 @@
import {
CompositeStatsKey,
previewCompositeStat,
TankDefinition,
} from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en-US.json';
import { amberDark, blueDark } from '@radix-ui/colors';
import { Flex, Text } from '@radix-ui/themes';
import { times } from 'lodash';
import { ReactNode } from 'react';
import { breakdownConfig } from '../constants/embeds';
import { toRadiusVar } from '../core/radix/utils';
import { useEmbedStateCurry } from '../stores/embedState/utilities';
import { ClassHeavy } from './ClassIcon/components/ClassHeavy';
import { ClassLight } from './ClassIcon/components/ClassLight';
import { ClassMedium } from './ClassIcon/components/ClassMedium';
import { ClassTankDestroyer } from './ClassIcon/components/ClassTankDestroyer';
interface BreakdownEmbedWrapperProps {
children: ReactNode;
}
export function BreakdownEmbedWrapper({
children,
}: BreakdownEmbedWrapperProps) {
const { useState } = useEmbedStateCurry<typeof breakdownConfig>();
return (
<Flex direction="column" gap={useState('listGap')} height="100%">
{children}
</Flex>
);
}
interface BreakdownEmbedCardProps {
tank: TankDefinition | null;
}
export function BreakdownEmbedCard({ tank }: BreakdownEmbedCardProps) {
const { useState, useRichText } =
useEmbedStateCurry<typeof breakdownConfig>();
return (
<Flex
flexShrink="0"
direction="column"
style={{
borderRadius: toRadiusVar(useState('cardRadius')),
overflow: 'hidden',
backgroundColor: useState('cardBodyBackgroundColor'),
}}
>
<Flex
justify="center"
style={{
backgroundColor: useState('cardHeaderBackgroundColor'),
}}
p={useState('cardHeaderPadding')}
>
<Text
{...{
...useRichText('cardTitle'),
style:
tank === null
? undefined
: tank.treeType === 'researchable' ||
!useState('cardTitleTypeColor')
? undefined
: {
color:
tank.treeType === 'premium'
? amberDark.amber11
: blueDark.blue11,
},
}}
>
<Flex align="center" gap="1">
{useState('cardTitleClassIcon') && tank !== null && (
<>
{tank.class === 'lightTank' && (
<ClassLight width="1em" height="1em" />
)}
{tank.class === 'mediumTank' && (
<ClassMedium width="1em" height="1em" />
)}
{tank.class === 'heavyTank' && (
<ClassHeavy width="1em" height="1em" />
)}
{tank.class === 'AT-SPG' && (
<ClassTankDestroyer width="1em" height="1em" />
)}
</>
)}{' '}
{tank === null ? 'Total' : tank.name}
</Flex>
</Text>
</Flex>
<Flex p={useState('cardBodyPadding')}>
{times(4, (index) => {
const column = (index + 1) as 1 | 2 | 3 | 4;
const columnState = useState(`column${column}`) as CompositeStatsKey;
const customLabel = useState(`column${column}CustomLabel`);
return (
<Flex
direction="column"
align="center"
gap={useState('columnGap')}
flexGrow="1"
>
<Text {...useRichText('columnValue')}>
{previewCompositeStat(columnState)}
</Text>
<Text {...useRichText('columnLabel')}>
{customLabel.length === 0
? strings.common.composite_stats[columnState]
: customLabel}
</Text>
</Flex>
);
})}
</Flex>
</Flex>
);
}
@@ -1,16 +0,0 @@
import { ComponentProps } from 'react';
export function VerifiedIcon(props: ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
{...props}
>
<path d="m344-60-76-128-144-32 14-148-98-112 98-112-14-148 144-32 76-128 136 58 136-58 76 128 144 32-14 148 98 112-98 112 14 148-144 32-76 128-136-58-136 58Zm34-102 102-44 104 44 56-96 110-26-10-112 74-84-74-86 10-112-110-24-58-96-102 44-104-44-56 96-110 24 10 112-74 86 74 84-10 114 110 24 58 96Zm102-318Zm-42 142 226-226-56-58-170 170-86-84-56 56 142 142Z" />
</svg>
);
}
@@ -1,28 +0,0 @@
import { ComponentProps } from 'react';
export function WargamingIcon(props: ComponentProps<'svg'>) {
return (
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_574_231)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.46409 0.156338C8.91652 0.149247 10.3384 0.57301 11.55 1.37404C12.7616 2.17507 13.7085 3.31739 14.2709 4.65654C14.8332 5.9957 14.9859 7.47154 14.7095 8.89745C14.4331 10.3234 13.7401 11.6353 12.7181 12.6673C11.6961 13.6994 10.391 14.4052 8.96789 14.6955C7.54475 14.9859 6.06748 14.8477 4.72288 14.2984C3.37829 13.7492 2.22676 12.8136 1.41391 11.6099C0.601062 10.4062 0.163403 8.98846 0.156278 7.53603V7.50009C0.150882 5.5586 0.916644 3.69442 2.28523 2.31733C3.65382 0.940243 5.51323 0.162958 7.45472 0.156338H7.46409ZM7.46409 1.56259C8.76962 1.56799 10.0372 2.00218 11.0719 2.79837C12.1065 3.59456 12.8509 4.70863 13.1906 5.96922C13.5302 7.2298 13.4463 8.56706 12.9517 9.77529C12.4571 10.9835 11.5792 11.9958 10.4532 12.6563C10.8305 11.3103 10.9857 9.91172 10.9125 8.51571C11.2938 8.62353 11.5094 8.68446 11.8922 8.8079C11.47 7.70829 10.9518 6.64795 10.3438 5.63915C9.79373 6.67527 9.10279 7.63018 8.29065 8.47665L9.51565 8.40009C8.46097 10.2938 6.97347 11.2126 5.08909 11.4798C4.6594 9.33759 4.98128 5.85478 7.38597 4.21728L7.62972 5.38603C8.09847 4.63134 9.02503 3.6329 9.71409 2.93759C8.57965 2.49502 7.34402 2.37866 6.1469 2.60165C6.34534 2.77196 6.59065 2.98759 6.78909 3.15634C4.07815 4.3079 2.58909 6.20009 1.6094 8.40009C1.48639 7.55586 1.54531 6.69509 1.78221 5.87549C2.0191 5.0559 2.4285 4.29643 2.98295 3.64802C3.5374 2.99961 4.22409 2.47724 4.99695 2.11595C5.76982 1.75465 6.611 1.56279 7.46409 1.55321V1.56259Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_574_231">
<rect width="15" height="15" fill="currentColor" />
</clipPath>
</defs>
</svg>
);
}