feat: migration 2

This commit is contained in:
TrèsAbhi committed 2024-09-06 23:17:53 -05:00
1 parent e1c8194beb
commit 6285f33989
305 files changed
+414 -414

No files matched your search

+1 -1
View File
@@ -1,3 +1,4 @@
import getStatsInPeriod from '@blitzkit/core/src/blitzstars/getStatsInPeriod';
import { Locale } from 'discord.js';
import { chunk } from 'lodash';
import * as Breakdown from '../../components/Breakdown';
@@ -12,7 +13,6 @@ import {
TankDefinition,
tankDefinitions,
} from '../../core/blitzkit/tankDefinitions';
import getStatsInPeriod from '../../core/blitzstars/getStatsInPeriod';
import { tankAverages } from '../../core/blitzstars/tankAverages';
import { ResolvedPeriod } from '../../core/discord/resolvePeriodFromCommand';
import { ResolvedPlayer } from '../../core/discord/resolvePlayerFromCommand';
+1 -1
View File
@@ -1,3 +1,4 @@
import getStatsInPeriod from '@blitzkit/core/src/blitzstars/getStatsInPeriod';
import { Locale } from 'discord.js';
import CommandWrapper from '../components/CommandWrapper';
import GenericAllStats from '../components/GenericAllStats';
@@ -9,7 +10,6 @@ import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { emblemURL } from '../core/blitzkit/emblemURL';
import { filtersToDescription } from '../core/blitzkit/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks';
+1 -1
View File
@@ -1,3 +1,4 @@
import getStatsInPeriod from '@blitzkit/core/src/blitzstars/getStatsInPeriod';
import { Locale } from 'discord.js';
import AllStatsOverview from '../components/AllStatsOverview';
import CommandWrapper from '../components/CommandWrapper';
@@ -8,7 +9,6 @@ import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { emblemURL } from '../core/blitzkit/emblemURL';
import { filtersToDescription } from '../core/blitzkit/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks';
+1 -1
View File
@@ -1,5 +1,5 @@
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
import { Locale } from 'discord.js';
import { Percentile } from '../../constants/percentiles';
import { SupplementaryStats } from '../../core/blitz/getAccountInfo';
import { translator } from '../../core/localization/translator';
import getWN8Percentile from '../../core/statistics/getWN8Percentile';
@@ -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 * as App from '../../../../packages/website/src/stores/app';
import * as Duel from '../../../../packages/website/src/stores/duel';
import * as TankopediaEphemeral from '../../../../packages/website/src/stores/tankopediaEphemeral';
import { resolveArmorIndex } from '../../../core/blitzkit/resolveArmorIndex';
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 * as Duel from '../../../../packages/website/src/stores/duel';
import { correctZYTuple } from '../../../core/blitz/correctZYTuple';
import { nameToArmorId } from '../../../core/blitzkit/nameToArmorId';
import { resolveArmor } from '../../../core/blitzkit/resolveThickness';
import { useArmor } from '../../../hooks/useArmor';
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition';
import { useTankTransform } from '../../../hooks/useTankTransform';
import { ModelTankWrapper } from './ModelTankWrapper';
import { PrimaryArmorSceneComponent } from './PrimaryArmorSceneComponent';
export const PrimaryArmorScene = memo(() => {
const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null);
const protagonist = Duel.use((draft) => draft.protagonist);
const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const armorGltf = useArmor(tank.id).gltf;
const armorNodes = Object.values(armorGltf.nodes);
const tankModelDefinition = useTankModelDefinition();
const trackModelDefinition = tankModelDefinition.tracks[track.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turretOrigin);
const gunOrigin = correctZYTuple(turretModelDefinition.gunOrigin);
const isDynamicArmorActive = Duel.use((state) =>
state.protagonist.consumables.includes(73),
);
useTankTransform(protagonist, turretContainer, gunContainer);
return (
<ModelTankWrapper ref={wrapper}>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
if (
!isVisible ||
spaced ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_01')) ||
(!isDynamicArmorActive && node.name.includes('state_00'))
)
return null;
return (
<PrimaryArmorSceneComponent
key={node.uuid}
thickness={thickness}
node={node}
/>
);
})}
</group>
<group ref={turretContainer}>
{armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
turretModelDefinition.armor,
armorId,
);
if (
!isVisible ||
spaced ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_01')) ||
(!isDynamicArmorActive && node.name.includes('state_00'))
)
return null;
return (
<group position={hullOrigin}>
<group key={node.uuid} position={turretOrigin}>
<PrimaryArmorSceneComponent
key={node.uuid}
thickness={thickness}
node={node}
/>
</group>
</group>
);
})}
<group ref={gunContainer}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor,
armorId,
);
if (
!isVisible ||
spaced ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_01')) ||
(!isDynamicArmorActive && node.name.includes('state_00'))
)
return null;
return (
<group position={hullOrigin}>
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<PrimaryArmorSceneComponent
thickness={thickness}
node={node}
/>
</group>
</group>
);
})}
</group>
</group>
</ModelTankWrapper>
);
});
@@ -1,169 +0,0 @@
import { resolveNearPenetration } from '@blitzkit/core/src/blitzkit/resolveNearPenetration';
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 * as Duel from '../../../../../packages/website/src/stores/duel';
import * as TankopediaPersistent from '../../../../../packages/website/src/stores/tankopediaPersistent';
import { canSplash } from '../../../../core/blitz/canSplash';
import { isExplosive } from '../../../../core/blitz/isExplosive';
import { resolvePenetrationCoefficient } from '../../../../core/blitz/resolvePenetrationCoefficient';
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../core/blitzkit/jsxTree';
import { ShellDefinition } from '../../../../core/blitzkit/tankDefinitions';
import fragmentShader from './shaders/fragment.glsl';
import vertexShader from './shaders/vertex.glsl';
import { spacedArmorRenderTarget } from './target';
interface PrimaryArmorSceneComponentProps {
thickness: number;
node: Object3D;
}
const excludeMaterial = new MeshBasicMaterial({
colorWrite: false,
});
export function PrimaryArmorSceneComponent({
node,
thickness,
}: PrimaryArmorSceneComponentProps) {
const tankopediaPersistentStore = TankopediaPersistent.useStore();
const duelStore = Duel.useStore();
const material = new ShaderMaterial({
fragmentShader,
vertexShader,
transparent: true,
uniforms: {
thickness: { value: null },
penetration: { value: null },
caliber: { value: null },
ricochet: { value: null },
normalization: { value: null },
isExplosive: { value: null },
canSplash: { value: null },
damage: { value: null },
explosionRadius: { value: null },
greenPenetration: { value: null },
opaque: { value: null },
inverseProjectionMatrix: { value: null },
resolution: { value: new Vector2() },
spacedArmorBuffer: { value: null },
spacedArmorDepth: { value: null },
},
});
useEffect(() => {
async function handleShellChange(shell: ShellDefinition) {
material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!,
);
material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0,
);
material.uniforms.isExplosive.value = isExplosive(shell.type);
material.uniforms.canSplash.value = canSplash(shell.type);
material.uniforms.damage.value = shell.damage.armor;
material.uniforms.explosionRadius.value = shell.explosionRadius;
const duel = duelStore.getState();
await handleProtagonistEquipmentChange(duel.protagonist.equipmentMatrix);
await handleAntagonistEquipmentChange(duel.antagonist.equipmentMatrix);
}
function handleVisualChange(
visual: TankopediaPersistent.TankopediaPersistent['model']['visual'],
) {
material.uniforms.greenPenetration.value = visual.greenPenetration;
material.uniforms.opaque.value = visual.opaque || visual.wireframe;
material.wireframe = visual.wireframe;
}
async function handleProtagonistEquipmentChange(
equipment: Duel.EquipmentMatrix,
) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist.tank.equipment,
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03
: thickness;
}
async function handleAntagonistEquipmentChange(
equipment: Duel.EquipmentMatrix,
) {
const duel = duelStore.getState();
const shell = duel.antagonist.shell;
const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist.tank.equipment,
equipment,
);
material.uniforms.penetration.value =
penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
}
handleShellChange(duelStore.getState().antagonist.shell);
handleVisualChange(tankopediaPersistentStore.getState().model.visual);
handleProtagonistEquipmentChange(
duelStore.getState().protagonist.equipmentMatrix,
);
handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix,
);
const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange),
tankopediaPersistentStore.subscribe(
(state) => state.model.visual,
handleVisualChange,
),
duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange,
),
duelStore.subscribe(
(state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange,
),
];
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
useFrame(({ gl, camera }) => {
gl.getSize(material.uniforms.resolution.value).multiplyScalar(
gl.getPixelRatio(),
);
material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
material.uniforms.spacedArmorDepth.value =
spacedArmorRenderTarget.depthTexture;
material.uniforms.inverseProjectionMatrix.value =
camera.projectionMatrixInverse;
});
return (
<>
{jsxTree(
node,
{ mesh: { renderOrder: 0, material: excludeMaterial } },
`${node.uuid}-exclude`,
)}
{jsxTree(
node,
{ mesh: { renderOrder: 1, material } },
`${node.uuid}-include`,
)}
</>
);
}
@@ -1,93 +0,0 @@
varying vec3 vNormal;
varying vec3 vViewPosition;
uniform float thickness;
uniform float penetration;
uniform float caliber;
uniform float ricochet;
uniform float normalization;
uniform vec2 resolution;
uniform bool isExplosive;
uniform bool canSplash;
uniform float damage;
uniform float explosionRadius;
uniform bool greenPenetration;
uniform bool opaque;
uniform mat4 inverseProjectionMatrix;
uniform highp sampler2D spacedArmorBuffer;
uniform highp sampler2D spacedArmorDepth;
float depthToDistance(float depth) {
vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
vec4 eyePosition = inverseProjectionMatrix * clipPosition;
return length(eyePosition.xyz / eyePosition.w);
}
void main() {
float penetrationChance = -1.0;
float splashChance = -1.0;
vec2 screenCoordinates = gl_FragCoord.xy / resolution;
float angle = acos(dot(vNormal, -vViewPosition) / length(vViewPosition));
vec4 spacedArmorBufferFragment = texture2D(spacedArmorBuffer, screenCoordinates);
bool isUnderSpacedArmor = spacedArmorBufferFragment.a != 0.0;
bool threeCalibersRule = caliber > thickness * 3.0 || isUnderSpacedArmor;
if (!threeCalibersRule && angle >= ricochet) {
penetrationChance = 0.0;
} else {
bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0;
float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization;
float finalThickness = thickness / cos(max(0.0, angle - finalNormalization));
// world space in meters
vec4 spacedArmorDepthFragment = texture2D(spacedArmorDepth, screenCoordinates);
float spacedArmorDistance = depthToDistance(spacedArmorDepthFragment.r);
float primaryArmorDistance = depthToDistance(gl_FragCoord.z);
float distanceFromSpacedArmor = primaryArmorDistance - spacedArmorDistance;
if (canSplash && isUnderSpacedArmor) {
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * (finalThickness + spacedArmorThickness);
penetrationChance = 0.0;
splashChance = finalDamage > 0.0 ? 1.0 : 0.0;
} else {
float remainingPenetration = penetration;
if (isUnderSpacedArmor) {
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
remainingPenetration -= spacedArmorThickness;
if (isExplosive && remainingPenetration > 0.0) {
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
}
}
remainingPenetration = max(0.0, remainingPenetration);
float delta = finalThickness - remainingPenetration;
float randomization = remainingPenetration * 0.05;
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
if (canSplash) {
float splashDamage = 0.5 * damage - 1.1 * finalThickness;
splashChance = splashDamage > 0.0 ? 1.0 : 0.0;
} else {
splashChance = 0.0;
}
}
}
float alpha = opaque ? 1.0 : 0.5;
vec3 color = vec3(1.0, splashChance * 0.392, 0.0);
if (greenPenetration) {
// non-trigonometric red-green mixing: https://www.desmos.com/calculator/ceo1oq7l67
float falling = -pow(penetrationChance, 2.0) + 1.0;
float gaining = -pow(penetrationChance - 1.0, 2.0) + 1.0;
gl_FragColor = vec4(falling * color + gaining * vec3(0.0, 1.0, 0.0), alpha);
} else {
gl_FragColor = vec4(color, (1.0 - penetrationChance) * alpha);
}
}
@@ -1,11 +0,0 @@
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
vViewPosition = vViewPosition4.xyz;
vNormal = normalMatrix * normal;
gl_Position = projectionMatrix * vViewPosition4;
}
@@ -1,3 +0,0 @@
import { WebGLRenderTarget } from 'three';
export const spacedArmorRenderTarget = new WebGLRenderTarget();
@@ -1,281 +0,0 @@
import { 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 * as TankopediaEphemeral from '../../../../packages/website/src/stores/tankopediaEphemeral';
import { J_HAT, K_HAT } from '../../../constants/axis';
import { useFullScreen } from '../../../hooks/useFullScreen';
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,224 +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 '../../../../packages/website/src/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,206 +0,0 @@
import { memo, useRef } from 'react';
import { Group, Plane, Scene, Vector3 } from 'three';
import * as Duel from '../../../../packages/website/src/stores/duel';
import { correctZYTuple } from '../../../core/blitz/correctZYTuple';
import { nameToArmorId } from '../../../core/blitzkit/nameToArmorId';
import { resolveArmor } from '../../../core/blitzkit/resolveThickness';
import { useArmor } from '../../../hooks/useArmor';
import { useModel } from '../../../hooks/useModel';
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition';
import { useTankTransform } from '../../../hooks/useTankTransform';
import { ModelTankWrapper } from './ModelTankWrapper';
import { SpacedArmorSceneComponent } from './SpacedArmorSceneComponent';
export enum ArmorType {
Primary,
Spaced,
External,
}
interface SpacedArmorSceneProps {
scene: Scene;
}
export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null);
const protagonist = Duel.use((draft) => draft.protagonist);
const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const { gltf: armorGltf } = useArmor(tank.id);
const { gltf: modelGltf } = useModel(tank.id);
const armorNodes = Object.values(armorGltf.nodes);
const modelNodes = Object.values(modelGltf.nodes);
const tankModelDefinition = useTankModelDefinition();
const trackModelDefinition = tankModelDefinition.tracks[track.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turretOrigin);
const gunOrigin = correctZYTuple(turretModelDefinition.gunOrigin);
const maskOrigin =
gunModelDefinition.mask === undefined
? undefined
: gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y;
const isDynamicArmorActive = Duel.use((state) =>
state.protagonist.consumables.includes(73),
);
useTankTransform(protagonist, turretContainer, gunContainer);
return (
<ModelTankWrapper ref={wrapper}>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
if (
!isVisible ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_00')) ||
(!isDynamicArmorActive && node.name.includes('state_01'))
)
return null;
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
/>
);
})}
</group>
{modelNodes.map((node) => {
const isWheel = node.name.startsWith('chassis_wheel_');
const isTrack = node.name.startsWith('chassis_track_');
const isVisible = isWheel || isTrack;
if (!isVisible) return null;
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={ArmorType.External}
thickness={trackModelDefinition.thickness}
variant="track"
node={node}
/>
);
})}
<group ref={turretContainer}>
{armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
turretModelDefinition.armor,
armorId,
);
if (
!isVisible ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_00')) ||
(!isDynamicArmorActive && node.name.includes('state_01'))
)
return null;
return (
<group position={hullOrigin}>
<group key={node.uuid} position={turretOrigin}>
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
/>
</group>
</group>
);
})}
<group ref={gunContainer}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor,
armorId,
);
if (
!isVisible ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_00')) ||
(!isDynamicArmorActive && node.name.includes('state_01'))
)
return null;
return (
<group position={hullOrigin}>
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<SpacedArmorSceneComponent
scene={scene}
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
/>
</group>
</group>
);
})}
{modelNodes.map((node) => {
const gunString = `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
const isCurrentGun = gunModelDefinition.mask
? node.name.startsWith(gunString)
: node.name === gunString;
const isVisible = isCurrentGun;
if (!isVisible) return null;
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={ArmorType.External}
thickness={gunModelDefinition.thickness}
variant="gun"
node={node}
clip={
maskOrigin === undefined
? undefined
: new Plane(new Vector3(0, 0, -1), -maskOrigin)
}
/>
);
})}
</group>
</group>
</ModelTankWrapper>
);
});
@@ -1,142 +0,0 @@
import { resolveNearPenetration } from '@blitzkit/core/src/blitzkit/resolveNearPenetration';
import { useEffect } from 'react';
import {
AdditiveBlending,
MeshBasicMaterial,
Object3D,
Plane,
ShaderMaterial,
} from 'three';
import { ArmorUserData, ExternalModuleVariant } from '../..';
import * as Duel from '../../../../../../../packages/website/src/stores/duel';
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
import { ShellDefinition } from '../../../../../../core/blitzkit/tankDefinitions';
import { ArmorType } from '../../../SpacedArmorScene';
import fragmentShader from './shaders/fragment.glsl';
import vertexShader from './shaders/vertex.glsl';
interface SpacedArmorSubExternalProps {
node: Object3D;
thickness: number;
variant: ExternalModuleVariant;
clip?: Plane;
}
export function SpacedArmorSubExternal({
node,
thickness,
variant,
clip,
}: SpacedArmorSubExternalProps) {
const duelStore = Duel.useStore();
const material = new ShaderMaterial({
fragmentShader,
vertexShader,
depthTest: true,
depthWrite: false,
blending: AdditiveBlending,
clipping: clip !== undefined,
...(clip ? { clippingPlanes: [clip] } : {}),
uniforms: {
thickness: { value: null },
penetration: { value: null },
},
});
useEffect(() => {
function handleShellChange(shell: ShellDefinition) {
material.uniforms.penetration.value = resolveNearPenetration(
shell.penetration,
);
}
async function handleProtagonistEquipmentChange(
equipment: Duel.EquipmentMatrix,
) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist.tank.equipment,
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03
: thickness;
}
async function handleAntagonistEquipmentChange(
equipment: Duel.EquipmentMatrix,
) {
const duel = duelStore.getState();
const shell = duel.antagonist.shell;
const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist.tank.equipment,
equipment,
);
material.uniforms.penetration.value =
penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
}
handleShellChange(duelStore.getState().antagonist.shell);
handleProtagonistEquipmentChange(
duelStore.getState().protagonist.equipmentMatrix,
);
handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix,
);
const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange),
duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange,
),
duelStore.subscribe(
(state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange,
),
];
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
return (
<>
{jsxTree(
node,
{
mesh: {
renderOrder: 3,
material: new MeshBasicMaterial({
colorWrite: false,
depthTest: true,
depthWrite: true,
...(clip ? { clippingPlanes: [clip] } : {}),
}),
onClick() {},
userData: {
type: ArmorType.External,
thickness,
variant,
} satisfies ArmorUserData,
},
},
`${node.uuid}-exclude`,
)}
{jsxTree(
node,
{ mesh: { renderOrder: 4, material } },
`${node.uuid}-include`,
)}
</>
);
}
@@ -1,10 +0,0 @@
uniform float thickness;
uniform float penetration;
#include <clipping_planes_pars_fragment>
void main() {
#include <clipping_planes_fragment>
gl_FragColor = vec4(thickness / penetration, 0.0, 0.0, 1.0);
}
@@ -1,7 +0,0 @@
#include <clipping_planes_pars_vertex>
void main() {
#include <begin_vertex>
#include <project_vertex>
#include <clipping_planes_vertex>
}
@@ -1,151 +0,0 @@
import { resolveNearPenetration } from '@blitzkit/core/src/blitzkit/resolveNearPenetration';
import { useEffect } from 'react';
import {
AdditiveBlending,
MeshBasicMaterial,
Object3D,
ShaderMaterial,
} from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { ArmorUserData } from '../..';
import * as Duel from '../../../../../../../packages/website/src/stores/duel';
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
import { ShellDefinition } from '../../../../../../core/blitzkit/tankDefinitions';
import { ArmorType } from '../../../SpacedArmorScene';
import fragmentShader from './shaders/fragment.glsl';
import vertexShader from './shaders/vertex.glsl';
interface SpacedArmorSubSpacedProps {
node: Object3D;
thickness: number;
}
const depthWriteMaterial = new MeshBasicMaterial({
depthWrite: true,
colorWrite: false,
});
export function SpacedArmorSubSpaced({
node,
thickness,
}: SpacedArmorSubSpacedProps) {
const duelStore = Duel.useStore();
const material = new ShaderMaterial({
fragmentShader,
vertexShader,
depthTest: true,
depthWrite: false,
blending: AdditiveBlending,
uniforms: {
thickness: { value: null },
penetration: { value: null },
caliber: { value: null },
ricochet: { value: null },
normalization: { value: null },
},
});
useEffect(() => {
function handleShellChange(shell: ShellDefinition) {
material.uniforms.penetration.value = resolveNearPenetration(
shell.penetration,
);
material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!,
);
material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0,
);
}
async function handleProtagonistEquipmentChange(
equipment: Duel.EquipmentMatrix,
) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist.tank.equipment,
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03
: thickness;
}
async function handleAntagonistEquipmentChange(
equipment: Duel.EquipmentMatrix,
) {
const duel = duelStore.getState();
const shell = duelStore.getState().antagonist.shell;
const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist.tank.equipment,
equipment,
);
material.uniforms.penetration.value =
penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
}
handleShellChange(duelStore.getState().antagonist.shell);
handleProtagonistEquipmentChange(
duelStore.getState().protagonist.equipmentMatrix,
);
handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix,
);
const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange),
duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange,
),
duelStore.subscribe(
(state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange,
),
];
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
return (
<>
{jsxTree(
node,
{
mesh: {
renderOrder: 2,
material,
onClick() {},
userData: {
type: ArmorType.Spaced,
thickness,
} satisfies ArmorUserData,
},
},
`${node.uuid}-exclude`,
)}
{jsxTree(
node,
{
mesh: {
renderOrder: 5,
material: depthWriteMaterial,
},
},
`${node.uuid}-include`,
)}
</>
);
}
@@ -1,24 +0,0 @@
varying vec3 vNormal;
varying vec3 vViewPosition;
uniform float thickness;
uniform float penetration;
uniform float caliber;
uniform float ricochet;
uniform float normalization;
void main() {
float angle = acos(dot(vNormal, -vViewPosition) / length(vViewPosition));
bool threeCalibersRule = caliber > thickness * 3.0;
if (!threeCalibersRule && angle >= ricochet) {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
return;
}
bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0;
float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization;
float finalThickness = thickness / cos(max(0.0, angle - finalNormalization));
gl_FragColor = vec4(finalThickness / penetration, 0.0, 0.0, 1.0);
}
@@ -1,11 +0,0 @@
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
vViewPosition = vViewPosition4.xyz;
vNormal = normalMatrix * normal;
gl_Position = projectionMatrix * vViewPosition4;
}
@@ -1,409 +0,0 @@
import { resolveNearPenetration } from '@blitzkit/core/src/blitzkit/resolveNearPenetration';
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 * as Duel from '../../../../../packages/website/src/stores/duel';
import * as TankopediaEphemeral from '../../../../../packages/website/src/stores/tankopediaEphemeral';
import { isExplosive } from '../../../../core/blitz/isExplosive';
import { resolvePenetrationCoefficient } from '../../../../core/blitz/resolvePenetrationCoefficient';
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../core/blitzkit/jsxTree';
import { ShellType } from '../../../../core/blitzkit/tankDefinitions';
import { discardClippingPlane } from '../../../../core/three/discardClippingPlane';
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,410 +0,0 @@
import { ThreeEvent, useThree } from '@react-three/fiber';
import { memo, useRef } from 'react';
import { Group, Plane, Vector2, Vector3 } from 'three';
import * as Duel from '../../../../packages/website/src/stores/duel';
import * as TankopediaEphemeral from '../../../../packages/website/src/stores/tankopediaEphemeral';
import * as TankopediaPersistent from '../../../../packages/website/src/stores/tankopediaPersistent';
import { applyPitchYawLimits } from '../../../core/blitz/applyPitchYawLimits';
import { correctZYTuple } from '../../../core/blitz/correctZYTuple';
import { hasEquipment } from '../../../core/blitzkit/hasEquipment';
import { modelTransformEvent } from '../../../core/blitzkit/modelTransform';
import { nameToArmorId } from '../../../core/blitzkit/nameToArmorId';
import { resolveArmor } from '../../../core/blitzkit/resolveThickness';
import { normalizeAngleRad } from '../../../core/math/normalizeAngleRad';
import { useArmor } from '../../../hooks/useArmor';
import { useModel } from '../../../hooks/useModel';
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition';
import { useTankTransform } from '../../../hooks/useTankTransform';
import { ModelTankWrapper } from './ModelTankWrapper';
import { 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 { 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 * as TankopediaEphemeral from '../../../../packages/website/src/stores/tankopediaEphemeral';
import { I_HAT, J_HAT } from '../../../constants/axis';
import { unrotateDavaVector } from '../../../core/blitz/unrotateDavaVector';
import { jsxTree } from '../../../core/blitzkit/jsxTree';
import {
modelTransformEvent,
ModelTransformEventData,
} from '../../../core/blitzkit/modelTransform';
import { discardClippingPlane } from '../../../core/three/discardClippingPlane';
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>
)}
</>
);
}
-55
View File
@@ -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}
</>
);
});
+3 -3
View File
@@ -1,5 +1,5 @@
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
import { createColors } from 'bepaint';
import { Percentile } from '../../../constants/percentiles';
import {
AccentColor,
GrayColor,
@@ -82,8 +82,8 @@ export function Row({
? treeType === 'collector'
? theme.textLowContrast_blue
: treeType === 'premium'
? theme.textLowContrast_amber
: theme.textHighContrast
? theme.textLowContrast_amber
: theme.textHighContrast
: theme.textLowContrast,
fontWeight: 900,
fontSize: 16,
@@ -1,5 +1,5 @@
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
import { createColors } from 'bepaint';
import { Percentile } from '../../../constants/percentiles';
import {
AccentColor,
GrayColor,
-71
View File
@@ -1,71 +0,0 @@
import { Flex, FlexProps } from '@radix-ui/themes';
import { ConsumableEntry } from '../core/blitzkit/consumableDefinitions';
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>
);
}
-60
View File
@@ -1,60 +0,0 @@
import { Flex } from '@radix-ui/themes';
import { use } from 'react';
import { permanentSkills } from '../app/tools/tankopedia/[id]/components/Characteristics/components/Skills/constants';
import { asset } from '../core/blitzkit/asset';
import { skillDefinitions } from '../core/blitzkit/skillDefinitions';
import { Tier } from '../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../core/blitzkit/tankDefinitions/constants';
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>
);
}
-68
View File
@@ -1,68 +0,0 @@
import { Flex } from '@radix-ui/themes';
import { cloneDeep } from 'lodash';
import { EquipmentMatrix } from '../../packages/website/src/stores/duel';
import { EquipmentPreset } from '../core/blitzkit/equipmentDefinitions';
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,4 +1,4 @@
import { Percentile } from '../../../constants/percentiles';
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
import { theme } from '../../../stitches.config';
import PercentileIndicator from '../../PercentileIndicator';
+1 -1
View File
@@ -1,7 +1,7 @@
import isNumber from '@blitzkit/core/src/math/isNumber';
import { Locale } from 'discord.js';
import { SupplementaryStats } from '../../core/blitz/getAccountInfo';
import { translator } from '../../core/localization/translator';
import isNumber from '../../core/math/isNumber';
import { BlitzStats } from '../../core/statistics/compositeStats/constants';
import getWN8Percentile from '../../core/statistics/getWN8Percentile';
import { Card } from './components/Card';
+1 -1
View File
@@ -1,4 +1,4 @@
import { Percentile } from '../../constants/percentiles';
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
import GenericStatsColumn from './components/GenericStatsColumn';
export type GenericStat =
@@ -1,73 +0,0 @@
import { ClockIcon, ReloadIcon } from '@radix-ui/react-icons';
import { Flex, Text } from '@radix-ui/themes';
import { use } from 'react';
import { asset } from '../../core/blitzkit/asset';
import { consumableDefinitions } from '../../core/blitzkit/consumableDefinitions';
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,21 +0,0 @@
import { use } from 'react';
import { asset } from '../../core/blitzkit/asset';
import { equipmentDefinitions } from '../../core/blitzkit/equipmentDefinitions';
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 '../../core/blitzkit/asset';
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,21 +0,0 @@
import { use } from 'react';
import { asset } from '../../core/blitzkit/asset';
import { provisionDefinitions } from '../../core/blitzkit/provisionDefinitions';
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/src/blitzkit/asset';
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;
}
-201
View File
@@ -1,201 +0,0 @@
import { DropdownMenu, Flex } from '@radix-ui/themes';
import { useState } from 'react';
import {
EngineDefinition,
GunDefinition,
ShellDefinition,
TankDefinition,
TrackDefinition,
TurretDefinition,
} from '../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../core/blitzkit/tankDefinitions/constants';
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,3 +1,4 @@
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
import {
amber,
blue,
@@ -10,7 +11,6 @@ import {
slate,
teal,
} from '@radix-ui/colors';
import { Percentile } from '../../constants/percentiles';
export const PERCENTILE_COLORS = {
[Percentile.VeryBad]: slate.slate9,
+1 -1
View File
@@ -1,4 +1,4 @@
import { Percentile } from '../../constants/percentiles';
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
import { PERCENTILE_COLORS } from './constants';
interface PercentileIndicatorProps {
-46
View File
@@ -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>
);
}
-6
View File
@@ -1,6 +0,0 @@
import { Vector3 } from 'three';
export const ZERO_VECTOR = new Vector3();
export const I_HAT = new Vector3(1, 0, 0);
export const J_HAT = new Vector3(0, 1, 0);
export const K_HAT = new Vector3(0, 0, 1);
-13
View File
@@ -1,13 +0,0 @@
// http://www.wotinfo.net/en/wn8scale
export enum Percentile {
VeryBad,
Bad,
BelowAverage,
Average,
AboveAverage,
Good,
VeryGood,
Great,
Unicum,
SuperUnicum,
}
-5
View File
@@ -1,5 +0,0 @@
export const youtubers = [
{ id: 'UC9lE09jxmMgMoG13WrNHPmA', name: 'World of Tanks Blitz' },
{ id: 'UCrQ-Dy8lVsm11u8pCJ8W7Tw', name: 'HisRoyalFatness' },
{ id: 'UCxRpZqMElssTdPYDK3x7wyg', name: 'N90' },
];
-84
View File
@@ -1,84 +0,0 @@
import {
PitchLimits,
YawLimits,
} from '@blitzkit/core/src/blitzkit/modelDefinitions';
import { clamp } from 'lodash';
import { degToRad } from 'three/src/math/MathUtils';
import { normalizeAngleRad } from '../math/normalizeAngleRad';
export function applyPitchYawLimits(
pitch: number, // rad
yaw: number, // rad
pitchLimits: PitchLimits, // deg
yawLimits?: YawLimits, // deg
verticalStabilizer = false,
) {
let newPitch = pitch;
let newYaw = yaw;
if (yawLimits) {
newYaw = normalizeAngleRad(
clamp(newYaw, -degToRad(yawLimits.max), -degToRad(yawLimits.min)),
);
}
const depressionBuff = verticalStabilizer ? degToRad(3) : 0;
const elevationBuff = verticalStabilizer ? degToRad(4) : 0;
let lowerPitch = -degToRad(pitchLimits.max) - depressionBuff;
let upperPitch = -degToRad(pitchLimits.min) + elevationBuff;
const transition = degToRad(pitchLimits.transition ?? 10);
if (pitchLimits.back) {
const range = degToRad(pitchLimits.back.range);
const yawRotatedAbs = Math.abs(normalizeAngleRad(newYaw - Math.PI));
if (yawRotatedAbs <= range + transition) {
if (yawRotatedAbs <= range) {
// inside range
lowerPitch = -degToRad(pitchLimits.back.max);
upperPitch = -degToRad(pitchLimits.back.min);
} else {
// inside transition
const transitionProgress = (yawRotatedAbs - range) / transition;
const maxInterpolatedPitch =
(1 - transitionProgress) * pitchLimits.back.max +
transitionProgress * pitchLimits.max;
const minInterpolatedPitch =
(1 - transitionProgress) * pitchLimits.back.min +
transitionProgress * pitchLimits.min;
lowerPitch = -degToRad(maxInterpolatedPitch);
upperPitch = -degToRad(minInterpolatedPitch);
}
}
}
if (pitchLimits.front) {
const range = degToRad(pitchLimits.front.range);
const yawAbs = Math.abs(normalizeAngleRad(newYaw));
if (yawAbs <= range + transition) {
if (yawAbs <= range) {
// inside range
lowerPitch = -degToRad(pitchLimits.front.max);
upperPitch = -degToRad(pitchLimits.front.min);
} else {
// inside transition
const transitionProgress = (yawAbs - range) / transition;
const maxInterpolatedPitch =
(1 - transitionProgress) * pitchLimits.front.max +
transitionProgress * pitchLimits.max;
const minInterpolatedPitch =
(1 - transitionProgress) * pitchLimits.front.min +
transitionProgress * pitchLimits.min;
lowerPitch = -degToRad(maxInterpolatedPitch);
upperPitch = -degToRad(minInterpolatedPitch);
}
}
}
newPitch = clamp(newPitch, lowerPitch, upperPitch);
return [newPitch, newYaw];
}
-5
View File
@@ -1,5 +0,0 @@
import { ShellDefinition, ShellType } from '../blitzkit/tankDefinitions';
export function canSplash(type: ShellDefinition['type']): type is ShellType.HE {
return type === ShellType.HE;
}
-6
View File
@@ -1,6 +0,0 @@
import { Vector3, Vector3Tuple } from 'three';
import { rotateDavaVector } from './rotateDavaVector';
export function correctZYTuple(tuple: Vector3Tuple) {
return rotateDavaVector(new Vector3(tuple[0], tuple[1], -tuple[2]));
}
-141
View File
@@ -1,141 +0,0 @@
import { Accessor, Document, Node, Scene } from '@gltf-transform/core';
import { times } from 'lodash';
import { Hierarchy, Sc2ReadStream } from '../streams/sc2';
import { ScgReadStream } from '../streams/scg';
import { VertexAttribute } from '../streams/scpg';
import {
vertexAttributeGLTFName,
vertexAttributeGltfVectorSizes,
} from './extractModel/constants';
import { readDVPLFile } from './readDVPLFile';
export async function extractArmor(data: string, fileName: string) {
const sc2Path = `${data}/3d/Tanks/CollisionMeshes/${fileName}.sc2.dvpl`;
const scgPath = `${data}/3d/Tanks/CollisionMeshes/${fileName}.scg.dvpl`;
const sc2 = new Sc2ReadStream(
(await readDVPLFile(sc2Path)).buffer as ArrayBuffer,
).sc2();
const scg = new ScgReadStream(
(await readDVPLFile(scgPath)).buffer as ArrayBuffer,
).scg();
const document = new Document();
const scene = document.createScene();
const buffer = document.createBuffer();
function parseHierarchies(hierarchies: Hierarchy[], parent: Scene | Node) {
hierarchies.forEach((hierarchy) => {
const components = times(
hierarchy.components.count,
(index) => hierarchy.components[index.toString().padStart(4, '0')],
);
components.forEach((component) => {
switch (component['comp.typename']) {
case 'TransformComponent':
break;
case 'RenderComponent': {
const batch = component['rc.renderObj']['ro.batches']['0000'];
const polygonGroup = scg.get(batch['rb.datasource']);
if (!polygonGroup) {
console.warn(
`Missing polygon group ${batch['rb.datasource']} (${hierarchy.name}); skipping...`,
);
break;
}
const hardJointIndices = new Set<number>();
const vertexHardJointIndices = new Map<number, number>();
const attributes = new Map<VertexAttribute, number[][]>();
polygonGroup.vertices.forEach((vertex, index) => {
vertex.forEach(({ attribute, value }) => {
if (!attributes.has(attribute)) {
attributes.set(attribute, []);
}
attributes.get(attribute)!.push(value);
if (attribute === VertexAttribute.HARD_JOINTINDEX) {
hardJointIndices.add(value[0]);
vertexHardJointIndices.set(index, value[0]);
}
});
});
if (attributes.has(VertexAttribute.TANGENT)) {
attributes.set(
VertexAttribute.TANGENT,
attributes
.get(VertexAttribute.TANGENT)!
.map((tangent) => [...tangent, 1]),
);
}
const accessors = new Map<string, Accessor>();
attributes.forEach((value, attribute) => {
const name = vertexAttributeGLTFName[attribute];
if (!name || accessors.has(name)) return;
const vertexSize = vertexAttributeGltfVectorSizes[attribute];
const accessor = document
.createAccessor(name)
.setType(vertexSize === 1 ? 'SCALAR' : `VEC${vertexSize}`)
.setArray(new Float32Array(value.flat()))
.setBuffer(buffer);
accessors.set(name, accessor);
});
hardJointIndices.forEach((hardJointIndex) => {
const node = document.createNode(
`${hierarchy.name}_armor_${hardJointIndex}`,
);
const mesh = document.createMesh(batch['##name']);
const indicesAccessor = document
.createAccessor()
.setType('SCALAR')
.setArray(
new Uint16Array(
polygonGroup.indices.filter(
(index) =>
vertexHardJointIndices.get(index) === hardJointIndex,
),
),
)
.setBuffer(buffer);
const primitive = document
.createPrimitive()
.setIndices(indicesAccessor);
accessors.forEach((accessor, name) => {
primitive.setAttribute(name, accessor);
});
mesh.addPrimitive(primitive);
node.setMesh(mesh);
parent.addChild(node);
});
break;
}
default:
throw new TypeError(
`Unhandled component type: ${component['comp.typename']}`,
);
}
});
if (hierarchy['#hierarchy']) {
parseHierarchies(hierarchy['#hierarchy'], parent);
}
});
}
parseHierarchies(sc2['#hierarchy'], scene);
return document;
}
-19
View File
@@ -1,19 +0,0 @@
import { vertexAttributeVectorSizes } from '../../streams/scg';
import { VertexAttribute } from '../../streams/scpg';
export const vertexAttributeGLTFName: Partial<Record<VertexAttribute, string>> =
{
[VertexAttribute.VERTEX]: 'POSITION',
[VertexAttribute.NORMAL]: 'NORMAL',
[VertexAttribute.TEXCOORD0]: 'TEXCOORD_0',
[VertexAttribute.TEXCOORD1]: 'TEXCOORD_0',
[VertexAttribute.TEXCOORD2]: 'TEXCOORD_0',
[VertexAttribute.TEXCOORD3]: 'TEXCOORD_0',
[VertexAttribute.TANGENT]: 'TANGENT',
[VertexAttribute.JOINTINDEX]: 'JOINT_0',
[VertexAttribute.JOINTWEIGHT]: 'WEIGHT_0',
};
export const vertexAttributeGltfVectorSizes = {
...vertexAttributeVectorSizes,
[VertexAttribute.TANGENT]: 4,
} as const;
-288
View File
@@ -1,288 +0,0 @@
import { Document, Material, Node, Scene } from '@gltf-transform/core';
import { times } from 'lodash';
import { dirname } from 'path';
import { Matrix4, Quaternion, Vector3, Vector4Tuple } from 'three';
import { readTexture } from '../../blitzkit/readTexture';
import { TextureMutation } from '../../blitzkit/readTexture/constants';
import { Hierarchy, Sc2ReadStream, Textures } from '../../streams/sc2';
import { ScgReadStream } from '../../streams/scg';
import { VertexAttribute } from '../../streams/scpg';
import { readDVPLFile } from '../readDVPLFile';
import {
vertexAttributeGLTFName,
vertexAttributeGltfVectorSizes,
} from './constants';
const ERROR_ON_UNKNOWN_COMPONENT = false;
const omitMeshNames = {
start: ['chassis_chassis_', 'chassis_track_crash_', 'HP_'],
end: ['_POINT'],
};
export async function extractModel(data: string, path: string) {
const sc2Path = `${data}/3d/${path}.sc2.dvpl`;
const scgPath = `${data}/3d/${path}.scg.dvpl`;
const sc2 = new Sc2ReadStream(
(await readDVPLFile(sc2Path)).buffer as ArrayBuffer,
).sc2();
const scg = new ScgReadStream(
(await readDVPLFile(scgPath)).buffer as ArrayBuffer,
).scg();
const document = new Document();
const scene = document.createScene();
const buffer = document.createBuffer();
const materials = new Map<bigint, Material | bigint | undefined>();
// create materials
await Promise.all(
sc2['#dataNodes'].map(async (node) => {
const id = new DataView(node['#id']).getBigUint64(0, true);
if (node.parentMaterialKey !== undefined) {
/**
* material depends on a parent and doesn't seem to have any
* properties so we just point it to the parent
*/
materials.set(id, node.parentMaterialKey);
return;
}
const material = document.createMaterial(node.materialName);
let textures: Textures | undefined = undefined;
if (node.textures) {
textures = node.textures;
} else if (typeof node.configCount === 'number') {
textures = node.configArchive_0.textures;
}
if (textures) {
material.setBaseColorTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setImage(
await readTexture(
`${data}/3d/${dirname(path)}/${textures.albedo}`,
TextureMutation.Albedo,
),
),
);
if (textures.baseRMMap) {
material.setMetallicRoughnessTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setImage(
await readTexture(
`${data}/3d/${dirname(path)}/${textures.baseRMMap}`,
TextureMutation.RoughnessMetallicness,
),
),
);
}
if (textures.baseNormalMap ?? textures.normalmap) {
const isBase = textures.baseNormalMap !== undefined;
material.setNormalTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setImage(
await readTexture(
`${data}/3d/${dirname(path)}/${
textures.baseNormalMap ?? textures.normalmap
}`,
isBase ? TextureMutation.Normal : undefined,
),
),
);
}
if (textures.miscMap) {
material.setOcclusionTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setImage(
await readTexture(
`${data}/3d/${dirname(path)}/${textures.miscMap}`,
TextureMutation.Miscellaneous,
),
),
);
}
materials.set(
new DataView(node['#id']).getBigUint64(0, true),
material,
);
}
}),
);
// replace children materials with parents
materials.forEach((material, id) => {
if (typeof material !== 'bigint') return;
let resolvedMaterial: Material | bigint | undefined = material;
while (typeof resolvedMaterial === 'bigint') {
const linkedParentMaterial = materials.get(resolvedMaterial);
resolvedMaterial = linkedParentMaterial;
}
materials.set(id, resolvedMaterial);
});
function parseHierarchies(hierarchies: Hierarchy[], parent: Scene | Node) {
hierarchies.forEach((hierarchy) => {
if (
omitMeshNames.start.some((omit) => hierarchy.name.startsWith(omit)) ||
omitMeshNames.end.some((omit) => hierarchy.name.endsWith(omit))
)
return;
const node = document.createNode(hierarchy.name);
const components = times(
hierarchy.components.count,
(index) => hierarchy.components[index.toString().padStart(4, '0')],
);
components.forEach((component) => {
switch (component['comp.typename']) {
case 'LodComponent':
// found and used later by transform component
// TODO: lod component always shows up before transform component; cache it before parsing transform
break;
case 'TransformComponent': {
const translation = new Vector3();
const rotation = new Quaternion();
const scale = new Vector3();
new Matrix4()
.multiplyMatrices(
new Matrix4().compose(
new Vector3().fromArray(component['tc.worldTranslation']),
new Quaternion().fromArray(component['tc.worldRotation']),
new Vector3().fromArray(component['tc.worldScale']),
),
new Matrix4().compose(
new Vector3().fromArray(component['tc.localTranslation']),
new Quaternion().fromArray(component['tc.localRotation']),
new Vector3().fromArray(component['tc.localScale']),
),
)
.decompose(translation, rotation, scale);
node.setTranslation(translation.toArray());
node.setRotation(rotation.toArray() as Vector4Tuple);
node.setScale(scale.toArray());
break;
}
case 'RenderComponent': {
const renderObject = component['rc.renderObj'];
times(renderObject['ro.batchCount'], (batchIndex): void => {
const lodIndex = renderObject[`rb${batchIndex}.lodIndex`];
if (lodIndex !== 0) return;
const batchKey = batchIndex.toString().padStart(4, '0');
const batch = renderObject['ro.batches'][batchKey];
const material = materials.get(batch['rb.nmatname']);
const polygonGroup = scg.get(batch['rb.datasource']);
if (!(material instanceof Material)) {
// probably shadow material
return;
}
if (polygonGroup === undefined) {
throw new Error(
`Missing polygon group ${batch['rb.datasource']}`,
);
}
const lodNode = document.createNode(batchKey);
const indicesAccessor = document
.createAccessor()
.setType('SCALAR')
.setArray(new Uint16Array(polygonGroup.indices))
.setBuffer(buffer);
const primitive = document
.createPrimitive()
.setIndices(indicesAccessor)
.setMaterial(material);
const attributes = new Map<VertexAttribute, number[][]>();
polygonGroup.vertices.forEach((vertex) => {
vertex.forEach(({ attribute, value }) => {
if (!attributes.has(attribute)) {
attributes.set(attribute, []);
}
attributes.get(attribute)!.push(value);
});
});
if (attributes.has(VertexAttribute.TANGENT)) {
attributes.set(
VertexAttribute.TANGENT,
attributes
.get(VertexAttribute.TANGENT)!
.map((tangent) => [...tangent, 1]),
);
}
attributes.forEach((value, attribute) => {
const name = vertexAttributeGLTFName[attribute];
if (!name || primitive.getAttribute(name)) return;
const vertexSize = vertexAttributeGltfVectorSizes[attribute];
const attributeAccessor = document
.createAccessor(name)
.setType(vertexSize === 1 ? 'SCALAR' : `VEC${vertexSize}`)
.setArray(new Float32Array(value.flat()))
.setBuffer(buffer);
primitive.setAttribute(name, attributeAccessor);
});
const mesh = document
.createMesh(batch['##name'])
.addPrimitive(primitive);
lodNode.setMesh(mesh);
node.addChild(lodNode);
});
break;
}
default: {
if (ERROR_ON_UNKNOWN_COMPONENT) {
throw new Error(
`Unhandled component type: ${component['comp.typename']}`,
);
}
}
}
});
if (hierarchy['#hierarchy']) {
parseHierarchies(hierarchy['#hierarchy'], node);
}
parent.addChild(node);
});
}
parseHierarchies(sc2['#hierarchy'], scene);
return document;
}
-88
View File
@@ -1,88 +0,0 @@
import { assertSecret } from '@blitzkit/core';
import { Region } from '../../constants/regions';
import { EventManager } from '../blitzkit/eventManager';
import { patientFetch } from '../blitzkit/patientFetch';
const RETRY_ERRORS = ['REQUEST_LIMIT_EXCEEDED', 'SOURCE_NOT_AVAILABLE'];
export const retryAbleBlitzFetchEvent = new EventManager();
export const blitzFetchQueueAvailableEvent = new EventManager();
type BlitzResponse<Data extends object> =
| {
status: 'error';
error: { field: string; message: string; code: 402; value: null };
}
| {
status: 'ok';
data: Data;
};
const MAX_CALLS_PER_SECOND = 10;
const queue: { url: string; resolve: (data: object) => void }[] = [];
let inProgress = 0;
function decrement() {
inProgress--;
blitzFetchQueueAvailableEvent.emit(undefined);
manageQueue();
}
async function manageQueue() {
if (queue.length > 0 && inProgress < MAX_CALLS_PER_SECOND) {
inProgress++;
const timeout = setTimeout(decrement, 1000);
const request = queue.shift()!;
const data = (await patientFetch(request.url)
.then((response) => response.json())
.catch((error) => ({ status: 'error', error }))) as BlitzResponse<
typeof request.resolve
>;
if (data.status === 'ok') {
request.resolve(data.data);
} else {
if (RETRY_ERRORS.includes(data.error.message)) {
retryAbleBlitzFetchEvent.emit(undefined);
clearTimeout(timeout);
setTimeout(() => {
queue.push(request);
decrement();
}, 1000 / MAX_CALLS_PER_SECOND);
} else {
clearTimeout(timeout);
decrement();
throw new Error(`Wargaming response error status: "${data.status}"`, {
cause: `Message: "${data.error.message}"\nURL: "${request.url}"`,
});
}
}
}
}
export type FetchBlitzParams = Record<string, string | number | undefined>;
export default function fetchBlitz<Data extends object>(
region: Region,
path: string,
params: FetchBlitzParams = {},
) {
return new Promise<Data>((resolve) => {
queue.push({
url: `https://api.wotblitz.${region}/wotb/${path}/?${Object.entries({
application_id: assertSecret(process.env.WARGAMING_APPLICATION_ID),
...params,
})
.filter(([_, value]) => value !== undefined)
.map(([key, value]) => `${key}=${encodeURIComponent(value!)}`)
.join('&')}`,
resolve(data) {
resolve(data as Data);
},
});
manageQueue();
});
}
-58
View File
@@ -1,58 +0,0 @@
import calculateWN8 from '../statistics/calculateWN8';
import { BlitzStats } from '../statistics/compositeStats/constants';
export type Stat = keyof ReturnType<typeof generateStats>;
export const STAT_NAMES: Record<Stat, string> = {
battles: 'Battles',
winrate: 'Winrate',
wn8: 'WN8',
averageDamage: 'Damage',
damage: 'Accumulated damage',
wins: 'Wins',
};
export const STAT_KEYS = Object.keys(STAT_NAMES) as Stat[];
export function generateStats(s: BlitzStats, e?: BlitzStats) {
return {
battles: s.battles,
wins: s.wins,
winrate: s.wins / s.battles,
wn8: e ? calculateWN8(e, s) : null,
damage: s.damage_dealt,
averageDamage: s.damage_dealt / s.battles,
};
}
export function prettifyStats(s: ReturnType<typeof generateStats>) {
return {
battles: `${s.battles}`,
wins: `${s.wins}`,
winrate: `${(s.winrate * 100).toFixed(0)}%`,
wn8: s.wn8 ? s.wn8.toFixed(0) : '--',
damage: `${s.damage}`,
averageDamage: s.averageDamage.toFixed(0),
} satisfies Record<Stat, string>;
}
export function sumStats(s: ReturnType<typeof generateStats>[]) {
function sum(slice: (stat: ReturnType<typeof generateStats>) => number) {
return s.reduce((a, b) => a + slice(b), 0);
}
const battles = sum((s) => s.battles);
const wins = sum((s) => s.wins);
const damage = sum((s) => s.damage);
return {
battles,
wins,
winrate: wins / battles,
damage,
averageDamage: damage / battles,
wn8:
sum((s) => (s.wn8 ? s.wn8 * s.battles : 0)) /
sum((s) => (s.wn8 ? s.battles : 0)),
} satisfies ReturnType<typeof generateStats>;
}
-35
View File
@@ -1,35 +0,0 @@
import { Region } from '../../constants/regions';
import fetchBlitz from './fetchBlitz';
import { normalizeIds } from './normalizeIds';
interface IndividualAccountAchievements {
achievements: {
[achievement: string]: number;
};
max_series: {
[achievement: string]: number;
};
}
export interface AccountAchievements {
[id: number]: IndividualAccountAchievements;
}
export async function getAccountAchievements<
Ids extends number | number[],
ReturnType = Ids extends number
? IndividualAccountAchievements
: IndividualAccountAchievements[],
>(region: Region, ids: Ids) {
const object = await fetchBlitz<AccountAchievements>(
region,
'account/achievements',
{ account_id: normalizeIds(ids) },
);
if (typeof ids === 'number') {
return object[ids as number] as ReturnType;
} else {
return ids.map((id) => object[id]) as ReturnType;
}
}
-96
View File
@@ -1,96 +0,0 @@
import { Region } from '../../constants/regions';
import { BlitzStats } from '../statistics/compositeStats/constants';
import fetchBlitz, { FetchBlitzParams } from './fetchBlitz';
import { normalizeIds } from './normalizeIds';
export interface SupplementaryStats {
WN8?: number;
tier?: number;
}
export interface ClanStats {
spotted: number;
max_frags_tank_id: number;
hits: number;
frags: number;
max_xp: number;
max_xp_tank_id: number;
wins: number;
losses: number;
capture_points: number;
battles: number;
damage_dealt: number;
damage_received: number;
max_frags: number;
shots: number;
frags8p: number;
xp: number;
win_and_survived: number;
survived_battles: number;
dropped_capture_points: number;
}
export interface RatingStats {
spotted: number;
calibration_battles_left: number;
hits: number;
frags: number;
recalibration_start_time: number;
mm_rating: number;
wins: number;
losses: number;
is_recalibration: boolean;
capture_points: number;
battles: number;
damage_dealt: number;
damage_received: number;
shots: number;
frags8p: number;
xp: number;
win_and_survived: number;
survived_battles: number;
dropped_capture_points: number;
}
export interface IndividualAccountInfo {
account_id: number;
created_at: number;
updated_at: number;
private: null;
last_battle_time: number;
nickname: string;
statistics: {
all: BlitzStats;
clan: ClanStats;
rating?: RatingStats;
frags: null | number;
};
}
export interface AccountInfo {
[accountId: number]: IndividualAccountInfo;
}
export async function getAccountInfo<
Ids extends number | number[],
ReturnType = Ids extends number
? IndividualAccountInfo
: IndividualAccountInfo[],
>(
region: Region,
ids: Ids,
extra: string[] = [],
params: FetchBlitzParams = {},
) {
const object = await fetchBlitz<AccountInfo>(region, 'account/info', {
account_id: normalizeIds(ids),
extra: extra.length === 0 ? undefined : extra.join(','),
...params,
});
if (typeof ids === 'number') {
return object[ids as number] as ReturnType;
} else {
return ids.map((id) => object[id]) as ReturnType;
}
}
-46
View File
@@ -1,46 +0,0 @@
import { Region } from '../../constants/regions';
import fetchBlitz from './fetchBlitz';
import { normalizeIds } from './normalizeIds';
type IndividualClanAccountInfo = {
account_id: number;
joined_at: number;
clan_id: number;
role: string;
account_name: string;
clan?: {
members_count: number;
name: string;
created_at: number;
tag: string;
clan_id: number;
emblem_set_id: number;
};
} | null;
interface ClanAccountInfo {
[accountId: number]: IndividualClanAccountInfo;
}
export async function getClanAccountInfo<
Ids extends number | number[],
ReturnType = Ids extends number
? IndividualClanAccountInfo
: IndividualClanAccountInfo[],
>(region: Region, ids: Ids, extra: string[] = []) {
const object = await fetchBlitz<ClanAccountInfo>(
region,
'clans/accountinfo',
{
account_id: normalizeIds(ids),
extra: extra.length === 0 ? undefined : extra.join(','),
},
);
if (typeof ids === 'number') {
return object[ids as number] as ReturnType;
} else {
return ids.map((id) => object[id]) as ReturnType;
}
}
-51
View File
@@ -1,51 +0,0 @@
import { Region } from '../../constants/regions';
import fetchBlitz from './fetchBlitz';
import { normalizeIds } from './normalizeIds';
interface IndividualClanInfo {
recruiting_options: {
vehicles_level: number;
wins_ratio: number;
average_battles_per_day: number;
battles: number;
average_damage: number;
};
members_count: number;
name: string;
creator_name: string;
clan_id: number;
created_at: number;
updated_at: number;
leader_name: string;
members_ids: number[];
recruiting_policy: string;
tag: string;
is_clan_disbanded: boolean;
old_name: string;
emblem_set_id: number;
creator_id: number;
motto: string;
renamed_at: number;
old_tag: null;
leader_id: number;
description: string;
}
interface ClanInfo {
[clanId: number]: IndividualClanInfo;
}
export async function getClanInfo<
Ids extends number | number[],
ReturnType = Ids extends number ? IndividualClanInfo : IndividualClanInfo[],
>(region: Region, ids: Ids) {
const object = await fetchBlitz<ClanInfo>(region, 'clans/info', {
clan_id: normalizeIds(ids),
});
if (typeof ids === 'number') {
return object[ids as number] as ReturnType;
} else {
return ids.map((id) => object[id]) as ReturnType;
}
}
-5
View File
@@ -1,5 +0,0 @@
import { LEAGUES } from '../../constants/leagues';
export function getLeagueFromScore(score: number) {
return LEAGUES.find(({ minScore }) => score >= minScore)!;
}
-25
View File
@@ -1,25 +0,0 @@
import { RatingInfo } from '../../commands/ratingLeaderboard';
import { Region } from '../../constants/regions';
import { context } from '../blitzkit/context';
import { patientFetch } from '../blitzkit/patientFetch';
import regionToRegionSubdomain from './regionToRegionSubdomain';
const cache: Partial<Record<Region, RatingInfo>> = {};
export default async function getRatingInfo(region: Region) {
if (cache[region]) return cache[region]!;
const response = await patientFetch(
context === 'website'
? `/api/${region}/rating/current/info`
: `https://${regionToRegionSubdomain(
region,
)}.wotblitz.com/en/api/rating-leaderboards/season/`,
undefined,
);
const data = (await response.json()) as RatingInfo;
cache[region] = data;
return data;
}
-28
View File
@@ -1,28 +0,0 @@
import { LeagueTop, RatingPlayer } from '../../commands/ratingLeaderboard';
import { Region } from '../../constants/regions';
import { context } from '../blitzkit/context';
import { patientFetch } from '../blitzkit/patientFetch';
import regionToRegionSubdomain from './regionToRegionSubdomain';
export async function getRatingLeague(
region: Region,
league: number,
usePatientFetcher = false,
) {
const fetcher = (url: string) =>
usePatientFetcher
? patientFetch(url, undefined, { cache: 'no-store' })
: fetch(url, { cache: 'no-store' });
const response = await fetcher(
context === 'website'
? `/api/${region}/rating/current/league/${league}`
: `https://${regionToRegionSubdomain(
region,
)}.wotblitz.com/en/api/rating-leaderboards/league/${league}/top/`,
);
const json = await response.json();
return context === 'website'
? (json as RatingPlayer[])
: (json as LeagueTop).result;
}
-27
View File
@@ -1,27 +0,0 @@
import { RatingNeighbors } from '../../commands/ratingLeaderboard';
import { Region } from '../../constants/regions';
import { patientFetch } from '../blitzkit/patientFetch';
import { withCORSProxy } from '../blitzkit/withCORSProxy';
import regionToRegionSubdomain from './regionToRegionSubdomain';
export async function getRatingNeighbors(
region: Region,
id: number,
count: number,
usePatientFetcher = false,
) {
const fetcher = (url: string) =>
usePatientFetcher
? patientFetch(url, undefined, { cache: 'no-store' })
: fetch(url, { cache: 'no-store' });
const response = await fetcher(
withCORSProxy(
`https://${regionToRegionSubdomain(
region,
)}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${count}`,
),
);
const json = (await response.json()) as RatingNeighbors;
return json;
}
-16
View File
@@ -1,16 +0,0 @@
import { Region } from '../../constants/regions';
import { TanksStats } from '../../types/tanksStats';
import fetchBlitz, { FetchBlitzParams } from './fetchBlitz';
export default async function getTankStats(
region: Region,
id: number,
params?: FetchBlitzParams,
) {
const tankStats = await fetchBlitz<TanksStats>(region, 'tanks/stats', {
account_id: id,
...params,
});
return tankStats[id];
}
-7
View File
@@ -1,7 +0,0 @@
import { ShellDefinition, ShellType } from '../blitzkit/tankDefinitions';
export function isExplosive(
type: ShellDefinition['type'],
): type is ShellType.HEAT | ShellType.HE {
return type === ShellType.HEAT || type === ShellType.HE;
}
-6
View File
@@ -1,6 +0,0 @@
import getRatingInfo from './getRatingInfo';
export async function isOnGoingRatingSeason() {
const ratingInfo = await getRatingInfo('com');
return ratingInfo.detail === undefined;
}
-32
View File
@@ -1,32 +0,0 @@
import { Region } from '../../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import { idToRegion } from './idToRegion';
export async function isValidBlitzId(id: number, token?: string) {
let region: Region;
try {
region = idToRegion(id);
} catch (error) {
return false;
}
const json = await fetch(
`https://api.wotblitz.${region}/wotb/account/info/?account_id=${
id
}&application_id=${WARGAMING_APPLICATION_ID}${
token === undefined ? '' : `&access_token=${token}`
}&fields=account_id%2C-account_id`,
).then(
(response) =>
response.json() as Promise<
| {
status: 'ok';
data: Record<number, {} | null>;
}
| { status: 'error' }
>,
);
return json.status === 'ok' && json.data[id] !== null;
}
-3
View File
@@ -1,3 +0,0 @@
export function normalizeExtra(extra?: string[]) {
return extra ? `&extra=${extra.join(',')}` : '';
}
-3
View File
@@ -1,3 +0,0 @@
export function normalizeIds(ids: number | number[]): string {
return typeof ids === 'number' ? ids.toString() : ids.join(',');
}
-3
View File
@@ -1,3 +0,0 @@
export function normalizeLeagueIcon(url: string) {
return url.startsWith('http') ? url : `https:${url}`;
}
-6
View File
@@ -1,6 +0,0 @@
import { readDVPLFile } from './readDVPLFile';
export async function readBase64DVPL(file: string) {
const buffer = await readDVPLFile(file);
return buffer.toString('base64');
}
-60
View File
@@ -1,60 +0,0 @@
import { crc32 } from 'crc';
import { decompressBlock } from 'lz4js';
/**
* Thanks Maddoxkkm! Modified heavily to be modern.
* https://github.com/Maddoxkkm/dvpl_converter/
*/
export function readDVPL(buffer: Buffer) {
const footerBuffer = buffer.subarray(buffer.length - 20, buffer.length);
if (
footerBuffer.toString('utf8', 16, 20) !== 'DVPL' ||
footerBuffer.length !== 20
) {
throw new SyntaxError('Invalid DVPL footer');
}
const footerData = {
oSize: footerBuffer.readUInt32LE(0),
cSize: footerBuffer.readUInt32LE(4),
crc32: footerBuffer.readUInt32LE(8),
type: footerBuffer.readUInt32LE(12),
};
const targetBlock = buffer.subarray(0, buffer.length - 20);
if (targetBlock.length !== footerData.cSize) {
throw new RangeError('DVPL size mismatch');
}
if (crc32(targetBlock) !== footerData.crc32) {
throw new TypeError('DVPL CRC32 mismatch');
}
if (footerData.type === 0) {
if (!(footerData.oSize === footerData.cSize && footerData.type === 0)) {
throw new RangeError('DVPL type and size mismatch');
}
return targetBlock;
} else if (footerData.type === 1 || footerData.type === 2) {
const source = new Uint8Array(targetBlock);
const destination = new Uint8Array(footerData.oSize);
const decompressedSize = decompressBlock(
source,
destination,
0,
source.length,
0,
);
if (decompressedSize !== footerData.oSize) {
throw new RangeError(
`Decompressed DVPL size mismatch (${decompressedSize} vs ${footerData.oSize})`,
);
}
return Buffer.from(destination);
} else {
throw new SyntaxError('Unknown DVPL format');
}
}
-6
View File
@@ -1,6 +0,0 @@
import { readFile } from 'fs/promises';
import { readDVPL } from './readDVPL';
export async function readDVPLFile(file: string) {
return readDVPL(await readFile(file));
}
-6
View File
@@ -1,6 +0,0 @@
import { readDVPLFile } from './readDVPLFile';
export async function readStringDVPL(file: string) {
const buffer = await readDVPLFile(file);
return buffer.toString();
}
-9
View File
@@ -1,9 +0,0 @@
import { XMLParser } from 'fast-xml-parser';
import { readStringDVPL } from './readStringDVPL';
export async function readXMLDVPL<Type>(file: string) {
const parser = new XMLParser();
const xml = await readStringDVPL(file);
return parser.parse(xml) as Type;
}
-7
View File
@@ -1,7 +0,0 @@
import { readFile } from 'fs/promises';
import { parse } from 'yaml';
export async function readYAML<Type>(file: string) {
const yaml = (await readFile(file)).toString();
return parse(yaml) as Type;
}
-8
View File
@@ -1,8 +0,0 @@
import { parse } from 'yaml';
import { readStringDVPL } from './readStringDVPL';
export async function readYAMLDVPL<Type>(file: string) {
const yaml = await readStringDVPL(file);
return parse(yaml) as Type;
}
@@ -1,7 +0,0 @@
import { Region, RegionSubdomain } from '../../constants/regions';
export default function regionSubdomainToRegion(
regionDomain: RegionSubdomain,
): Region {
return regionDomain === 'na' ? 'com' : regionDomain;
}
@@ -1,7 +0,0 @@
import { Region, RegionSubdomain } from '../../constants/regions';
export default function regionToRegionSubdomain(
regionDomain: Region,
): RegionSubdomain {
return regionDomain === 'com' ? 'na' : regionDomain;
}
@@ -1,16 +0,0 @@
import { ShellType } from '../blitzkit/tankDefinitions';
export function resolvePenetrationCoefficient(
calibratedShells: boolean,
type: ShellType,
) {
if (!calibratedShells) return 1;
return type === 'ap'
? 1.08
: type === 'ap_cr'
? 1.05
: type === 'hc'
? 1.13
: 1.08;
}
-46
View File
@@ -1,46 +0,0 @@
import { Locale } from 'discord.js';
import { go } from 'fuzzysort';
import markdownEscape from 'markdown-escape';
import { UserError } from '../../hooks/userError';
import { tankDefinitions } from '../blitzkit/tankDefinitions';
import { tankNames } from '../blitzkit/tankDefinitions/tankNames';
import { tankNamesTechTreeOnly } from '../discord/autocompleteTanks';
import { translator } from '../localization/translator';
export default async function resolveTankId(
tank: string | number,
locale: Locale,
techTreeOnly = false,
) {
const { translate } = translator(locale);
const number = typeof tank === 'string' ? Number(tank) : tank;
if (Number.isNaN(number)) {
const searchResult = go(
`${tank}`,
await (techTreeOnly ? tankNamesTechTreeOnly : tankNames),
{
keys: ['searchableName', 'searchableNameDeburr', 'camouflages'],
limit: 1,
},
);
if (searchResult.length === 0) {
throw new UserError(
translate('bot.common.errors.tank_name_not_found', [
markdownEscape(`${tank}`),
]),
);
} else {
return searchResult[0].obj.id;
}
} else {
if ((await tankDefinitions)[number]) {
return number;
} else {
throw new UserError(
translate('bot.common.errors.tank_id_not_found', [`${number}`]),
);
}
}
}
-6
View File
@@ -1,6 +0,0 @@
import { Vector3 } from 'three';
import { I_HAT } from '../../constants/axis';
export function rotateDavaVector(vector: Vector3) {
return vector.applyAxisAngle(I_HAT, Math.PI / 2);
}
@@ -1,54 +0,0 @@
import { Region } from '../../constants/regions';
import { ClanList } from '../../types/clanList';
import fetchBlitz from './fetchBlitz';
export default async function searchClansAcrossRegions(
search: string,
limit = 25,
) {
const trimmed = search.trim();
if (trimmed.length < 3 && trimmed.length > 100) return [];
const normalizedLimit = Math.floor(limit / 3);
// TODO: remove splice once wargaming fixes the functionality of the limit felid
return (
await Promise.all([
fetchBlitz<ClanList>('com', 'clans/list', {
search,
limit: normalizedLimit,
}).then((value) =>
value
.map((account) => ({
...account,
region: 'com' as Region,
}))
.splice(0, normalizedLimit),
),
fetchBlitz<ClanList>('eu', 'clans/list', {
search,
limit: normalizedLimit,
}).then((value) =>
value
.map((account) => ({
...account,
region: 'eu' as Region,
}))
.splice(0, normalizedLimit),
),
fetchBlitz<ClanList>('asia', 'clans/list', {
search,
limit: normalizedLimit,
}).then((value) =>
value
.map((account) => ({
...account,
region: 'asia' as Region,
}))
.splice(0, normalizedLimit),
),
])
)
.filter(Boolean)
.flat();
}
@@ -1,40 +0,0 @@
import { Region } from '../../constants/regions';
import { context } from '../blitzkit/context';
import regionToRegionSubdomain from './regionToRegionSubdomain';
type CurrentRatingPlayerSearch = Record<
number,
{
spa_id: number;
nickname: string;
clan_tag: string;
} & (
| {
skip: true;
}
| {
skip: false;
mmr: number;
season_number: number;
calibrationBattlesLeft: number;
number: number;
updated_at: string;
neighbors: [];
}
)
>;
export async function searchCurrentRatingPlayers(region: Region, name: string) {
const encodedSearch = encodeURIComponent(name);
const response = await fetch(
context === 'website'
? `/api/${region}/rating/current/search/${encodedSearch}`
: `https://${regionToRegionSubdomain(
region,
)}.wotblitz.com/en/api/rating-leaderboards/search/?prefix=${encodedSearch}`,
{ cache: 'no-store' },
);
const accountList = (await response.json()) as CurrentRatingPlayerSearch;
return accountList;
}
@@ -1,2 +0,0 @@
export const usernamePattern = /^[a-zA-Z0-9_]{3,24}$/;
export const usernamePatternWithoutPosition = /[a-zA-Z0-9_]{3,24}/;
@@ -1,64 +0,0 @@
import { Region } from '../../../constants/regions';
import fetchBlitz from '../fetchBlitz';
import { usernamePattern } from './constants';
export interface AccountListItem {
nickname: string;
account_id: number;
}
export type AccountList = AccountListItem[];
export type AccountListWithServer = (AccountListItem & {
region: Region;
})[];
export default async function searchPlayersAcrossRegions(
search: string,
limit = 25,
) {
const trimmedSearch = search.trim();
const normalizedLimit = Math.floor(limit / 3);
if (usernamePattern.test(trimmedSearch)) {
return (
await Promise.all([
fetchBlitz<AccountList>('com', 'account/list', {
search,
limit: normalizedLimit,
}).then(
(value) =>
value &&
value.map((account) => ({
...account,
region: 'com' as Region,
})),
),
fetchBlitz<AccountList>('eu', 'account/list', {
search,
limit: normalizedLimit,
}).then(
(value) =>
value &&
value.map((account) => ({
...account,
region: 'eu' as Region,
})),
),
fetchBlitz<AccountList>('asia', 'account/list', {
search,
limit: normalizedLimit,
}).then(
(value) =>
value &&
value.map((account) => ({
...account,
region: 'asia' as Region,
})),
),
])
)
.filter(Boolean)
.flat() as AccountListWithServer;
} else {
return [] as AccountListWithServer;
}
}
-5
View File
@@ -1,5 +0,0 @@
import { NATION_IDS } from '../../constants/nations';
export function toUniqueId(nation: string, id: number) {
return (id << 8) + (NATION_IDS[nation] << 4) + 1;
}
-6
View File
@@ -1,6 +0,0 @@
import { Vector3 } from 'three';
import { I_HAT } from '../../constants/axis';
export function unrotateDavaVector(vector: Vector3) {
return vector.applyAxisAngle(I_HAT, -Math.PI / 2);
}
-64
View File
@@ -1,64 +0,0 @@
import { crc32 } from 'crc';
import { compressBlock } from 'lz4js';
/**
* Thanks Maddoxkkm! Modernized heavily.
*
* https://github.com/Maddoxkkm/dvpl_converter/
*/
export function writeDVPL(buffer: Buffer) {
const source = new Uint8Array(buffer);
const destination = new Uint8Array(buffer.length);
const compressedBlockSize = compressBlock(
source,
destination,
0,
source.length,
0,
);
let output = Buffer.from(destination);
if (compressedBlockSize === 0 || compressedBlockSize >= buffer.length) {
const footerBuffer = toDVPLFooter(
buffer.length,
buffer.length,
crc32(buffer),
0,
);
return Buffer.concat(
[new Uint8Array(buffer), new Uint8Array(footerBuffer)],
buffer.length + 20,
);
} else {
output = output.subarray(0, compressedBlockSize);
const footerBuffer = toDVPLFooter(
buffer.length,
compressedBlockSize,
crc32(output),
2,
);
return Buffer.concat(
[new Uint8Array(output), new Uint8Array(footerBuffer)],
compressedBlockSize + 20,
);
}
}
function toDVPLFooter(
inputSize: number,
compressedSize: number,
crc32: number,
type: number,
) {
const result = Buffer.alloc(20);
result.writeUInt32LE(inputSize, 0);
result.writeUInt32LE(compressedSize, 4);
result.writeUInt32LE(crc32, 8);
result.writeUInt32LE(type, 12);
result.write('DVPL', 16, 4, 'utf8');
return result;
}
-13
View File
@@ -1,13 +0,0 @@
import { checkConsumableProvisionInclusivity } from './checkConsumableProvisionInclusivity';
import { ProvisionDefinitions } from './provisionDefinitions';
import { GunDefinition, TankDefinition } from './tankDefinitions';
export function availableProvisions(
tank: TankDefinition,
gun: GunDefinition,
provisionDefinitions: ProvisionDefinitions,
) {
return Object.values(provisionDefinitions).filter((provision) =>
checkConsumableProvisionInclusivity(provision, tank, gun),
);
}
@@ -1,22 +0,0 @@
import { Samples } from '.';
import { emptyAllStats } from '../../blitzstars/getStatsInPeriod/constants';
import { BlitzkitStats } from '../../statistics/compositeStats/constants';
export const emptyAverageDefinitionsAllStats: BlitzkitStats = {
...emptyAllStats,
battle_life_time: 0,
};
export const averageDefinitionsAllStatsKeys = Object.keys(
emptyAverageDefinitionsAllStats,
) as (keyof BlitzkitStats)[];
export const emptySamples: Samples = {
d_1: 0,
d_7: 0,
d_30: 0,
d_60: 0,
d_90: 0,
d_120: 0,
total: 0,
};
@@ -1,71 +0,0 @@
import { decode } from '../../protobuf/decode';
import { BlitzkitStats } from '../../statistics/compositeStats/constants';
import { asset } from '../asset';
export interface AverageDefinitionsEntry {
mu: BlitzkitStats;
sigma: BlitzkitStats;
r: BlitzkitStats;
samples: Samples;
}
export interface AverageDefinitionsEntrySubPartial {
mu: Partial<BlitzkitStats>;
sigma: Partial<BlitzkitStats>;
r: Partial<BlitzkitStats>;
samples: Samples;
}
export interface AverageDefinitionsEntryWithId extends AverageDefinitionsEntry {
id: number;
}
export interface AverageDefinitions {
averages: Record<number, AverageDefinitionsEntry>;
samples: Samples;
time: number;
}
export interface Samples {
d_1: number;
d_7: number;
d_30: number;
d_60: number;
d_90: number;
d_120: number;
total: number;
}
export interface AverageDefinitionsManifest {
version: 1;
/**
* epoch in days
*/
latest: number;
}
export const averageDefinitions = fetch(asset('averages/manifest.json'), {
cache: 'no-store',
})
.then((response) => response.json() as Promise<AverageDefinitionsManifest>)
.then((manifest) => fetch(asset(`averages/${manifest.latest}.pb`)))
.then((response) => response.arrayBuffer())
.then((buffer) =>
decode<AverageDefinitions>(
'average_definitions',
'blitzkit.AverageDefinitions',
new Uint8Array(buffer),
),
);
export const averageDefinitionsArray = averageDefinitions.then((data) =>
Object.entries(data.averages)
.map(
([key, value]) =>
({
id: Number(key),
...(value as AverageDefinitionsEntry),
}) satisfies AverageDefinitionsEntryWithId,
)
.filter((tank) => tank.mu.battles > 0),
);
-20
View File
@@ -1,20 +0,0 @@
import { resolveAncestry } from './resolveAncestry';
import { tankDefinitions } from './tankDefinitions';
export async function buildTechTreeLine(start: number, end: number) {
const awaitedTankDefinitions = await tankDefinitions;
const endTank = awaitedTankDefinitions[end];
if (!endTank.ancestors) throw new Error('End tank has no ancestors');
const line = [end];
if (start === end) return line;
for (const ancestorId of endTank.ancestors) {
if ((await resolveAncestry(ancestorId)).includes(start)) {
const ancestorTree = await buildTechTreeLine(start, ancestorId);
line.push(...ancestorTree);
break;
}
}
return line;
}
@@ -1,56 +0,0 @@
import { ConsumableEntry } from './consumableDefinitions';
import { ProvisionEntry } from './provisionDefinitions';
import { GunDefinition, TankDefinition } from './tankDefinitions';
export function checkConsumableProvisionInclusivity(
consumableProvision: ConsumableEntry | ProvisionEntry,
tank: TankDefinition,
gun: GunDefinition,
) {
const included =
!consumableProvision.gameMode &&
consumableProvision.include?.every((rule) => {
switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
case 'ids':
return rule.ids.includes(tank.id);
case 'nation':
return rule.nations.includes(tank.nation);
case 'category':
throw new SyntaxError('Category filtering found in include rule');
}
});
const excluded =
consumableProvision.gameMode ||
consumableProvision.exclude?.some((rule) => {
switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
case 'ids':
return rule.ids.includes(tank.id);
case 'nation':
return rule.nations.includes(tank.nation);
case 'category':
return rule.categories.some((category) => {
switch (category) {
case 'clip':
return gun.type !== 'regular';
}
});
}
});
return (
!consumableProvision.gameMode &&
consumableProvision.include?.length > 0 &&
included &&
!excluded
);
}
-3
View File
@@ -1,3 +0,0 @@
export function coefficient(...effects: [boolean, number][]) {
return effects.reduce((accumulator, b) => accumulator + (b[0] ? b[1] : 0), 1);
}
-17
View File
@@ -1,17 +0,0 @@
import { assertSecret } from '../../../packages/core/src/blitzkit/assertSecret';
import { ASSETS_REPO } from '../../constants/assets';
import commitMultipleFiles, { FileChange } from './commitMultipleFiles';
export async function commitAssets(message: string, changes: FileChange[]) {
console.log(`Committing ${message}...`);
if (changes.length === 0) return;
await commitMultipleFiles(
'tresabhi',
ASSETS_REPO,
assertSecret(process.env.NEXT_PUBLIC_ASSET_BRANCH),
`${message} - ${new Date().toDateString()}`,
changes,
);
}
-63
View File
@@ -1,63 +0,0 @@
import ProgressBar from 'progress';
import { GithubChangeBlob, createBlob } from './createBlob';
import { octokit } from './octokit';
export interface FileChange {
path: string;
content: string;
encoding: 'utf-8' | 'base64';
}
export default async function commitMultipleFiles(
owner: string,
repo: string,
branch: string,
message: string,
changes: FileChange[],
) {
const latestCommitSha = (
await octokit.git.getRef({
owner,
repo,
ref: `heads/${branch}`,
})
).data.object.sha;
const treeSha = (
await octokit.git.getCommit({
owner,
repo,
commit_sha: latestCommitSha,
})
).data.tree.sha;
const blobs: GithubChangeBlob[] = [];
const bar = new ProgressBar('Blobs :bar', changes.length);
for (const changeIndexString in changes) {
const changeIndex = parseInt(changeIndexString);
const change = changes[changeIndex];
const createdBlob = await createBlob(owner, repo, change);
blobs.push(createdBlob!);
bar.tick();
}
const { data: treeData } = await octokit.git.createTree({
owner,
repo,
base_tree: treeSha,
tree: blobs,
});
const { data: newCommitData } = await octokit.git.createCommit({
owner,
repo,
message,
tree: treeData.sha,
parents: [latestCommitSha],
});
await octokit.git.updateRef({
owner,
repo,
ref: `heads/${branch}`,
sha: newCommitData.sha,
force: true,
});
}
@@ -1,47 +0,0 @@
import { asset } from './asset';
import { fetchCdonLz4 } from './fetchCdonLz4';
export type TankFilterDefinitionCategory = 'clip';
export type TankFilterDefinition =
| {
type: 'tier';
min: number;
max: number;
}
| {
type: 'ids';
ids: number[];
}
| {
type: 'category';
categories: TankFilterDefinitionCategory[];
}
| {
type: 'nation';
nations: string[];
};
export type ConsumableEntry = {
id: number;
name: string;
cooldown: number;
duration?: number;
} & (
| {
gameMode: false;
include: TankFilterDefinition[];
exclude?: TankFilterDefinition[];
}
| {
gameMode: true;
}
);
export interface ConsumableDefinitions {
[key: string]: ConsumableEntry;
}
export const consumableDefinitions = fetchCdonLz4<ConsumableDefinitions>(
asset('definitions/consumables.cdon.lz4'),
);
-4
View File
@@ -1,4 +0,0 @@
type Context = 'website' | 'server';
export const context: Context =
typeof window === 'undefined' ? 'server' : 'website';
-46
View File
@@ -1,46 +0,0 @@
import { throttle } from 'lodash';
import { FileChange } from './commitMultipleFiles';
import { octokit } from './octokit';
export interface GithubChangeBlob {
sha: string;
path: string;
mode: '100644';
type: 'blob';
}
const TIME_PER_BLOB = 2 ** 4 * 1000;
// github rate limit: 5000 requests per hour
export const createBlob = throttle(
async (owner: string, repo: string, change: FileChange) => {
while (true) {
try {
const { sha } = (
await octokit.git.createBlob({
owner,
repo,
content: change.content,
encoding: change.encoding,
})
).data;
return {
sha,
path: change.path,
mode: '100644',
type: 'blob',
} satisfies GithubChangeBlob;
} catch (error) {
console.warn(
`Failed blob ${change.path}; retrying in ${TIME_PER_BLOB}ms...`,
// error,
);
await new Promise((resolve) => setTimeout(resolve, TIME_PER_BLOB));
}
}
},
(5000 / (60 * 60 * 1000)) * 0.9,
{ trailing: true },
);
-14
View File
@@ -1,14 +0,0 @@
import { permanentSkills } from '../../app/tools/tankopedia/[id]/components/Characteristics/components/Skills/constants';
import { SkillDefinitions } from './skillDefinitions';
export function createDefaultSkills(skillDefinitions: SkillDefinitions) {
const skills: Record<string, number> = {};
Object.values(skillDefinitions.classes).forEach((tankSkills) => {
tankSkills.forEach((skill) => {
skills[skill] = permanentSkills.includes(skill) ? 7 : 0;
});
});
return skills;
}
Loaded 100 of 305 files, more files were not shown because too many files have changed in this diff. Show more