mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-23 19:49:50 +02:00
feat: legacy
This commit is contained in:
1 parent
9640920863
commit
8d9ca3d716
271 files changed
+1
-1
No files matched your search
@@ -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>
|
||||
);
|
||||
});
|
||||
-171
@@ -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`,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
-93
@@ -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);
|
||||
}
|
||||
}
|
||||
-11
@@ -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>
|
||||
);
|
||||
});
|
||||
-144
@@ -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`,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
-10
@@ -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);
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
#include <clipping_planes_pars_vertex>
|
||||
|
||||
void main() {
|
||||
#include <begin_vertex>
|
||||
#include <project_vertex>
|
||||
#include <clipping_planes_vertex>
|
||||
}
|
||||
-153
@@ -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`,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
-24
@@ -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);
|
||||
}
|
||||
-11
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user