diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7ef122024..b395babe4 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -54,6 +54,7 @@
"glsl",
"GLTF",
"haha",
+ "highp",
"iife",
"immer",
"Inclusivity",
@@ -145,5 +146,6 @@
"zustand"
],
"deno.enable": false,
- "deno.unstable": false
+ "deno.unstable": false,
+ "cSpell.enableFiletypes": ["glsl"]
}
diff --git a/docs/changelogs/1.16.1.md b/docs/changelogs/1.16.1.md
new file mode 100644
index 000000000..ed1de5eca
--- /dev/null
+++ b/docs/changelogs/1.16.1.md
@@ -0,0 +1,28 @@
+# Blitzkrieg 1.16.1
+
+Armor sandbox gains 25% rendering speed and 40% memory efficiency with added accuracy and features.
+
+## Features
+
+- Multiple layers of spaced armors are now calculated properly by the visual armor highlighting
+- Added opaque mode to view armor highlighting without any transparency
+- Added breakdown on layers of armor penetrated
+ - Penetration of each layer is shown represented by a color and an index
+ - Green: penetration
+ - Red: blocked
+ - Yellow: ricochet
+ - This also shows layers encountered after a ricochet
+
+## Fixes
+
+- Accuracy now doesn't drop the further the shot is placed from the origin
+- HE shells no longer loose penetration like how HEAT shells do when traveling between layers of armor
+- Persistent settings are now merged in the correct order making them truly persistent now
+- Enhanced armor and calibrated shells are now properly synced from the get go
+
+## Technical Changes
+
+- Spaced armor and external module scenes have been merged
+ - They now use additive blending
+ - Colors are now normalized by the penetration value instead of maximum armor plate thickness
+- Shell normalization now doesn't violate arccosine's domain
diff --git a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx
index a8a087792..3e077f02b 100644
--- a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx
+++ b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx
@@ -4,7 +4,8 @@ import { PerspectiveCamera } from '@react-three/drei';
import { Canvas } from '@react-three/fiber';
import { Suspense, use, useEffect, useRef, useState } from 'react';
import { isMobile } from 'react-device-detect';
-import { ShotDisplay } from '../../../../../../components/ShotDisplay';
+import { Armor } from '../../../../../../components/Armor';
+import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTransform';
@@ -24,9 +25,6 @@ import { RotationInputs } from '../QuickInputs';
import { SceneProps } from '../SceneProps';
import { ModelLoader } from './components/ModelLoader';
import { Options } from './components/Options';
-import { TankArmor } from './components/TankArmor';
-import { ExternalModuleMask } from './components/TankArmor/components/ExternalModuleMask';
-import { SpacedArmorDepth } from './components/TankArmor/components/SpacedArmorDepth';
import { TankModel } from './components/TankModel';
export function TankSandbox() {
@@ -198,12 +196,11 @@ export function TankSandbox() {
}>
-
+ {/* TODO: add rotation cube to the top right */}
-
-
-
+
+
diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/Options.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/Options.tsx
index 9896894d8..e3ac0d6c9 100644
--- a/src/app/tools/tankopedia/[id]/components/Model/components/Options.tsx
+++ b/src/app/tools/tankopedia/[id]/components/Model/components/Options.tsx
@@ -30,6 +30,7 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
const wireframe = useTankopediaPersistent(
(state) => state.model.visual.wireframe,
);
+ const opaque = useTankopediaPersistent((state) => state.model.visual.opaque);
const fullScreenAvailable =
typeof document !== 'undefined' && document.fullscreenEnabled;
const environment = useTankopediaPersistent(
@@ -78,6 +79,8 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
+ Armor
+
{
@@ -89,6 +92,30 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
Green penetration
+ {
+ mutateTankopediaPersistent((draft) => {
+ draft.model.visual.wireframe = checked;
+ });
+ }}
+ >
+ Wireframe
+
+
+ {
+ mutateTankopediaPersistent((draft) => {
+ draft.model.visual.opaque = checked;
+ });
+ }}
+ >
+ Opaque
+
+
+ Environment
+
{
@@ -132,17 +159,6 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
-
- {
- mutateTankopediaPersistent((draft) => {
- draft.model.visual.wireframe = checked;
- });
- }}
- >
- Wireframe
-
)}
diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ExternalModuleMask.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ExternalModuleMask.tsx
deleted file mode 100644
index 66858e5bd..000000000
--- a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ExternalModuleMask.tsx
+++ /dev/null
@@ -1,286 +0,0 @@
-import { memo, useEffect, useRef } from 'react';
-import { Euler, Group, Vector3 } from 'three';
-import { degToRad } from 'three/src/math/MathUtils';
-import { ArmorMeshExternalModuleMask } from '../../../../../../../../../components/ArmorMesh';
-import { I_HAT, J_HAT, K_HAT } from '../../../../../../../../../constants/axis';
-import {
- ModelTransformEventData,
- modelTransformEvent,
-} from '../../../../../../../../../core/blitzkrieg/modelTransform';
-import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
-import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
-import { useArmor } from '../../../../../../../../../hooks/useArmor';
-import { useModel } from '../../../../../../../../../hooks/useModel';
-import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions';
-import { useDuel } from '../../../../../../../../../stores/duel';
-import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia';
-
-interface ExternalModuleMaskProps {
- ornamental?: boolean;
-}
-
-/**
- * When rendered, generates a mask and thickness buffer for external modules.
- * - R: 1 means external module
- * - G: normalized thickness; multiply with max thickness to get nominal thickness
- * - B: no data
- * - A: 1 means is armor; 0 is background
- */
-export const ExternalModuleMask = memo(
- ({ ornamental = false }) => {
- const protagonist = useDuel((state) => state.protagonist!);
- const wrapper = useRef(null);
- const modelDefinitions = useModelDefinitions();
- const turretContainer = useRef(null);
- const gunContainer = useRef(null);
- const initialTankopediaState = useTankopediaPersistent.getState();
-
- useEffect(() => {
- const hullOrigin = new Vector3(
- trackModelDefinition.origin[0],
- trackModelDefinition.origin[1],
- -trackModelDefinition.origin[2],
- ).applyAxisAngle(I_HAT, Math.PI / 2);
- const turretOrigin = new Vector3(
- tankModelDefinition.turretOrigin[0],
- tankModelDefinition.turretOrigin[1],
- -tankModelDefinition.turretOrigin[2],
- ).applyAxisAngle(I_HAT, Math.PI / 2);
- const gunOrigin = new Vector3(
- turretModelDefinition.gunOrigin[0],
- turretModelDefinition.gunOrigin[1],
- -turretModelDefinition.gunOrigin[2],
- ).applyAxisAngle(I_HAT, Math.PI / 2);
- const turretPosition = new Vector3();
- const turretRotation = new Euler();
- const gunPosition = new Vector3();
- const gunRotation = new Euler();
-
- function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
- gunPosition
- .set(0, 0, 0)
- .sub(hullOrigin)
- .sub(turretOrigin)
- .sub(gunOrigin)
- .applyAxisAngle(I_HAT, pitch)
- .add(gunOrigin)
- .add(turretOrigin)
- .add(hullOrigin);
- gunRotation.set(pitch, 0, 0);
- gunContainer.current?.position.copy(gunPosition);
- gunContainer.current?.rotation.copy(gunRotation);
-
- if (yaw === undefined) return;
-
- turretPosition
- .set(0, 0, 0)
- .sub(turretOrigin)
- .sub(hullOrigin)
- .applyAxisAngle(new Vector3(0, 0, 1), yaw);
- turretRotation.set(0, 0, yaw);
-
- if (tankModelDefinition.turretRotation) {
- const initialPitch = -degToRad(
- tankModelDefinition.turretRotation.pitch,
- );
- const initialYaw = -degToRad(tankModelDefinition.turretRotation.yaw);
- const initialRoll = -degToRad(
- tankModelDefinition.turretRotation.roll,
- );
-
- turretPosition
- .applyAxisAngle(I_HAT, initialPitch)
- .applyAxisAngle(J_HAT, initialRoll)
- .applyAxisAngle(K_HAT, initialYaw);
- turretRotation.x += initialPitch;
- turretRotation.y += initialRoll;
- turretRotation.z += initialYaw;
- }
-
- turretPosition.add(turretOrigin).add(hullOrigin);
- turretContainer.current?.position.copy(turretPosition);
- turretContainer.current?.rotation.copy(turretRotation);
- }
-
- handleModelTransform(protagonist);
- modelTransformEvent.on(handleModelTransform);
-
- return () => {
- modelTransformEvent.off(handleModelTransform);
- };
- });
-
- useEffect(() => {
- const unsubscribe = useTankopediaPersistent.subscribe(
- (state) => state.mode,
- (mode) => {
- if (wrapper.current) wrapper.current.visible = mode === 'armor';
- },
- );
-
- return unsubscribe;
- });
-
- const armorGltf = useArmor(protagonist.tank.id);
- const { gltf: modelGltf } = useModel(protagonist.tank.id);
-
- const armorNodes = Object.values(armorGltf.nodes);
- const modelNodes = Object.values(modelGltf.nodes);
- const tankModelDefinition = modelDefinitions[protagonist.tank.id];
- const trackModelDefinition =
- tankModelDefinition.tracks[protagonist.track.id];
- const turretModelDefinition =
- tankModelDefinition.turrets[protagonist.turret.id];
- const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
- const maxThickness = Math.max(
- trackModelDefinition.thickness,
- gunModelDefinition.thickness,
- ...armorNodes
- .map((node) => {
- const armorId = nameToArmorId(node.name);
- return resolveArmor(tankModelDefinition.armor, armorId).thickness;
- })
- .filter(Boolean),
- );
- const hullOrigin = new Vector3(
- trackModelDefinition.origin[0],
- trackModelDefinition.origin[1],
- -trackModelDefinition.origin[2],
- ).applyAxisAngle(I_HAT, Math.PI / 2);
- const turretOrigin = new Vector3(
- tankModelDefinition.turretOrigin[0],
- tankModelDefinition.turretOrigin[1],
- -tankModelDefinition.turretOrigin[2],
- ).applyAxisAngle(I_HAT, Math.PI / 2);
- const gunOrigin = new Vector3(
- turretModelDefinition.gunOrigin[0],
- turretModelDefinition.gunOrigin[1],
- -turretModelDefinition.gunOrigin[2],
- ).applyAxisAngle(I_HAT, Math.PI / 2);
-
- return (
-
-
- {armorNodes.map((node) => {
- const isHull = node.name.startsWith('hull_');
- const isVisible = isHull;
- const armorId = nameToArmorId(node.name);
- const { thickness, spaced } = resolveArmor(
- tankModelDefinition.armor,
- armorId,
- );
-
- if (!isVisible || thickness === undefined || spaced) return null;
-
- return (
-
- );
- })}
-
-
- {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 (
-
- );
- })}
-
-
-
- {armorNodes.map((node) => {
- const isCurrentTurret = node.name.startsWith(
- `turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
- );
- const isVisible = isCurrentTurret;
- const armorId = nameToArmorId(node.name);
- const { thickness, spaced } = resolveArmor(
- turretModelDefinition.armor,
- armorId,
- );
-
- if (!isVisible || thickness === undefined || spaced) return null;
-
- return (
-
-
-
- );
- })}
-
-
-
- {armorNodes.map((node) => {
- const isCurrentGun = node.name.startsWith(
- `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
- );
- const isVisible = isCurrentGun;
- const armorId = nameToArmorId(node.name);
- const { thickness, spaced } = resolveArmor(
- gunModelDefinition.armor,
- armorId,
- );
-
- if (!isVisible || thickness === undefined || spaced) return null;
-
- return (
-
-
-
- );
- })}
-
- {modelNodes.map((node) => {
- const isCurrentGun =
- node.name ===
- `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
- const isVisible = isCurrentGun;
-
- if (!isVisible) return null;
-
- return (
-
- );
- })}
-
-
-
- );
- },
-);
diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/SpacedArmorDepth.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/SpacedArmorDepth.tsx
deleted file mode 100644
index a856743ae..000000000
--- a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/SpacedArmorDepth.tsx
+++ /dev/null
@@ -1,299 +0,0 @@
-import { memo, useEffect, useRef } from 'react';
-import { Euler, Group, Vector3 } from 'three';
-import { degToRad } from 'three/src/math/MathUtils';
-import { ArmorMeshSpacedArmorDepth } from '../../../../../../../../../components/ArmorMesh/components/SpacedArmorDepth';
-import { I_HAT, J_HAT, K_HAT } from '../../../../../../../../../constants/axis';
-import {
- ModelTransformEventData,
- modelTransformEvent,
-} from '../../../../../../../../../core/blitzkrieg/modelTransform';
-import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
-import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
-import { useArmor } from '../../../../../../../../../hooks/useArmor';
-import { useModel } from '../../../../../../../../../hooks/useModel';
-import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions';
-import { useDuel } from '../../../../../../../../../stores/duel';
-import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia';
-
-interface SpacedArmorDepthProps {
- ornamental?: boolean;
-}
-
-/**
- * When rendered, this component provides the depth buffer for the armor which
- * when compared to the depth of the core armor will give you the distance
- * between the first point of contact and the core armor. The color channels
- * hold information about the thickness of the spaced armor (external modules
- * not included).
- * - R: angle of the armor where 0 is 0 and 1 is 90
- * - G: normalized thickness; multiply with max thickness to get nominal thickness
- * - B: no data
- * - A: 1 means is armor; 0 is background
- */
-export const SpacedArmorDepth = memo(
- ({ ornamental = false }) => {
- const protagonist = useDuel((state) => state.protagonist!);
- const wrapper = useRef(null);
- const modelDefinitions = useModelDefinitions();
- const turretContainer = useRef(null);
- const gunContainer = useRef(null);
- const initialTankopediaState = useTankopediaPersistent.getState();
-
- useEffect(() => {
- if (!modelDefinitions) return;
-
- const hullOrigin = new Vector3(
- trackModelDefinition.origin[0],
- trackModelDefinition.origin[1],
- -trackModelDefinition.origin[2],
- ).applyAxisAngle(I_HAT, Math.PI / 2);
- const turretOrigin = new Vector3(
- tankModelDefinition.turretOrigin[0],
- tankModelDefinition.turretOrigin[1],
- -tankModelDefinition.turretOrigin[2],
- ).applyAxisAngle(I_HAT, Math.PI / 2);
- const gunOrigin = new Vector3(
- turretModelDefinition.gunOrigin[0],
- turretModelDefinition.gunOrigin[1],
- -turretModelDefinition.gunOrigin[2],
- ).applyAxisAngle(I_HAT, Math.PI / 2);
- const turretPosition = new Vector3();
- const turretRotation = new Euler();
- const gunPosition = new Vector3();
- const gunRotation = new Euler();
-
- function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
- gunPosition
- .set(0, 0, 0)
- .sub(hullOrigin)
- .sub(turretOrigin)
- .sub(gunOrigin)
- .applyAxisAngle(I_HAT, pitch)
- .add(gunOrigin)
- .add(turretOrigin)
- .add(hullOrigin);
- gunRotation.set(pitch, 0, 0);
- gunContainer.current?.position.copy(gunPosition);
- gunContainer.current?.rotation.copy(gunRotation);
-
- if (yaw === undefined) return;
-
- turretPosition
- .set(0, 0, 0)
- .sub(hullOrigin)
- .sub(turretOrigin)
- .applyAxisAngle(new Vector3(0, 0, 1), yaw);
- turretRotation.set(0, 0, yaw);
-
- if (tankModelDefinition.turretRotation) {
- const initialPitch = -degToRad(
- tankModelDefinition.turretRotation.pitch,
- );
- const initialYaw = -degToRad(tankModelDefinition.turretRotation.yaw);
- const initialRoll = -degToRad(
- tankModelDefinition.turretRotation.roll,
- );
-
- turretPosition
- .applyAxisAngle(I_HAT, initialPitch)
- .applyAxisAngle(J_HAT, initialRoll)
- .applyAxisAngle(K_HAT, initialYaw);
- turretRotation.x += initialPitch;
- turretRotation.y += initialRoll;
- turretRotation.z += initialYaw;
- }
-
- turretPosition.add(turretOrigin).add(hullOrigin);
- turretContainer.current?.position.copy(turretPosition);
- turretContainer.current?.rotation.copy(turretRotation);
- }
-
- handleModelTransform(protagonist);
- modelTransformEvent.on(handleModelTransform);
-
- return () => {
- modelTransformEvent.off(handleModelTransform);
- };
- });
-
- useEffect(() => {
- const unsubscribe = useTankopediaPersistent.subscribe(
- (state) => state.mode,
- (mode) => {
- if (wrapper.current) wrapper.current.visible = mode === 'armor';
- },
- );
-
- return unsubscribe;
- });
-
- const armorGltf = useArmor(protagonist.tank.id);
- const { gltf: modelGltf } = useModel(protagonist.tank.id);
-
- const armorNodes = Object.values(armorGltf.nodes);
- const modelNodes = Object.values(modelGltf.nodes);
- const tankModelDefinition = modelDefinitions[protagonist.tank.id];
- const trackModelDefinition =
- tankModelDefinition.tracks[protagonist.track.id];
- const turretModelDefinition =
- tankModelDefinition.turrets[protagonist.turret.id];
- const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
- const maxThickness = Math.max(
- trackModelDefinition.thickness,
- gunModelDefinition.thickness,
- ...armorNodes
- .map((node) => {
- const armorId = nameToArmorId(node.name);
- return resolveArmor(tankModelDefinition.armor, armorId).thickness;
- })
- .filter(Boolean),
- );
- const hullOrigin = new Vector3(
- trackModelDefinition.origin[0],
- trackModelDefinition.origin[1],
- -trackModelDefinition.origin[2],
- ).applyAxisAngle(I_HAT, Math.PI / 2);
- const turretOrigin = new Vector3(
- tankModelDefinition.turretOrigin[0],
- tankModelDefinition.turretOrigin[1],
- -tankModelDefinition.turretOrigin[2],
- ).applyAxisAngle(I_HAT, Math.PI / 2);
- const gunOrigin = new Vector3(
- turretModelDefinition.gunOrigin[0],
- turretModelDefinition.gunOrigin[1],
- -turretModelDefinition.gunOrigin[2],
- ).applyAxisAngle(I_HAT, Math.PI / 2);
-
- return (
-
-
- {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) return null;
-
- return (
-
- );
- })}
-
- {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 (
-
- );
- })}
-
-
-
- {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) return null;
-
- return (
-
-
-
- );
- })}
-
-
-
- {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) return null;
-
- return (
-
-
-
- );
- })}
-
-
- {modelNodes.map((node) => {
- const isCurrentGun =
- node.name ===
- `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
- const isVisible = isCurrentGun;
-
- if (!isVisible) return null;
-
- return (
-
- );
- })}
-
-
-
- );
- },
-);
diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/index.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/index.tsx
index df4cc78d5..e69de29bb 100644
--- a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/index.tsx
+++ b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/index.tsx
@@ -1,67 +0,0 @@
-import { createPortal, useFrame } from '@react-three/fiber';
-import { memo } from 'react';
-import { DepthTexture, Scene, Vector2 } from 'three';
-import { externalModuleMaskRenderTarget } from '../../../../../../../../components/ArmorMesh';
-import { spacedArmorDepthRenderTarget } from '../../../../../../../../components/ArmorMesh/components/SpacedArmorDepth';
-import { ArmorHighlighting } from './components/ArmorHighlighting';
-import { ExternalModuleMask } from './components/ExternalModuleMask';
-import { SpacedArmorDepth } from './components/SpacedArmorDepth';
-
-export const TankArmor = memo(() => {
- const externalModuleMaskScene = new Scene();
- const spacedArmorDepthScene = new Scene();
- const armorHighlightingScene = new Scene();
- const externalModuleMaskPortal = createPortal(
- ,
- externalModuleMaskScene,
- );
- const spacedArmorDepthPortal = createPortal(
- ,
- spacedArmorDepthScene,
- );
- const armorHighlightingPortal = createPortal(
- ,
- armorHighlightingScene,
- );
- const renderSize = new Vector2();
- const newRenderSize = new Vector2();
-
- useFrame(({ gl, scene, camera }) => {
- const pixelRatio = gl.getPixelRatio();
- gl.autoClear = true;
-
- gl.getSize(newRenderSize).multiplyScalar(pixelRatio);
- if (!newRenderSize.equals(renderSize)) {
- renderSize.copy(newRenderSize);
- spacedArmorDepthRenderTarget.depthTexture = new DepthTexture(
- renderSize.x,
- renderSize.y,
- );
- }
-
- externalModuleMaskRenderTarget.setSize(renderSize.x, renderSize.y);
- gl.setRenderTarget(externalModuleMaskRenderTarget);
- gl.render(externalModuleMaskScene, camera);
-
- gl.clearDepth();
- spacedArmorDepthRenderTarget.setSize(renderSize.x, renderSize.y * 0.5);
- gl.setRenderTarget(spacedArmorDepthRenderTarget);
- gl.render(spacedArmorDepthScene, camera);
-
- gl.autoClear = false;
-
- gl.setRenderTarget(null);
- gl.render(scene, camera);
-
- gl.clearDepth();
- gl.render(armorHighlightingScene, camera);
- }, 1);
-
- return (
- <>
- {externalModuleMaskPortal}
- {spacedArmorDepthPortal}
- {armorHighlightingPortal}
- >
- );
-});
diff --git a/src/app/tools/tankopedia/[id]/page.tsx b/src/app/tools/tankopedia/[id]/page.tsx
index 0251a97c6..2a632f1ae 100644
--- a/src/app/tools/tankopedia/[id]/page.tsx
+++ b/src/app/tools/tankopedia/[id]/page.tsx
@@ -63,6 +63,8 @@ export default function Page({ params }: { params: { id: string } }) {
};
}, [initialId, assigned]);
+
+
return (
{assigned && (
diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ArmorHighlighting.tsx b/src/components/Armor/components/CoreArmorScene.tsx
similarity index 54%
rename from src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ArmorHighlighting.tsx
rename to src/components/Armor/components/CoreArmorScene.tsx
index e547a01fe..b5b133c3d 100644
--- a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ArmorHighlighting.tsx
+++ b/src/components/Armor/components/CoreArmorScene.tsx
@@ -1,28 +1,29 @@
-import { useEffect, useRef } from 'react';
-import { Euler, Group, Mesh, Vector3 } from 'three';
+import { memo, useEffect, useRef } from 'react';
+import { Euler, Group, Vector3 } from 'three';
import { degToRad } from 'three/src/math/MathUtils';
-import { ArmorMesh } from '../../../../../../../../../components/ArmorMesh';
-import { I_HAT, J_HAT, K_HAT } from '../../../../../../../../../constants/axis';
+import { I_HAT, J_HAT, K_HAT } from '../../../constants/axis';
import {
ModelTransformEventData,
modelTransformEvent,
-} from '../../../../../../../../../core/blitzkrieg/modelTransform';
-import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
-import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
-import { useArmor } from '../../../../../../../../../hooks/useArmor';
-import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions';
-import { useDuel } from '../../../../../../../../../stores/duel';
-import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia';
+} from '../../../core/blitzkrieg/modelTransform';
+import { nameToArmorId } from '../../../core/blitzkrieg/nameToArmorId';
+import { resolveArmor } from '../../../core/blitzkrieg/resolveThickness';
+import { useArmor } from '../../../hooks/useArmor';
+import { useModelDefinitions } from '../../../hooks/useModelDefinitions';
+import { useDuel } from '../../../stores/duel';
+import { useTankopediaPersistent } from '../../../stores/tankopedia';
+import { CoreArmorSceneComponent } from './CoreArmorSceneComponent';
-export function ArmorHighlighting() {
- const protagonist = useDuel((state) => state.protagonist!);
+export const CoreArmorScene = memo(() => {
const wrapper = useRef(null);
const modelDefinitions = useModelDefinitions();
- const gunContainer = useRef(null);
const turretContainer = useRef(null);
+ const gunContainer = useRef(null);
const initialTankopediaState = useTankopediaPersistent.getState();
useEffect(() => {
+ if (!modelDefinitions) return;
+
const hullOrigin = new Vector3(
trackModelDefinition.origin[0],
trackModelDefinition.origin[1],
@@ -33,14 +34,17 @@ export function ArmorHighlighting() {
tankModelDefinition.turretOrigin[1],
-tankModelDefinition.turretOrigin[2],
).applyAxisAngle(I_HAT, Math.PI / 2);
+ const gunOrigin = new Vector3(
+ turretModelDefinition.gunOrigin[0],
+ turretModelDefinition.gunOrigin[1],
+ -turretModelDefinition.gunOrigin[2],
+ ).applyAxisAngle(I_HAT, Math.PI / 2);
const turretPosition = new Vector3();
const turretRotation = new Euler();
const gunPosition = new Vector3();
const gunRotation = new Euler();
- function handleModelTransform({ pitch, yaw }: ModelTransformEventData) {
- if (yaw === undefined) return;
-
+ function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
gunPosition
.set(0, 0, 0)
.sub(hullOrigin)
@@ -53,6 +57,9 @@ export function ArmorHighlighting() {
gunRotation.set(pitch, 0, 0);
gunContainer.current?.position.copy(gunPosition);
gunContainer.current?.rotation.copy(gunRotation);
+
+ if (yaw === undefined) return;
+
turretPosition
.set(0, 0, 0)
.sub(hullOrigin)
@@ -80,6 +87,16 @@ export function ArmorHighlighting() {
turretContainer.current?.position.copy(turretPosition);
turretContainer.current?.rotation.copy(turretRotation);
}
+
+ handleModelTransform(useDuel.getState().protagonist!);
+ modelTransformEvent.on(handleModelTransform);
+
+ return () => {
+ modelTransformEvent.off(handleModelTransform);
+ };
+ });
+
+ useEffect(() => {
const unsubscribe = useTankopediaPersistent.subscribe(
(state) => state.mode,
(mode) => {
@@ -87,27 +104,24 @@ export function ArmorHighlighting() {
},
);
- handleModelTransform(protagonist);
- modelTransformEvent.on(handleModelTransform);
-
- return () => {
- modelTransformEvent.off(handleModelTransform);
- unsubscribe();
- };
+ return unsubscribe;
});
- const armorGltf = useArmor(protagonist.tank.id);
+ const tank = useDuel((state) => state.protagonist!.tank);
+ const track = useDuel((state) => state.protagonist!.track);
+ const turret = useDuel((state) => state.protagonist!.turret);
+ const gun = useDuel((state) => state.protagonist!.gun);
+ const armorGltf = useArmor(tank.id);
const armorNodes = Object.values(armorGltf.nodes);
- const tankModelDefinition = modelDefinitions[protagonist.tank.id];
- const trackModelDefinition = tankModelDefinition.tracks[protagonist.track.id];
- const turretModelDefinition =
- tankModelDefinition.turrets[protagonist.turret.id];
- const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
+ const tankModelDefinition = modelDefinitions[tank.id];
+ const trackModelDefinition = tankModelDefinition.tracks[track.id];
+ const turretModelDefinition = tankModelDefinition.turrets[turret.id];
+ const gunModelDefinition = turretModelDefinition.guns[gun.id];
const hullOrigin = new Vector3(
trackModelDefinition.origin[0],
trackModelDefinition.origin[1],
-trackModelDefinition.origin[2],
- );
+ ).applyAxisAngle(I_HAT, Math.PI / 2);
const turretOrigin = new Vector3(
tankModelDefinition.turretOrigin[0],
tankModelDefinition.turretOrigin[1],
@@ -118,96 +132,88 @@ export function ArmorHighlighting() {
turretModelDefinition.gunOrigin[1],
-turretModelDefinition.gunOrigin[2],
).applyAxisAngle(I_HAT, Math.PI / 2);
- const maxThickness = Math.max(
- trackModelDefinition.thickness,
- gunModelDefinition.thickness,
- ...armorNodes
- .map((node) => {
- const armorId = nameToArmorId(node.name);
- return resolveArmor(tankModelDefinition.armor, armorId).thickness;
- })
- .filter(Boolean),
- );
return (
- {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 || spaced) return null;
-
- return (
-
- );
- })}
-
-
+
{armorNodes.map((node) => {
- const isCurrentTurret = node.name.startsWith(
- `turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
- );
- const isVisible = isCurrentTurret;
+ const isHull = node.name.startsWith('hull_');
+ const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
- turretModelDefinition.armor,
+ tankModelDefinition.armor,
armorId,
);
- if (!isVisible || thickness === undefined || spaced) return null;
+ if (!isVisible || spaced || thickness === undefined) return null;
return (
-
);
})}
+
-
+
+
{armorNodes.map((node) => {
- const isCurrentGun = node.name.startsWith(
- `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
+ const isCurrentTurret = node.name.startsWith(
+ `turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
);
- const isVisible = isCurrentGun;
+ const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
- gunModelDefinition.armor,
+ turretModelDefinition.armor,
armorId,
);
- if (!isVisible || thickness === undefined || spaced) return null;
+ if (!isVisible || spaced || thickness === undefined) return null;
return (
-
+
+
+
);
})}
+
+
+ {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) return null;
+
+ return (
+
+
+
+ );
+ })}
+
+
);
-}
+});
diff --git a/src/components/Armor/components/CoreArmorSceneComponent/index.tsx b/src/components/Armor/components/CoreArmorSceneComponent/index.tsx
new file mode 100644
index 000000000..c646ee157
--- /dev/null
+++ b/src/components/Armor/components/CoreArmorSceneComponent/index.tsx
@@ -0,0 +1,151 @@
+import { useFrame } from '@react-three/fiber';
+import { useEffect } from 'react';
+import {
+ MeshBasicMaterial,
+ Object3D,
+ ShaderMaterial,
+ Vector2,
+ WebGLRenderTarget,
+} from 'three';
+import { degToRad } from 'three/src/math/MathUtils';
+import { canSplash } from '../../../../core/blitz/canSplash';
+import { isExplosive } from '../../../../core/blitz/isExplosive';
+import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
+import { hasEquipment } from '../../../../core/blitzkrieg/hasEquipment';
+import { jsxTree } from '../../../../core/blitzkrieg/jsxTree';
+import { ShellDefinition } from '../../../../core/blitzkrieg/tankDefinitions';
+import { EquipmentMatrix, useDuel } from '../../../../stores/duel';
+import {
+ TankopediaPersistent,
+ useTankopediaPersistent,
+} from '../../../../stores/tankopedia';
+import fragmentShader from './shaders/fragment.glsl';
+import vertexShader from './shaders/vertex.glsl';
+
+export const spacedArmorRenderTarget = new WebGLRenderTarget();
+
+interface CoreArmorSceneComponentProps {
+ thickness: number;
+ node: Object3D;
+}
+
+const excludeMaterial = new MeshBasicMaterial({
+ colorWrite: false,
+});
+
+export function CoreArmorSceneComponent({
+ node,
+ thickness,
+}: CoreArmorSceneComponentProps) {
+ 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 },
+
+ resolution: { value: new Vector2() },
+ spacedArmorBuffer: { value: null },
+ spacedArmorDepth: { 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(shell.ricochet ?? 90);
+ 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;
+ }
+ function handleVisualChange(
+ visual: 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: EquipmentMatrix,
+ ) {
+ const hasEnhancedArmor = await hasEquipment(110, false, equipment);
+ material.uniforms.thickness.value = hasEnhancedArmor
+ ? thickness * 1.04
+ : thickness;
+ }
+ async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
+ const shell = useDuel.getState().antagonist!.shell;
+ const penetration = resolveNearPenetration(shell.penetration);
+ const hasCalibratedShells = await hasEquipment(103, true, equipment);
+ material.uniforms.penetration.value = hasCalibratedShells
+ ? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
+ : penetration;
+ }
+
+ handleShellChange(useDuel.getState().antagonist!.shell);
+ handleVisualChange(useTankopediaPersistent.getState().model.visual);
+ handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
+ handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
+
+ const unsubscribes = [
+ useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
+ useTankopediaPersistent.subscribe(
+ (state) => state.model.visual,
+ handleVisualChange,
+ ),
+ useDuel.subscribe(
+ (state) => state.protagonist!.equipment,
+ handleProtagonistEquipmentChange,
+ ),
+ useDuel.subscribe(
+ (state) => state.antagonist!.equipment,
+ handleAntagonistEquipmentChange,
+ ),
+ ];
+
+ return () => {
+ unsubscribes.forEach((unsubscribe) => unsubscribe());
+ };
+ }, []);
+
+ useFrame(({ gl }) => {
+ gl.getSize(material.uniforms.resolution.value).multiplyScalar(
+ gl.getPixelRatio(),
+ );
+ material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
+ material.uniforms.spacedArmorDepth.value =
+ spacedArmorRenderTarget.depthTexture;
+ });
+
+ return (
+ <>
+ {jsxTree(node, {
+ renderOrder: 0,
+ material: excludeMaterial,
+ })}
+ {jsxTree(node, {
+ renderOrder: 1,
+ material,
+ })}
+ >
+ );
+}
diff --git a/src/components/Armor/components/CoreArmorSceneComponent/shaders/fragment.glsl b/src/components/Armor/components/CoreArmorSceneComponent/shaders/fragment.glsl
new file mode 100644
index 000000000..e03696067
--- /dev/null
+++ b/src/components/Armor/components/CoreArmorSceneComponent/shaders/fragment.glsl
@@ -0,0 +1,87 @@
+varying vec3 vNormal;
+varying vec3 vViewPosition;
+varying mat4 vProjectionMatrix;
+
+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 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 = inverse(vProjectionMatrix) * 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));
+ float remainingPenetration = penetration;
+
+ // world space is in meters
+ vec4 spacedArmorDepthFragment = texture2D(spacedArmorDepth, screenCoordinates);
+ float spacedArmorDistance = depthToDistance(spacedArmorDepthFragment.r);
+ float coreArmorDistance = depthToDistance(gl_FragCoord.z);
+ float distanceFromSpacedArmor = coreArmorDistance - spacedArmorDistance;
+
+ if (isUnderSpacedArmor) {
+ float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
+ remainingPenetration -= spacedArmorThickness;
+
+ if (isExplosive && !canSplash && remainingPenetration > 0.0) {
+ // there is a 50% penetration loss per meter for HEAT shells
+ 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) {
+ // only allow splashing if the damage equation deals more than 0 damage
+ float nominalArmorThickness = finalThickness - remainingPenetration;
+ float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * nominalArmorThickness;
+ splashChance = finalDamage > 0.0 ? 1.0 : 0.0; // TODO: add randomization here?
+ } 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);
+ }
+}
\ No newline at end of file
diff --git a/src/components/ArmorMesh/components/Armor/shaders/vertex.glsl b/src/components/Armor/components/CoreArmorSceneComponent/shaders/vertex.glsl
similarity index 100%
rename from src/components/ArmorMesh/components/Armor/shaders/vertex.glsl
rename to src/components/Armor/components/CoreArmorSceneComponent/shaders/vertex.glsl
diff --git a/src/components/Armor/components/ShotDisplay.tsx b/src/components/Armor/components/ShotDisplay.tsx
new file mode 100644
index 000000000..6b470feb4
--- /dev/null
+++ b/src/components/Armor/components/ShotDisplay.tsx
@@ -0,0 +1,306 @@
+import { Card, Flex, Inset, Text } from '@radix-ui/themes';
+import { Html } from '@react-three/drei';
+import { useFrame } from '@react-three/fiber';
+import { useEffect, useRef } from 'react';
+import { Mesh } from 'three';
+import { radToDeg } from 'three/src/math/MathUtils';
+import { isExplosive } from '../../../core/blitz/isExplosive';
+import { NaNFallback } from '../../../core/math/NaNFallback';
+import { normalToEuler } from '../../../core/math/normalToEuler';
+import { AngledPenetration } from '../../../icons/AngledPenetration';
+import { Block } from '../../../icons/Block';
+import { NominalPenetration } from '../../../icons/NominalPenetration';
+import { Ricochet } from '../../../icons/Ricochet';
+import { useDuel } from '../../../stores/duel';
+import {
+ ShotLayer,
+ ShotLayerBase,
+ ShotLayerGap,
+ ShotLayerNonExternal,
+ mutateTankopediaTemporary,
+ useTankopediaTemporary,
+} from '../../../stores/tankopedia';
+import { ArmorType } from './SpacedArmorScene';
+
+const layerTypeNames: Record = {
+ [ArmorType.Core]: 'Core',
+ [ArmorType.Spaced]: 'Spaced',
+ [ArmorType.External]: 'External',
+ null: 'Gap',
+};
+
+const LENGTH_INFINITY = 4;
+const TRACER_THICK = 1 / 64;
+const TRACER_THIN = TRACER_THICK / 2;
+
+export function ShotDisplay() {
+ const shot = useTankopediaTemporary((state) => state.shot);
+ const preMidPointTracer = useRef(null);
+ const postMidPointTracer = useRef(null);
+ const hasMultipleLayers = shot?.layers.some(
+ (layer, index) => index !== 0 && layer.type !== null,
+ );
+ const { shell } = useDuel.getState().antagonist!;
+ const explosive = isExplosive(shell.type);
+ const midPointLayer = (shot?.layers.findLast(
+ (layer) => layer.type !== null && layer.status === 'ricochet',
+ ) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase;
+ const firstLayer = shot?.layers[0] as Exclude;
+ const lastLayer = shot?.layers.at(-1)!;
+ const distanceFromSpacedArmor = shot
+ ? firstLayer.point.distanceTo(shot.point)
+ : undefined;
+ const preMidPointLayers = shot?.layers.slice(
+ 0,
+ shot.layers.indexOf(midPointLayer as ShotLayer),
+ );
+ const postMidPointLayers = shot?.layers.slice(
+ shot.layers.indexOf(midPointLayer as ShotLayer),
+ shot.layers.length,
+ );
+ const preMidPointGap = shot
+ ? preMidPointLayers!.reduce((accumulator, layer) => {
+ if (layer.type === null) return accumulator + layer.distance;
+ return accumulator;
+ }, 0) + LENGTH_INFINITY
+ : undefined;
+ const postMidPointGap = shot
+ ? postMidPointLayers!.reduce((accumulator, layer) => {
+ if (layer.type === null) return accumulator + layer.distance;
+ return accumulator;
+ }, 0) || LENGTH_INFINITY
+ : undefined;
+
+ useFrame(({ clock }) => {
+ if (!shot || !preMidPointTracer.current) return;
+
+ const t1 = clock.elapsedTime % 1;
+ const t2 = (clock.elapsedTime + 0.5) % 1;
+
+ preMidPointTracer.current.position.set(0, (1 - t1) * preMidPointGap!, 0);
+ preMidPointTracer.current.scale.set(1, 1 - 2 * Math.abs(t1 - 0.5), 1);
+
+ if (!postMidPointTracer.current) return;
+
+ postMidPointTracer.current.position.set(0, t2 * postMidPointGap!, 0);
+ postMidPointTracer.current.scale.set(1, 1 - 2 * Math.abs(t2 - 0.5), 1);
+ });
+
+ useEffect(() => {
+ function resetShot() {
+ mutateTankopediaTemporary((draft) => {
+ draft.shot = undefined;
+ });
+ }
+
+ const unsubscribes = [
+ useDuel.subscribe((state) => state.protagonist?.tank, resetShot),
+ useDuel.subscribe((state) => state.antagonist?.tank, resetShot),
+ ];
+
+ return () => {
+ unsubscribes.forEach((unsubscribe) => unsubscribe());
+ };
+ });
+
+ if (!shot) return null;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ {midPointLayer.status === 'ricochet' && (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {shot.layers.map((layer, index) => {
+ if (layer.type === null) return;
+
+ const shellRotation = normalToEuler(layer.shellNormal);
+ const nextLayer = shot.layers[index + 1] as ShotLayerGap | undefined;
+ const trueStatus =
+ nextLayer?.status === 'blocked' ? 'blocked' : layer.status;
+
+ return (
+ <>
+
+
+
+
+ {hasMultipleLayers && (
+
+ {layer.index + 1}
+
+ )}
+
+
+
+
+ >
+ );
+ })}
+
+
+
+
+
+ {lastLayer.status[0].toUpperCase()}
+ {lastLayer.status.slice(1)}
+ {lastLayer.status === 'penetration' &&
+ `: ${Math.round(shell.damage.armor)}hp`}
+ {lastLayer.status === 'blocked' &&
+ shell.type === 'he' &&
+ `: ${NaNFallback(
+ Math.max(
+ 0,
+ Math.round(
+ 0.5 *
+ shell.damage.armor *
+ (1 -
+ distanceFromSpacedArmor! / shell.explosionRadius!) -
+ 1.1 *
+ (midPointLayer as ShotLayerNonExternal)
+ .thicknessAngled,
+ ),
+ ),
+ 0,
+ )}hp`}
+
+
+ {shot.layers.map((layer, index) => {
+ if (layer.type === null && !explosive) return null;
+
+ return (
+
+
+ {hasMultipleLayers && (
+
+ {layer.type === null ? null : `${layer.index + 1}.`}
+
+ )}
+ {layerTypeNames[`${layer.type}`]}
+
+
+ {layer.type !== null && (
+ <>
+
+
+ {Math.round(layer.thickness)}mm
+
+
+ {layer.type !== ArmorType.External && (
+
+ {layer.status === 'ricochet' ? (
+
+ ) : layer.status === 'blocked' ? (
+
+ ) : (
+
+ )}
+
+ {Math.round(layer.thicknessAngled)}mm (
+ {Math.round(radToDeg(layer.angle))}°)
+
+
+ )}
+ >
+ )}
+
+ {layer.type === null && (
+
+ {Math.round(layer.distance * 1000)}mm (-
+ {Math.round(Math.min(100, layer.distance * 50))}%
+ penetration)
+
+ )}
+
+ );
+ })}
+
+
+
+ >
+ );
+}
diff --git a/src/components/Armor/components/SpacedArmorScene.tsx b/src/components/Armor/components/SpacedArmorScene.tsx
new file mode 100644
index 000000000..56337dfe9
--- /dev/null
+++ b/src/components/Armor/components/SpacedArmorScene.tsx
@@ -0,0 +1,279 @@
+import { memo, useEffect, useRef } from 'react';
+import { Euler, Group, Scene, Vector3 } from 'three';
+import { degToRad } from 'three/src/math/MathUtils';
+import { I_HAT, J_HAT, K_HAT } from '../../../constants/axis';
+import {
+ ModelTransformEventData,
+ modelTransformEvent,
+} from '../../../core/blitzkrieg/modelTransform';
+import { nameToArmorId } from '../../../core/blitzkrieg/nameToArmorId';
+import { resolveArmor } from '../../../core/blitzkrieg/resolveThickness';
+import { useArmor } from '../../../hooks/useArmor';
+import { useModel } from '../../../hooks/useModel';
+import { useModelDefinitions } from '../../../hooks/useModelDefinitions';
+import { useDuel } from '../../../stores/duel';
+import { useTankopediaPersistent } from '../../../stores/tankopedia';
+import { SpacedArmorSceneComponent } from './SpacedArmorSceneComponent';
+
+export enum ArmorType {
+ Core,
+ Spaced,
+ External,
+}
+
+interface SpacedArmorSceneProps {
+ scene: Scene;
+}
+
+export const SpacedArmorScene = memo(({ scene }) => {
+ const wrapper = useRef(null);
+ const modelDefinitions = useModelDefinitions();
+ const turretContainer = useRef(null);
+ const gunContainer = useRef(null);
+ const initialTankopediaState = useTankopediaPersistent.getState();
+
+ useEffect(() => {
+ if (!modelDefinitions) return;
+
+ const hullOrigin = new Vector3(
+ trackModelDefinition.origin[0],
+ trackModelDefinition.origin[1],
+ -trackModelDefinition.origin[2],
+ ).applyAxisAngle(I_HAT, Math.PI / 2);
+ const turretOrigin = new Vector3(
+ tankModelDefinition.turretOrigin[0],
+ tankModelDefinition.turretOrigin[1],
+ -tankModelDefinition.turretOrigin[2],
+ ).applyAxisAngle(I_HAT, Math.PI / 2);
+ const gunOrigin = new Vector3(
+ turretModelDefinition.gunOrigin[0],
+ turretModelDefinition.gunOrigin[1],
+ -turretModelDefinition.gunOrigin[2],
+ ).applyAxisAngle(I_HAT, Math.PI / 2);
+ const turretPosition = new Vector3();
+ const turretRotation = new Euler();
+ const gunPosition = new Vector3();
+ const gunRotation = new Euler();
+
+ function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
+ gunPosition
+ .set(0, 0, 0)
+ .sub(hullOrigin)
+ .sub(turretOrigin)
+ .sub(gunOrigin)
+ .applyAxisAngle(I_HAT, pitch)
+ .add(gunOrigin)
+ .add(turretOrigin)
+ .add(hullOrigin);
+ gunRotation.set(pitch, 0, 0);
+ gunContainer.current?.position.copy(gunPosition);
+ gunContainer.current?.rotation.copy(gunRotation);
+
+ if (yaw === undefined) return;
+
+ turretPosition
+ .set(0, 0, 0)
+ .sub(hullOrigin)
+ .sub(turretOrigin)
+ .applyAxisAngle(new Vector3(0, 0, 1), yaw);
+ turretRotation.set(0, 0, yaw);
+
+ if (tankModelDefinition.turretRotation) {
+ const initialPitch = -degToRad(
+ tankModelDefinition.turretRotation.pitch,
+ );
+ const initialYaw = -degToRad(tankModelDefinition.turretRotation.yaw);
+ const initialRoll = -degToRad(tankModelDefinition.turretRotation.roll);
+
+ turretPosition
+ .applyAxisAngle(I_HAT, initialPitch)
+ .applyAxisAngle(J_HAT, initialRoll)
+ .applyAxisAngle(K_HAT, initialYaw);
+ turretRotation.x += initialPitch;
+ turretRotation.y += initialRoll;
+ turretRotation.z += initialYaw;
+ }
+
+ turretPosition.add(turretOrigin).add(hullOrigin);
+ turretContainer.current?.position.copy(turretPosition);
+ turretContainer.current?.rotation.copy(turretRotation);
+ }
+
+ handleModelTransform(useDuel.getState().protagonist!);
+ modelTransformEvent.on(handleModelTransform);
+
+ return () => {
+ modelTransformEvent.off(handleModelTransform);
+ };
+ });
+
+ useEffect(() => {
+ const unsubscribe = useTankopediaPersistent.subscribe(
+ (state) => state.mode,
+ (mode) => {
+ if (wrapper.current) wrapper.current.visible = mode === 'armor';
+ },
+ );
+
+ return unsubscribe;
+ });
+
+ const tank = useDuel((state) => state.protagonist!.tank);
+ const track = useDuel((state) => state.protagonist!.track);
+ const turret = useDuel((state) => state.protagonist!.turret);
+ const gun = useDuel((state) => state.protagonist!.gun);
+ const armorGltf = useArmor(tank.id);
+ const { gltf: modelGltf } = useModel(tank.id);
+ const armorNodes = Object.values(armorGltf.nodes);
+ const modelNodes = Object.values(modelGltf.nodes);
+ const tankModelDefinition = modelDefinitions[tank.id];
+ const trackModelDefinition = tankModelDefinition.tracks[track.id];
+ const turretModelDefinition = tankModelDefinition.turrets[turret.id];
+ const gunModelDefinition = turretModelDefinition.guns[gun.id];
+ const hullOrigin = new Vector3(
+ trackModelDefinition.origin[0],
+ trackModelDefinition.origin[1],
+ -trackModelDefinition.origin[2],
+ ).applyAxisAngle(I_HAT, Math.PI / 2);
+ const turretOrigin = new Vector3(
+ tankModelDefinition.turretOrigin[0],
+ tankModelDefinition.turretOrigin[1],
+ -tankModelDefinition.turretOrigin[2],
+ ).applyAxisAngle(I_HAT, Math.PI / 2);
+ const gunOrigin = new Vector3(
+ turretModelDefinition.gunOrigin[0],
+ turretModelDefinition.gunOrigin[1],
+ -turretModelDefinition.gunOrigin[2],
+ ).applyAxisAngle(I_HAT, Math.PI / 2);
+
+ return (
+
+
+ {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) return null;
+
+ return (
+
+ );
+ })}
+
+ {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 (
+
+ );
+ })}
+
+
+
+ {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) return null;
+
+ return (
+
+
+
+ );
+ })}
+
+
+
+ {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) return null;
+
+ return (
+
+
+
+ );
+ })}
+
+
+ {modelNodes.map((node) => {
+ const isCurrentGun =
+ node.name ===
+ `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
+ const isVisible = isCurrentGun;
+
+ if (!isVisible) return null;
+
+ return (
+
+ );
+ })}
+
+
+
+ );
+});
diff --git a/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/index.tsx b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/index.tsx
new file mode 100644
index 000000000..053ca4dfa
--- /dev/null
+++ b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/index.tsx
@@ -0,0 +1,110 @@
+import { useEffect } from 'react';
+import {
+ AdditiveBlending,
+ MeshBasicMaterial,
+ Object3D,
+ ShaderMaterial,
+} from 'three';
+import { ArmorUserData, ExternalModuleVariant } from '../..';
+import { isExplosive } from '../../../../../../core/blitz/isExplosive';
+import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
+import { hasEquipment } from '../../../../../../core/blitzkrieg/hasEquipment';
+import { jsxTree } from '../../../../../../core/blitzkrieg/jsxTree';
+import { ShellDefinition } from '../../../../../../core/blitzkrieg/tankDefinitions';
+import { EquipmentMatrix, useDuel } from '../../../../../../stores/duel';
+import { ArmorType } from '../../../SpacedArmorScene';
+import fragmentShader from './shaders/fragment.glsl';
+import vertexShader from './shaders/vertex.glsl';
+
+interface SpacedArmorSubExternalProps {
+ node: Object3D;
+ thickness: number;
+ variant: ExternalModuleVariant;
+}
+
+export function SpacedArmorSubExternal({
+ node,
+ thickness,
+ variant,
+}: SpacedArmorSubExternalProps) {
+ const material = new ShaderMaterial({
+ fragmentShader,
+ vertexShader,
+
+ depthTest: true,
+ depthWrite: false,
+ blending: AdditiveBlending,
+
+ uniforms: {
+ thickness: { value: null },
+ penetration: { value: null },
+ },
+ });
+
+ useEffect(() => {
+ function handleShellChange(shell: ShellDefinition) {
+ material.uniforms.penetration.value = resolveNearPenetration(
+ shell.penetration,
+ );
+ }
+ async function handleProtagonistEquipmentChange(
+ equipment: EquipmentMatrix,
+ ) {
+ const hasEnhancedArmor = await hasEquipment(110, false, equipment);
+ material.uniforms.thickness.value = hasEnhancedArmor
+ ? thickness * 1.04
+ : thickness;
+ }
+ async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
+ const shell = useDuel.getState().antagonist!.shell;
+ const penetration = resolveNearPenetration(shell.penetration);
+ const hasCalibratedShells = await hasEquipment(103, true, equipment);
+ material.uniforms.penetration.value = hasCalibratedShells
+ ? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
+ : penetration;
+ }
+
+ handleShellChange(useDuel.getState().antagonist!.shell);
+ handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
+ handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
+
+ const unsubscribes = [
+ useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
+ useDuel.subscribe(
+ (state) => state.protagonist!.equipment,
+ handleProtagonistEquipmentChange,
+ ),
+ useDuel.subscribe(
+ (state) => state.antagonist!.equipment,
+ handleAntagonistEquipmentChange,
+ ),
+ ];
+
+ return () => {
+ unsubscribes.forEach((unsubscribe) => unsubscribe());
+ };
+ }, []);
+
+ return (
+ <>
+ {jsxTree(node, {
+ renderOrder: 3,
+ material: new MeshBasicMaterial({
+ colorWrite: false,
+ depthTest: true,
+ depthWrite: true,
+ }),
+ onClick() {},
+ userData: {
+ type: ArmorType.External,
+ thickness,
+ variant,
+ } satisfies ArmorUserData,
+ })}
+ {jsxTree(node, {
+ renderOrder: 4,
+ material,
+ })}
+ >
+ );
+}
diff --git a/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/shaders/fragment.glsl b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/shaders/fragment.glsl
new file mode 100644
index 000000000..0ab70c10d
--- /dev/null
+++ b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/shaders/fragment.glsl
@@ -0,0 +1,6 @@
+uniform float thickness;
+uniform float penetration;
+
+void main() {
+ gl_FragColor = vec4(thickness / penetration, 0.0, 0.0, 1.0);
+}
\ No newline at end of file
diff --git a/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/shaders/vertex.glsl b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/shaders/vertex.glsl
new file mode 100644
index 000000000..5f9df37ef
--- /dev/null
+++ b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/shaders/vertex.glsl
@@ -0,0 +1,4 @@
+void main() {
+ vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
+ gl_Position = projectionMatrix * vViewPosition4;
+}
\ No newline at end of file
diff --git a/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/index.tsx b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/index.tsx
new file mode 100644
index 000000000..5a1b2933e
--- /dev/null
+++ b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/index.tsx
@@ -0,0 +1,117 @@
+import { useEffect } from 'react';
+import {
+ AdditiveBlending,
+ MeshBasicMaterial,
+ Object3D,
+ ShaderMaterial,
+} from 'three';
+import { degToRad } from 'three/src/math/MathUtils';
+import { ArmorUserData } from '../..';
+import { isExplosive } from '../../../../../../core/blitz/isExplosive';
+import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
+import { hasEquipment } from '../../../../../../core/blitzkrieg/hasEquipment';
+import { jsxTree } from '../../../../../../core/blitzkrieg/jsxTree';
+import { ShellDefinition } from '../../../../../../core/blitzkrieg/tankDefinitions';
+import { EquipmentMatrix, useDuel } from '../../../../../../stores/duel';
+import { ArmorType } from '../../../SpacedArmorScene';
+import fragmentShader from './shaders/fragment.glsl';
+import vertexShader from './shaders/vertex.glsl';
+
+interface SpacedArmorSubSpacedProps {
+ node: Object3D;
+ thickness: number;
+}
+
+const depthWriteMaterial = new MeshBasicMaterial({
+ depthWrite: true,
+ colorWrite: false,
+});
+
+export function SpacedArmorSubSpaced({
+ node,
+ thickness,
+}: SpacedArmorSubSpacedProps) {
+ const 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(shell.ricochet ?? 90);
+ material.uniforms.normalization.value = degToRad(
+ shell.normalization ?? 0,
+ );
+ }
+ async function handleProtagonistEquipmentChange(
+ equipment: EquipmentMatrix,
+ ) {
+ const hasEnhancedArmor = await hasEquipment(110, false, equipment);
+ material.uniforms.thickness.value = hasEnhancedArmor
+ ? thickness * 1.04
+ : thickness;
+ }
+ async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
+ const shell = useDuel.getState().antagonist!.shell;
+ const penetration = resolveNearPenetration(shell.penetration);
+ const hasCalibratedShells = await hasEquipment(103, true, equipment);
+ material.uniforms.penetration.value = hasCalibratedShells
+ ? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
+ : penetration;
+ }
+
+ handleShellChange(useDuel.getState().antagonist!.shell);
+ handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
+ handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
+
+ const unsubscribes = [
+ useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
+ useDuel.subscribe(
+ (state) => state.protagonist!.equipment,
+ handleProtagonistEquipmentChange,
+ ),
+ useDuel.subscribe(
+ (state) => state.antagonist!.equipment,
+ handleAntagonistEquipmentChange,
+ ),
+ ];
+
+ return () => {
+ unsubscribes.forEach((unsubscribe) => unsubscribe());
+ };
+ }, []);
+
+ return (
+ <>
+ {jsxTree(node, {
+ renderOrder: 2,
+ material,
+ onClick() {},
+ userData: {
+ type: ArmorType.Spaced,
+ thickness,
+ } satisfies ArmorUserData,
+ })}
+ {jsxTree(node, {
+ renderOrder: 5,
+ material: depthWriteMaterial,
+ })}
+ >
+ );
+}
diff --git a/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/shaders/fragment.glsl b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/shaders/fragment.glsl
new file mode 100644
index 000000000..7d486d5e8
--- /dev/null
+++ b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/shaders/fragment.glsl
@@ -0,0 +1,24 @@
+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);
+}
\ No newline at end of file
diff --git a/src/components/ArmorMesh/components/SpacedArmorDepth/shaders/vertex.glsl b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/shaders/vertex.glsl
similarity index 73%
rename from src/components/ArmorMesh/components/SpacedArmorDepth/shaders/vertex.glsl
rename to src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/shaders/vertex.glsl
index d57a82f32..82f94e255 100644
--- a/src/components/ArmorMesh/components/SpacedArmorDepth/shaders/vertex.glsl
+++ b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/shaders/vertex.glsl
@@ -1,10 +1,11 @@
-varying vec3 vViewPosition;
varying vec3 vNormal;
+varying vec3 vViewPosition;
void main() {
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
+
vViewPosition = vViewPosition4.xyz;
vNormal = normalMatrix * normal;
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ gl_Position = projectionMatrix * vViewPosition4;
}
\ No newline at end of file
diff --git a/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx b/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx
new file mode 100644
index 000000000..5bed3de2c
--- /dev/null
+++ b/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx
@@ -0,0 +1,318 @@
+import { useThree } from '@react-three/fiber';
+import {
+ Intersection,
+ MeshBasicMaterial,
+ Object3D,
+ Quaternion,
+ Raycaster,
+ Scene,
+ Vector3,
+} from 'three';
+import { degToRad } from 'three/src/math/MathUtils';
+import { canSplash } from '../../../../core/blitz/canSplash';
+import { isExplosive } from '../../../../core/blitz/isExplosive';
+import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
+import { hasEquipment } from '../../../../core/blitzkrieg/hasEquipment';
+import { jsxTree } from '../../../../core/blitzkrieg/jsxTree';
+import { useDuel } from '../../../../stores/duel';
+import {
+ Shot,
+ ShotLayerBase,
+ useTankopediaTemporary,
+} from '../../../../stores/tankopedia';
+import { ArmorType } from '../SpacedArmorScene';
+import { SpacedArmorSubExternal } from './components/SpacedArmorSubExternal';
+import { SpacedArmorSubSpaced } from './components/SpacedArmorSubSpaced';
+
+type SpacedArmorSceneComponentProps = {
+ node: Object3D;
+ thickness: number;
+ scene: Scene;
+} & (
+ | {
+ type: Exclude;
+ }
+ | {
+ type: ArmorType.External;
+ variant: ExternalModuleVariant;
+ }
+);
+
+export type ExternalModuleVariant = 'gun' | 'track';
+export type ArmorUserData = {
+ thickness: number;
+} & (
+ | {
+ type: Exclude;
+ }
+ | {
+ type: ArmorType.External;
+ variant: ExternalModuleVariant;
+ }
+);
+type ArmorMeshIntersection = Intersection & {
+ object: Object3D & { userData: ArmorUserData };
+};
+
+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,
+ scene,
+ ...props
+}: SpacedArmorSceneComponentProps) {
+ const camera = useThree((state) => state.camera);
+
+ return (
+ <>
+ {props.type === ArmorType.Core &&
+ jsxTree(node, {
+ renderOrder: 0,
+ material: omitMaterial,
+ userData: {
+ type: ArmorType.Core,
+ thickness,
+ } satisfies ArmorUserData,
+
+ async onClick(event) {
+ event.stopPropagation();
+
+ const hasCalibratedShells = await hasEquipment(103, true);
+ const hasEnhancedArmor = await hasEquipment(110);
+ const thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1;
+ const shell = useDuel.getState().antagonist!.shell;
+ const explosive = isExplosive(shell.type);
+ const splashing = canSplash(shell.type);
+ const penetration =
+ resolveNearPenetration(shell.penetration) *
+ (hasCalibratedShells
+ ? isExplosive(shell.type)
+ ? 1.1
+ : 1.05
+ : 1);
+ const shot: Shot = {
+ layers: [],
+ point: event.point,
+ }; // TODO: type this
+ let remainingPenetration = penetration;
+
+ const shellNormal = camera.position
+ .clone()
+ .sub(event.point)
+ .normalize();
+
+ pushLogs(shellNormal, filterIntersections(event.intersections), 0);
+
+ function filterIntersections(intersectionsRaw: Intersection[]) {
+ const intersectionsAll = intersectionsRaw.filter(
+ ({ object }) => object.userData.type !== undefined,
+ ) as ArmorMeshIntersection[];
+ const intersectionsWithDuplicates = intersectionsAll.slice(
+ 0,
+ intersectionsAll.findIndex(
+ ({ object }) => object.userData.type === ArmorType.Core,
+ ) + 1,
+ );
+ const firstGunIntersectionIndex =
+ intersectionsWithDuplicates.findIndex(
+ ({ object }) =>
+ object.userData.type === ArmorType.External &&
+ object.userData.variant === 'gun',
+ );
+ const firstTrackIntersectionIndex =
+ intersectionsWithDuplicates.findIndex(
+ ({ object }) =>
+ object.userData.type === ArmorType.External &&
+ object.userData.variant === 'track',
+ );
+ let intersections = intersectionsWithDuplicates;
+
+ if (firstGunIntersectionIndex !== -1) {
+ intersections = intersections.filter(({ object }, index) => {
+ return object.userData.type === ArmorType.External &&
+ object.userData.variant === 'gun'
+ ? index === firstGunIntersectionIndex
+ : true;
+ });
+ }
+ if (firstTrackIntersectionIndex !== -1) {
+ intersections = intersections.filter(({ object }, index) => {
+ return object.userData.type === ArmorType.External &&
+ object.userData.variant === 'track'
+ ? index === firstTrackIntersectionIndex
+ : true;
+ });
+ }
+
+ return intersections;
+ }
+
+ function pushLogs(
+ shellNormal: Vector3,
+ intersections: ArmorMeshIntersection[],
+ startingIndex: number,
+ ) {
+ let layerIndex = startingIndex;
+ let loopIndex = 0;
+
+ if (layerIndex !== 0 && intersections.length > 0) {
+ const distance = (
+ shot.layers.at(-1) as ShotLayerBase
+ ).point.distanceTo(intersections[0].point);
+ if (explosive && !splashing)
+ remainingPenetration -= 0.5 * remainingPenetration * distance;
+ const blocked = remainingPenetration < 0;
+
+ shot.layers.push({
+ type: null,
+ status: blocked ? 'blocked' : 'penetration',
+ distance,
+ });
+
+ if (blocked) return;
+ }
+
+ for (const intersection of intersections) {
+ const surfaceNormal = intersection
+ .normal!.clone()
+ .applyQuaternion(
+ event.object.getWorldQuaternion(new Quaternion()),
+ );
+ const thickness =
+ thicknessCoefficient * intersection.object.userData.thickness;
+
+ if (layerIndex !== 0 && startingIndex === 0) {
+ const previousIntersection = intersections[layerIndex - 1];
+ const distance =
+ intersection.distance - previousIntersection.distance;
+ if (explosive && !splashing)
+ remainingPenetration -=
+ 0.5 * remainingPenetration * distance;
+ const wasted = remainingPenetration < 0;
+
+ shot.layers.push({
+ type: null,
+ status: wasted ? 'blocked' : 'penetration',
+ distance,
+ });
+
+ if (wasted) return;
+ }
+
+ if (intersection.object.userData.type === ArmorType.External) {
+ remainingPenetration -= thickness;
+ const blocked = remainingPenetration < 0;
+
+ shot.layers.push({
+ type: ArmorType.External,
+ index: layerIndex,
+ shellNormal,
+ surfaceNormal,
+ point: intersection.point,
+ thickness: intersection.object.userData.thickness,
+ status: blocked ? 'blocked' : 'penetration',
+ });
+
+ if (blocked) break;
+ } else {
+ const type = intersection.object.userData.type;
+ const angle = surfaceNormal.angleTo(shellNormal);
+ const ricochet = degToRad(shell.ricochet ?? 90);
+ const normalization = degToRad(shell.normalization ?? 0);
+ const threeCalibersRule =
+ shell.caliber > thickness * 3 || layerIndex > 0;
+ const twoCalibersRule = shell.caliber > thickness * 2;
+ const finalNormalization = twoCalibersRule
+ ? (1.4 * normalization * shell.caliber) / (2.0 * thickness)
+ : normalization;
+ const finalThickness =
+ thickness === 0
+ ? 0
+ : thickness /
+ Math.cos(Math.max(0, angle - finalNormalization));
+
+ if (!threeCalibersRule && angle >= ricochet) {
+ shot.layers.push({
+ type,
+ index: layerIndex,
+ shellNormal,
+ surfaceNormal,
+ point: intersection.point,
+ thickness,
+ thicknessAngled: finalThickness,
+ angle,
+ status: 'ricochet',
+ });
+
+ remainingPenetration *= 0.75;
+
+ const raycaster = new Raycaster();
+ const newShellNormal = shellNormal
+ .clone()
+ .reflect(surfaceNormal);
+ const newCameraNormal = newShellNormal
+ .clone()
+ .multiplyScalar(-1);
+
+ raycaster.set(intersection.point, newCameraNormal);
+ pushLogs(
+ newShellNormal,
+ filterIntersections(
+ raycaster.intersectObjects(scene.children, true),
+ ),
+ layerIndex + 1,
+ );
+
+ break;
+ } else {
+ remainingPenetration -= finalThickness;
+ const blocked = remainingPenetration < 0;
+
+ shot.layers.push({
+ type,
+ index: layerIndex,
+ shellNormal,
+ surfaceNormal,
+ point: intersection.point,
+ thickness,
+ thicknessAngled: finalThickness,
+ angle,
+ status: blocked ? 'blocked' : 'penetration',
+ });
+ }
+ }
+
+ layerIndex++;
+ loopIndex++;
+ }
+ }
+
+ useTankopediaTemporary.setState({ shot });
+ },
+ })}
+
+ {props.type === ArmorType.Spaced && (
+
+ )}
+
+ {props.type === ArmorType.External && (
+
+ )}
+ >
+ );
+}
diff --git a/src/components/Armor/index.tsx b/src/components/Armor/index.tsx
new file mode 100644
index 000000000..67ac23c84
--- /dev/null
+++ b/src/components/Armor/index.tsx
@@ -0,0 +1,52 @@
+import { createPortal, useFrame } from '@react-three/fiber';
+import { memo, useState } from 'react';
+import { DepthTexture, Scene, Vector2 } from 'three';
+import { CoreArmorScene } from './components/CoreArmorScene';
+import { spacedArmorRenderTarget } from './components/CoreArmorSceneComponent';
+import { SpacedArmorScene } from './components/SpacedArmorScene';
+
+export const Armor = memo(() => {
+ const [spacedArmorScene] = useState(() => new Scene());
+ const [coreArmorScene] = useState(() => new Scene());
+ const spacedArmorPortal = createPortal(
+ ,
+ spacedArmorScene,
+ );
+ const coreArmorPortal = createPortal(, coreArmorScene);
+
+ 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(coreArmorScene, camera);
+ }, 1);
+
+ return (
+ <>
+ {spacedArmorPortal}
+ {coreArmorPortal}
+ >
+ );
+});
diff --git a/src/components/ArmorMesh/components/Armor/index.tsx b/src/components/ArmorMesh/components/Armor/index.tsx
deleted file mode 100644
index 816799dfc..000000000
--- a/src/components/ArmorMesh/components/Armor/index.tsx
+++ /dev/null
@@ -1,459 +0,0 @@
-import { MeshProps, useFrame, useThree } from '@react-three/fiber';
-import { memo, useEffect, useRef } from 'react';
-import {
- IUniform,
- Mesh,
- Quaternion,
- Raycaster,
- ShaderMaterial,
- Vector2,
- Vector3,
-} from 'three';
-import { degToRad } from 'three/src/math/MathUtils';
-import { canRicochet } from '../../../../core/blitz/canRicochet';
-import { canSplash } from '../../../../core/blitz/canSplash';
-import { isExplosive } from '../../../../core/blitz/isExplosive';
-import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
-import { hasEquipment } from '../../../../core/blitzkrieg/hasEquipment';
-import { useAwait } from '../../../../hooks/useAwait';
-import { useDuel } from '../../../../stores/duel';
-import {
- ArmorPiercingLayer,
- Shot,
- mutateTankopediaTemporary,
- useTankopediaPersistent,
-} from '../../../../stores/tankopedia';
-import { externalModuleMaskRenderTarget } from '../ExternalModuleMask';
-import { spacedArmorDepthRenderTarget } from '../SpacedArmorDepth';
-import fragmentShader from './shaders/fragment.glsl';
-import vertexShader from './shaders/vertex.glsl';
-
-interface ArmorMeshProps extends MeshProps {
- thickness: number;
- maxThickness: number;
-}
-
-export type ArmorMeshUserData = {
- type: 'coreArmor' | 'spacedArmor' | 'externalModule';
- thickness: number;
-};
-
-export const ArmorMesh = memo(
- ({ thickness, maxThickness, ...props }) => {
- const camera = useThree((state) => state.camera);
- const initialShell = useDuel.getState().antagonist!.shell;
- const initialTankopedia = useTankopediaPersistent.getState();
- const material = useRef(null);
- const explosionCapable = isExplosive(initialShell.type);
- const ricochetCapable = canRicochet(initialShell.type);
- const cameraNormal = new Vector3();
- const shellNormal = new Vector3();
- const surfaceNormal = new Vector3();
- const mesh = useRef(null);
- const scene = useThree((state) => state.scene);
- const hasCalibratedShells = useAwait(hasEquipment(103, true));
- const hasEnhancedArmor = useAwait(hasEquipment(110));
- const wireframe = initialTankopedia.model.visual.wireframe;
-
- useEffect(() => {
- async function updateQuickEquipments() {
- const shell = useDuel.getState().antagonist!.shell;
- const nearPenetration = resolveNearPenetration(shell.penetration);
- const hasCalibratedShells = await hasEquipment(103, true);
- const hasEnhancedArmor = await hasEquipment(110);
-
- if (material.current) {
- material.current.uniforms.thickness.value =
- thickness * (hasEnhancedArmor ? 1.04 : 1);
- material.current.uniforms.penetration.value =
- nearPenetration *
- (hasCalibratedShells ? (isExplosive(shell.type) ? 1.1 : 1.05) : 1);
- }
- }
-
- function updateShellProperties() {
- const shell = useDuel.getState().antagonist!.shell;
-
- if (material.current) {
- material.current.uniforms.canRicochet.value = canRicochet(shell.type);
- material.current.uniforms.isExplosive.value = isExplosive(shell.type);
- material.current.uniforms.canSplash.value = canSplash(shell.type);
- material.current.uniforms.caliber.value = shell.caliber;
- material.current.uniforms.ricochetAngle.value = degToRad(
- shell.ricochet ?? 90,
- );
- material.current.uniforms.damage.value = shell.damage.armor;
- material.current.uniforms.explosionRadius.value =
- shell.explosionRadius ?? 0;
- }
-
- updateQuickEquipments();
- }
-
- const unsubscribes = [
- useDuel.subscribe(
- (state) => state.protagonist!.equipment,
- updateQuickEquipments,
- ),
- useDuel.subscribe(
- (state) => state.antagonist!.equipment,
- updateQuickEquipments,
- ),
- useDuel.subscribe(
- (state) => state.antagonist!.shell,
- updateShellProperties,
- ),
- useTankopediaPersistent.subscribe(
- (state) => state.model.visual.greenPenetration,
- (greenPenetration) => {
- if (material.current) {
- material.current.uniforms.greenPenetration.value =
- greenPenetration;
- }
- },
- ),
- useTankopediaPersistent.subscribe(
- (state) => state.model.visual.wireframe,
- (wireframe) => {
- if (material.current) {
- material.current.wireframe = wireframe;
- material.current.uniforms.wireframe.value = wireframe;
- }
- },
- ),
- ];
-
- return () => {
- unsubscribes.forEach((unsubscribe) => unsubscribe());
- };
- }, []);
-
- useFrame(({ gl, camera }) => {
- if (material.current) {
- gl.getSize(material.current.uniforms.resolution.value);
-
- (
- material.current.uniforms.resolution as IUniform
- ).value.multiplyScalar(gl.getPixelRatio());
-
- material.current.uniforms.externalModuleMask.value =
- externalModuleMaskRenderTarget.texture;
- material.current.uniforms.spacedArmorDepth.value =
- spacedArmorDepthRenderTarget.depthTexture;
- material.current.uniforms.spacedArmorMask.value =
- spacedArmorDepthRenderTarget.texture;
- material.current.uniforms.projectionMatrixInverse.value =
- camera.projectionMatrixInverse;
- }
- });
-
- return (
- <>
-
-
-
-
- {
- // if (useTankopediaTemporary.getState().mode !== 'armor') return;
-
- shellNormal.copy(
- cameraNormal.copy(camera.position).sub(event.point).normalize(),
- );
- surfaceNormal
- .copy(event.normal!)
- .applyQuaternion(
- event.object.getWorldQuaternion(new Quaternion()),
- );
- const angle = Math.acos(surfaceNormal.dot(shellNormal));
- const shell = useDuel.getState().antagonist!.shell;
- const hasCalibratedShells = await hasEquipment(103, true);
- const resolvedPenetration =
- resolveNearPenetration(shell.penetration) *
- (hasCalibratedShells
- ? isExplosive(shell.type)
- ? 1.1
- : 1.05
- : 1);
- const indexedObjects = new Set();
- const intersections = event.intersections
- .filter(({ object }) => Boolean(object.userData.type))
- .map((event) => {
- const typedEvent = event as typeof event & {
- object: (typeof event)['object'] & {
- userData: ArmorMeshUserData;
- };
- };
-
- return typedEvent;
- })
- .filter(({ object }) => {
- if (indexedObjects.has(object.userData.type)) return false;
- indexedObjects.add(object.userData.type);
- return true;
- });
- const intersectionsTillFirstCoreArmor = intersections.slice(
- 0,
- intersections.findIndex(
- ({ object }) => object.userData.type === 'coreArmor',
- ) + 1,
- );
- const coreArmorMesh =
- intersectionsTillFirstCoreArmor.at(-1)!.object;
- const hasEnhancedArmor = await hasEquipment(110);
- const thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1;
-
- if (mesh.current !== (coreArmorMesh as unknown as Mesh)) return;
-
- let hasRicochet = false;
- const armorPiercingLayersRaw = intersectionsTillFirstCoreArmor
- .map((event, index) => {
- if (hasRicochet) return null;
-
- switch (event.object.userData.type) {
- case 'externalModule': {
- return {
- nominal:
- event.object.userData.thickness * thicknessCoefficient,
- angled:
- event.object.userData.thickness * thicknessCoefficient,
- type: 'external',
- distance: event.distance,
- ricochet: false,
- } satisfies ArmorPiercingLayer;
- }
-
- case 'spacedArmor':
- case 'coreArmor': {
- const threeCalibersRule =
- shell.caliber >
- 3 *
- (event.object.userData.thickness *
- thicknessCoefficient);
-
- if (
- index === 0 &&
- canRicochet(shell.type) &&
- !threeCalibersRule &&
- angle >= degToRad(shell.ricochet!)
- ) {
- hasRicochet = true;
- }
-
- const twoCalibersRule =
- shell.caliber >
- 2 *
- (event.object.userData.thickness *
- thicknessCoefficient);
- const normalization =
- twoCalibersRule && event.object.userData.thickness > 0
- ? ((shell.normalization ?? 0) * 1.4 * shell.caliber) /
- (2 *
- event.object.userData.thickness *
- thicknessCoefficient)
- : shell.normalization ?? 0;
- const angled =
- (event.object.userData.thickness * thicknessCoefficient) /
- Math.cos(angle - degToRad(normalization));
-
- return {
- ricochet: hasRicochet,
- nominal:
- event.object.userData.thickness * thicknessCoefficient,
- angled,
- type:
- event.object.userData.type === 'coreArmor'
- ? 'core'
- : 'spaced',
- distance: event.distance,
- } satisfies ArmorPiercingLayer;
- }
- }
- })
- .filter(Boolean);
- const gaps = armorPiercingLayersRaw.map((layerA, aIndex) => {
- if (aIndex === armorPiercingLayersRaw.length - 1) return null;
- const layerB = armorPiercingLayersRaw[aIndex + 1];
-
- return {
- type: 'gap',
- gap: Math.abs(layerB.distance - layerA.distance),
- } satisfies ArmorPiercingLayer;
- });
- const armorPiercingLayers: ArmorPiercingLayer[] = [];
-
- armorPiercingLayersRaw.forEach((layer, index) => {
- const gap = gaps[index];
-
- armorPiercingLayers.push(layer);
- if (gap) armorPiercingLayers.push(gap);
- });
-
- let remainingPenetration = resolvedPenetration;
- const explosiveCapable = isExplosive(shell.type);
-
- armorPiercingLayers.forEach((layer) => {
- if (layer.type !== 'gap') {
- if (layer.ricochet) {
- remainingPenetration *= 0.75;
- } else {
- remainingPenetration -= layer.angled;
- }
- } else if (explosiveCapable) {
- // there is a 50% penetration loss per meter for HE based shells
- remainingPenetration -= 0.5 * remainingPenetration * layer.gap;
- }
-
- remainingPenetration = Math.max(0, remainingPenetration);
- });
-
- const hasPenetrated = remainingPenetration > 0;
- let ricochet: Shot['ricochet'] = undefined;
-
- if (hasRicochet) {
- const raycaster = new Raycaster();
- const bounceNormal = shellNormal
- .clone()
- .multiplyScalar(-1)
- .sub(
- surfaceNormal
- .clone()
- .multiplyScalar(
- 2 *
- surfaceNormal.dot(
- shellNormal.clone().multiplyScalar(-1),
- ),
- ),
- );
- raycaster.set(event.point, bounceNormal);
- const intersections = raycaster
- .intersectObjects(scene.children, true)
- .filter(
- (intersection) =>
- typeof intersection.object.userData?.type === 'string',
- );
- const indexOfCoreArmor = intersections.findIndex(
- ({ object }) => object.userData.type === 'coreArmor',
- );
- const intersectionsTillFirstCoreArmor = intersections.slice(
- 0,
- indexOfCoreArmor + 1,
- );
-
- if (intersectionsTillFirstCoreArmor.length > 0) {
- intersectionsTillFirstCoreArmor.forEach((intersection) => {
- switch (intersection.object.userData.type) {
- case 'externalModule': {
- remainingPenetration -=
- intersection.object.userData.thickness;
- break;
- }
-
- case 'spacedArmor':
- case 'coreArmor': {
- const angle = bounceNormal.angleTo(
- intersection.object.position.clone().sub(event.point),
- );
- const twoCalibersRule =
- shell.caliber >
- 2 *
- (intersection.object.userData.thickness *
- thicknessCoefficient);
- const normalization = twoCalibersRule
- ? ((shell.normalization ?? 0) * 1.4 * shell.caliber) /
- (2 *
- intersection.object.userData.thickness *
- thicknessCoefficient)
- : shell.normalization ?? 0;
- const angled =
- (intersection.object.userData.thickness *
- thicknessCoefficient) /
- Math.cos(angle - degToRad(normalization));
-
- remainingPenetration -= angled;
- }
- }
- });
-
- ricochet = {
- point: intersectionsTillFirstCoreArmor
- .at(-1)!
- .point.toArray(),
- distance: intersectionsTillFirstCoreArmor.at(-1)!.distance,
- penetration: remainingPenetration > 0,
- };
- }
- }
-
- mutateTankopediaTemporary((draft) => {
- draft.shot = {
- type: hasRicochet
- ? 'ricochet'
- : hasPenetrated
- ? 'penetration'
- : 'block',
- point: event.point.toArray(),
- thicknesses: armorPiercingLayers,
- shellNormal: shellNormal.toArray(),
- surfaceNormal: surfaceNormal.toArray(),
- angle,
- ricochet,
- };
- });
- }}
- >
-
-
- >
- );
- },
- () => true,
-);
diff --git a/src/components/ArmorMesh/components/Armor/shaders/fragment.glsl b/src/components/ArmorMesh/components/Armor/shaders/fragment.glsl
deleted file mode 100644
index 1bb58ff26..000000000
--- a/src/components/ArmorMesh/components/Armor/shaders/fragment.glsl
+++ /dev/null
@@ -1,120 +0,0 @@
-#define PI 3.1415926535897932384626433832795
-
-varying vec3 vViewPosition;
-varying vec3 vNormal;
-uniform bool isExplosive;
-uniform bool canRicochet;
-uniform bool canSplash;
-uniform float thickness;
-uniform float penetration;
-uniform float caliber;
-uniform float ricochetAngle;
-uniform float normalization;
-uniform sampler2D externalModuleMask;
-uniform highp sampler2D spacedArmorDepth;
-uniform sampler2D spacedArmorMask;
-uniform vec2 resolution;
-varying mat4 vProjectionMatrix;
-uniform float maxThickness;
-uniform bool greenPenetration;
-uniform float damage;
-uniform float explosionRadius;
-uniform bool wireframe;
-
-float depthToDistance(float depth) {
- mat4 projectionMatrixInverse = inverse(vProjectionMatrix);
- vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
- vec4 eyePosition = projectionMatrixInverse * clipPosition;
- vec3 NDCPosition = eyePosition.xyz / eyePosition.w;
- float worldDistance = length(NDCPosition);
-
- return worldDistance;
-}
-
-float angleTo(vec3 a, vec3 b) {
- return acos(dot(a, b) / (length(a) * length(b)));
-}
-
-void main() {
- float penetrationChance = -1.0;
- float splashChance = -1.0;
-
- vec2 screenCoordinates = gl_FragCoord.xy / resolution;
- vec4 externalModuleMaskColor = texture2D(externalModuleMask, screenCoordinates);
- vec4 spacedArmorMaskColor = texture2D(spacedArmorMask, screenCoordinates);
- bool isUnderExternalModule = externalModuleMaskColor.r == 1.0;
- bool isUnderSpacedArmor = spacedArmorMaskColor.a == 1.0;
- float spacedArmorAngle = spacedArmorMaskColor.r * (PI / 2.0);
-
- float coreArmorAngle = angleTo(vNormal, -vViewPosition);
- bool coreArmorThreeCalibersRule = caliber > 3.0 * thickness;
- float spacedArmorNominalThickness = spacedArmorMaskColor.g * maxThickness;
- bool spacedArmorThreeCalibersRule = isUnderSpacedArmor && caliber > 3.0 * spacedArmorNominalThickness;
- bool hasRicochetedSpacedArmor = isUnderSpacedArmor && !spacedArmorThreeCalibersRule && spacedArmorAngle >= ricochetAngle;
- bool hasRicochetedCoreArmor = !hasRicochetedSpacedArmor && !isUnderExternalModule && canRicochet && !coreArmorThreeCalibersRule && coreArmorAngle >= ricochetAngle;
-
- if (hasRicochetedCoreArmor || hasRicochetedSpacedArmor) {
- penetrationChance = 0.0;
- splashChance = 0.0;
- } else {
- // time to chip away at the penetration, one case at a time
- float remainingPenetration = penetration;
-
- if (isUnderExternalModule) {
- // external modules don't care about angle, they'll reduce penetration by their thickness
- float externalModuleThickness = externalModuleMaskColor.g * maxThickness;
- remainingPenetration -= externalModuleThickness;
- }
-
- if (isUnderSpacedArmor) {
- // spaced armor on the other hand, does care about angle
- bool spacedArmorTwoCalibersRule = caliber > 2.0 * spacedArmorNominalThickness && spacedArmorNominalThickness > 0.0;
- float finalSpacedArmorNormalization = spacedArmorTwoCalibersRule ? (normalization * 1.4 * caliber) / (2.0 * spacedArmorNominalThickness) : normalization;
- float finalSpacedArmorThickness = spacedArmorNominalThickness / cos(spacedArmorAngle - finalSpacedArmorNormalization);
- remainingPenetration -= finalSpacedArmorThickness;
- }
- remainingPenetration = max(remainingPenetration, 0.0);
-
- float spacedArmorDepth = texture2D(spacedArmorDepth, screenCoordinates).r;
- float spacedArmorDistance = depthToDistance(spacedArmorDepth);
- float coreArmorDistance = depthToDistance(gl_FragCoord.z);
- float distanceFromSpacedArmor = abs(coreArmorDistance - spacedArmorDistance);
- if (isExplosive) {
- // there is a 50% penetration loss per meter for HE based shells
- remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
- }
-
- bool coreArmorTwoCalibersRule = caliber > 2.0 * thickness && thickness > 0.0;
- float finalNormalization = coreArmorTwoCalibersRule ? (normalization * 1.4 * caliber) / (2.0 * thickness) : normalization;
- float finalCoreArmorThickness = thickness / cos(coreArmorAngle - finalNormalization);
- float deltaPenetration = finalCoreArmorThickness - remainingPenetration;
- float randomRadius = remainingPenetration * 0.05;
-
- if (deltaPenetration > randomRadius) {
- // even with a 5% buff to the penetration, can't penetrate
- penetrationChance = 0.0;
- } else if (deltaPenetration < -randomRadius) {
- // even with a 5% nerf, can penetrate
- penetrationChance = 1.0;
- } else {
- // transition area
- penetrationChance = max(1.0 - (deltaPenetration + randomRadius) / (2.0 * randomRadius), 0.0);
- }
-
- if (canSplash) {
- // only allow splasing if the damage equation deals more than 0 damage
- float nominalArmorThickness = finalCoreArmorThickness - remainingPenetration;
- float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * nominalArmorThickness;
- splashChance = finalDamage > 0.0 ? 1.0 : 0.0;
- } else {
- splashChance = 0.0;
- }
- }
-
- vec3 color = vec3(1.0, splashChance * 0.392, 0.0);
- if (greenPenetration || wireframe) {
- gl_FragColor = vec4(mix(color, vec3(0.0, 1.0, 0.0), penetrationChance), wireframe ? 1.0 : 0.5);
- } else {
- gl_FragColor = vec4(color, (1.0 - penetrationChance) * 0.5);
- }
-}
\ No newline at end of file
diff --git a/src/components/ArmorMesh/components/ExternalModuleMask.tsx b/src/components/ArmorMesh/components/ExternalModuleMask.tsx
deleted file mode 100644
index 1d5aed6bb..000000000
--- a/src/components/ArmorMesh/components/ExternalModuleMask.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Color, MeshBasicMaterial, Object3D, WebGLRenderTarget } from 'three';
-import { jsxTree } from '../../../core/blitzkrieg/jsxTree';
-import { ArmorMeshUserData } from './Armor';
-
-export const externalModuleMaskRenderTarget = new WebGLRenderTarget();
-
-type ArmorMeshExternalModuleMaskProps = {
- ornamental?: boolean;
- node: Object3D;
-} & (
- | {
- exclude?: false;
- maxThickness: number;
- thickness: number;
- }
- | {
- exclude: true;
- }
-);
-
-const excludeMaterial = new MeshBasicMaterial({
- colorWrite: false,
-});
-
-export function ArmorMeshExternalModuleMask({
- ornamental = false,
- node,
- ...props
-}: ArmorMeshExternalModuleMaskProps) {
- return (
- <>
- {props.exclude &&
- !ornamental &&
- jsxTree(node, { renderOrder: 0, material: excludeMaterial })}
-
- {!props.exclude &&
- jsxTree(node, {
- renderOrder: 1,
- onClick: ornamental ? () => {} : undefined,
- userData: {
- type: 'externalModule',
- thickness: props.thickness,
- } satisfies ArmorMeshUserData,
- material: new MeshBasicMaterial({
- color: new Color(1, props.thickness / props.maxThickness, 0),
- depthWrite: false,
- colorWrite: !ornamental,
- }),
- })}
- >
- );
-}
diff --git a/src/components/ArmorMesh/components/SpacedArmorDepth/index.tsx b/src/components/ArmorMesh/components/SpacedArmorDepth/index.tsx
deleted file mode 100644
index 9d61e0796..000000000
--- a/src/components/ArmorMesh/components/SpacedArmorDepth/index.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import {
- MeshBasicMaterial,
- Object3D,
- ShaderMaterial,
- WebGLRenderTarget,
-} from 'three';
-import { jsxTree } from '../../../../core/blitzkrieg/jsxTree';
-import { ArmorMeshUserData } from '../Armor';
-import fragmentShader from './shaders/fragment.glsl';
-import vertexShader from './shaders/vertex.glsl';
-
-export const spacedArmorDepthRenderTarget = new WebGLRenderTarget();
-
-type ArmorMeshSpacedArmorDepthProps = {
- ornamental?: boolean;
- node: Object3D;
-} & (
- | {
- include?: false;
- isExternalModule: boolean;
- }
- | {
- include: true;
- thickness: number;
- maxThickness: number;
- }
-);
-
-const excludeMaterial = new MeshBasicMaterial({
- colorWrite: false,
-});
-const includeMaterial = new MeshBasicMaterial({
- colorWrite: false,
- depthWrite: false,
-});
-
-export function ArmorMeshSpacedArmorDepth({
- ornamental = false,
- node,
- ...props
-}: ArmorMeshSpacedArmorDepthProps) {
- return (
- <>
- {!props.include &&
- !ornamental &&
- jsxTree(node, {
- renderOrder: props.isExternalModule ? 2 : 0,
- material: excludeMaterial,
- })}
-
- {props.include &&
- jsxTree(node, {
- renderOrder: 1,
- onClick: ornamental ? () => {} : undefined,
- userData: {
- type: 'spacedArmor',
- thickness: props.thickness,
- } satisfies ArmorMeshUserData,
- material: ornamental
- ? includeMaterial
- : new ShaderMaterial({
- fragmentShader,
- vertexShader,
- uniforms: {
- thickness: { value: props.thickness },
- maxThickness: { value: props.maxThickness },
- },
- }),
- })}
- >
- );
-}
diff --git a/src/components/ArmorMesh/components/SpacedArmorDepth/shaders/fragment.glsl b/src/components/ArmorMesh/components/SpacedArmorDepth/shaders/fragment.glsl
deleted file mode 100644
index 6fe90ce36..000000000
--- a/src/components/ArmorMesh/components/SpacedArmorDepth/shaders/fragment.glsl
+++ /dev/null
@@ -1,11 +0,0 @@
-#define PI 3.1415926535897932384626433832795
-
-varying vec3 vViewPosition;
-varying vec3 vNormal;
-uniform float thickness;
-uniform float maxThickness;
-
-void main() {
- float angle = acos(dot(vNormal, -normalize(vViewPosition)));
- gl_FragColor = vec4(angle / (PI / 2.0), thickness / maxThickness, 0.0, 1.0);
-}
\ No newline at end of file
diff --git a/src/components/ArmorMesh/index.tsx b/src/components/ArmorMesh/index.tsx
deleted file mode 100644
index b64eb57f2..000000000
--- a/src/components/ArmorMesh/index.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './components/Armor';
-export * from './components/ExternalModuleMask';
diff --git a/src/components/HeadsUpDisplay.tsx b/src/components/HeadsUpDisplay.tsx
deleted file mode 100644
index b64759a1f..000000000
--- a/src/components/HeadsUpDisplay.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { createPortal, useFrame, useThree } from '@react-three/fiber';
-import { isInteger } from 'lodash';
-import { ReactNode } from 'react';
-import { Scene } from 'three';
-
-interface HeadsUpDisplayProps {
- priority?: number;
- children: ReactNode;
-}
-/**
- * Renders `children` above the default scene, even if the priority is a
- * negative number.
- */
-export function HeadsUpDisplay({
- priority = 1,
- children,
-}: HeadsUpDisplayProps) {
- const { gl: renderer, scene: defaultScene, camera } = useThree();
- const scene = new Scene();
- const portal = createPortal(children, scene, {
- events: { priority: priority + 1 },
- });
-
- useFrame(() => {
- if (priority === 1) {
- renderer.clear();
- renderer.render(defaultScene, camera);
- }
-
- renderer.clearDepth();
- renderer.render(scene, camera);
- }, priority);
-
- renderer.autoClear = false;
- if (!isInteger(priority)) {
- throw new TypeError('Priority property must be an integer');
- }
-
- return portal;
-}
-
-/**
- * Use this component if the default scene disappears on usage of the
- * HeadsUpDisplay; caused because there are not HeadsUpDisplay components
- * with `1` priority.
- */
-export function RenderDefault() {
- const { gl: renderer, scene, camera } = useThree();
-
- useFrame(() => renderer.render(scene, camera));
-
- return null;
-}
diff --git a/src/components/ShotDisplay.tsx b/src/components/ShotDisplay.tsx
deleted file mode 100644
index 2e2c29a05..000000000
--- a/src/components/ShotDisplay.tsx
+++ /dev/null
@@ -1,226 +0,0 @@
-import { Card, Flex, Text } from '@radix-ui/themes';
-import { Html } from '@react-three/drei';
-import { useFrame } from '@react-three/fiber';
-import { ReactNode, useEffect, useRef } from 'react';
-import { Group, Mesh, Vector3 } from 'three';
-import { radToDeg } from 'three/src/math/MathUtils';
-import { isExplosive } from '../core/blitz/isExplosive';
-import { AngledPenetration } from '../icons/AngledPenetration';
-import { Block } from '../icons/Block';
-import { NominalPenetration } from '../icons/NominalPenetration';
-import { Ricochet } from '../icons/Ricochet';
-import { useDuel } from '../stores/duel';
-import { Shot, useTankopediaTemporary } from '../stores/tankopedia';
-
-export const SHOT_NAMES: Record = {
- ricochet: 'Ricochet',
- penetration: 'Penetration',
- block: 'Blocked',
-};
-
-export const SHOT_ICONS: Record = {
- ricochet: ,
- block: ,
- penetration: ,
-};
-
-const THICKNESS = 0.05;
-const LENGTH = 4;
-
-export function ShotDisplay() {
- const shot = useTankopediaTemporary((state) => state.shot);
- const entryWrapper = useRef(null);
- const entryLine = useRef(null);
- const circle = useRef(null);
- const ricochetCircle = useRef(null);
- const exitWrapper = useRef(null);
- const exitLine = useRef(null);
- const secondLineLength = shot?.ricochet ? shot?.ricochet.distance : LENGTH;
- const number = shot && (shot.thicknesses.length > 1 || shot.ricochet);
- const shell = useDuel((state) => state.antagonist!.shell);
-
- useFrame(({ clock }) => {
- const t1 = clock.elapsedTime % 1;
- const t2 = (clock.elapsedTime + 0.5) % 1;
-
- entryLine.current?.position.set(0, 0, LENGTH - t1 * LENGTH);
- entryLine.current?.scale.set(1, -2 * Math.abs(t1 - 0.5) + 1, 1);
- exitLine.current?.position.set(0, 0, t2 * secondLineLength);
- exitLine.current?.scale.set(1, -2 * Math.abs(t2 - 0.5) + 1, 1);
- });
-
- useEffect(() => {
- if (!shot) return;
-
- const point = new Vector3(...shot.point);
- const shellNormal = new Vector3(...shot.shellNormal);
- const surfaceNormal = new Vector3(...shot.surfaceNormal);
- const bounceNormal = shellNormal
- .clone()
- .multiplyScalar(-1)
- .sub(
- surfaceNormal
- .clone()
- .multiplyScalar(
- 2 * surfaceNormal.dot(shellNormal.clone().multiplyScalar(-1)),
- ),
- );
-
- entryWrapper.current?.position.set(...shot.point);
- entryWrapper.current?.lookAt(point.clone().add(shellNormal));
- exitWrapper.current?.position.set(...shot.point);
- exitWrapper.current?.lookAt(point.clone().add(bounceNormal));
- circle.current?.position.set(...shot.point);
- circle.current?.lookAt(point.clone().add(surfaceNormal));
- }, [shot]);
-
- if (!shot) return null;
-
- return (
- <>
-
-
-
-
-
-
-
-
-
-
- {SHOT_ICONS[shot.type]}
-
- {SHOT_NAMES[shot.type]} at {Math.round(radToDeg(shot.angle))}°
-
-
-
-
- {shot.thicknesses.map((layer, index) => (
-
- {number && (
-
- {index + 1}. {layer.type[0].toUpperCase()}
- {layer.type.slice(1)}
-
- )}
-
-
- {layer.type !== 'gap' && (
-
- )}
-
- {Math.round(
- layer.type === 'gap'
- ? layer.gap * 1000
- : layer.nominal,
- )}
- mm
- {layer.type === 'gap' && isExplosive(shell.type)
- ? ` (-${Math.round(Math.min(100, 0.5 * layer.gap * 100))}% penetration)`
- : ''}
-
-
- {layer.type !== 'external' && layer.type !== 'gap' && (
-
-
- {Math.round(layer.angled)}mm
-
- )}
-
- ))}
-
- {shot.ricochet && (
- <>
-
- {shot.thicknesses.length + 1}. Ricochet
-
- (-25% penetration)
-
- {shot.ricochet.penetration ? (
-
- {shot.thicknesses.length + 2}. Penetration
-
-
- ) : (
-
- {shot.thicknesses.length + 2}. Blocked
-
-
- )}
- >
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {shot.type === 'ricochet' && (
-
-
-
-
-
-
-
-
-
-
- {shot.ricochet && (
-
-
-
-
- )}
-
- )}
- >
- );
-}
diff --git a/src/constants/axis.ts b/src/constants/axis.ts
index 9d600d2be..784321114 100644
--- a/src/constants/axis.ts
+++ b/src/constants/axis.ts
@@ -1,5 +1,6 @@
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);
diff --git a/src/core/blitzkrieg/hasEquipment.ts b/src/core/blitzkrieg/hasEquipment.ts
index 396153b31..d0ddf713f 100644
--- a/src/core/blitzkrieg/hasEquipment.ts
+++ b/src/core/blitzkrieg/hasEquipment.ts
@@ -1,15 +1,17 @@
import { useDuel } from '../../stores/duel';
import { equipmentDefinitions } from './equipmentDefinitions';
-export async function hasEquipment(id: number, antagonist = false) {
+export async function hasEquipment(
+ id: number,
+ antagonist = false,
+ equipmentMatrix = useDuel.getState()[
+ antagonist ? 'antagonist' : 'protagonist'
+ ]!.equipment,
+) {
const awaitedEquipmentDefinitions = await equipmentDefinitions;
const member = useDuel.getState()[antagonist ? 'antagonist' : 'protagonist']!;
- const equipmentRows =
- awaitedEquipmentDefinitions.presets[member.tank.equipment];
- const equipmentMatrix =
- useDuel.getState()[antagonist ? 'antagonist' : 'protagonist']!.equipment;
-
- return equipmentRows.some((equipmentRow, rowIndex) => {
+ const presetRows = awaitedEquipmentDefinitions.presets[member.tank.equipment];
+ return presetRows.some((equipmentRow, rowIndex) => {
return equipmentRow.some((equipment, columnIndex) => {
const choice = equipmentMatrix[rowIndex][columnIndex];
if (choice === 0) return false;
diff --git a/src/core/math/NaNFallback.ts b/src/core/math/NaNFallback.ts
new file mode 100644
index 000000000..d1b93b662
--- /dev/null
+++ b/src/core/math/NaNFallback.ts
@@ -0,0 +1,3 @@
+export function NaNFallback(value: number, fallback: number) {
+ return isNaN(value) ? fallback : value;
+}
diff --git a/src/core/math/normalToEuler.ts b/src/core/math/normalToEuler.ts
new file mode 100644
index 000000000..b3b005e96
--- /dev/null
+++ b/src/core/math/normalToEuler.ts
@@ -0,0 +1,14 @@
+import { Euler, Quaternion, Vector3 } from 'three';
+import { J_HAT } from '../../constants/axis';
+
+const quaternion = new Quaternion();
+const axis = new Vector3();
+
+export function normalToEuler(normal: Vector3, up = J_HAT) {
+ return new Euler().setFromQuaternion(
+ quaternion.setFromAxisAngle(
+ axis.crossVectors(up, normal).normalize(),
+ up.angleTo(normal),
+ ),
+ );
+}
diff --git a/src/stores/app.ts b/src/stores/app.ts
index 1c1366937..e615eb1f9 100644
--- a/src/stores/app.ts
+++ b/src/stores/app.ts
@@ -1,3 +1,4 @@
+import { merge } from 'lodash';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
@@ -10,6 +11,6 @@ export const useApp = create()(
(set) => ({
devBuildAgreementTime: 0,
}),
- { name: 'app' },
+ { name: 'app', merge: (a, b) => merge(b, a) },
),
);
diff --git a/src/stores/session.ts b/src/stores/session.ts
index ffc8b46c9..3a2011e9f 100644
--- a/src/stores/session.ts
+++ b/src/stores/session.ts
@@ -1,4 +1,5 @@
import { produce } from 'immer';
+import { merge } from 'lodash';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { CustomColumnDisplay } from '../app/tools/session/components/CustomColumn';
@@ -83,7 +84,7 @@ export const useSession = create()(
],
color: 'slate',
}),
- { name: 'session' },
+ { name: 'session', merge: (a, b) => merge(b, a) },
),
);
diff --git a/src/stores/tankopedia.ts b/src/stores/tankopedia.ts
index f2f8b5d26..3cbaeb781 100644
--- a/src/stores/tankopedia.ts
+++ b/src/stores/tankopedia.ts
@@ -1,9 +1,10 @@
import { produce } from 'immer';
import { merge } from 'lodash';
-import { Vector3Tuple } from 'three';
+import { Vector3 } from 'three';
import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import { ENVIRONMENTS } from '../app/tools/tankopedia/[id]/components/Lighting';
+import { ArmorType } from '../components/Armor/components/SpacedArmorScene';
import { TankType, TreeType } from '../components/Tanks';
import { Tier } from '../core/blitzkrieg/tankDefinitions';
@@ -12,10 +13,11 @@ export type TankopediaSortDirection = 'ascending' | 'descending';
export type TankopediaTestTankDisplay = 'include' | 'exclude' | 'only';
export type TankopediaMode = 'model' | 'armor';
-interface TankopediaPersistent {
+export interface TankopediaPersistent {
model: {
visual: {
wireframe: boolean;
+ opaque: boolean;
environment: (typeof ENVIRONMENTS)[number];
controlsEnabled: boolean;
showGrid: boolean;
@@ -42,20 +44,38 @@ interface TankopediaTemporary {
shot?: Shot;
}
-export interface Shot {
- point: Vector3Tuple;
- surfaceNormal: Vector3Tuple;
- shellNormal: Vector3Tuple;
- thicknesses: ArmorPiercingLayer[];
- type: 'ricochet' | 'penetration' | 'block';
- angle: number;
- ricochet?: {
- distance: number;
- point: Vector3Tuple;
- penetration: boolean;
- };
+export interface ShotLayerBase {
+ index: number;
+ thickness: number;
+ point: Vector3;
+ shellNormal: Vector3;
+ surfaceNormal: Vector3;
+ status: 'blocked' | 'penetration' | 'ricochet';
}
+interface ShotLayerExternal extends ShotLayerBase {
+ type: ArmorType.External;
+}
+
+export interface ShotLayerNonExternal extends ShotLayerBase {
+ type: Exclude;
+ angle: number;
+ thicknessAngled: number;
+}
+
+export interface ShotLayerGap {
+ type: null;
+ status: 'penetration' | 'blocked';
+ distance: number;
+}
+
+export type ShotLayer = ShotLayerExternal | ShotLayerNonExternal | ShotLayerGap;
+
+export type Shot = {
+ point: Vector3;
+ layers: ShotLayer[];
+};
+
export type ArmorPiercingLayer =
| {
distance: number;
@@ -104,6 +124,7 @@ export const useTankopediaPersistent = create()(
model: {
visual: {
wireframe: false,
+ opaque: false,
environment: ENVIRONMENTS[0],
controlsEnabled: true,
showGrid: true,
@@ -125,7 +146,7 @@ export const useTankopediaPersistent = create()(
},
mode: 'model',
})),
- { name: 'tankopedia', merge },
+ { name: 'tankopedia', merge: (a, b) => merge(b, a) },
),
);