Merge pull request #81 from tresabhi/1.16.1

This commit is contained in:
TrèsAbhi authored and GitHub committed 2024-02-29 16:05:48 -06:00
commit 7fb64e690f
37 files changed
+1683 -1781

No files matched your search

@@ -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() {
<SceneProps />
<Suspense fallback={<ModelLoader />}>
<ShotDisplay />
{/* TODO: add rotation cube to the top right */}
<Lighting />
<TankModel />
<TankArmor />
<ExternalModuleMask ornamental />
<SpacedArmorDepth ornamental />
<Armor />
<ShotDisplay />
</Suspense>
</Canvas>
</div>
@@ -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) {
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>Armor</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={greenPenetration}
onCheckedChange={(checked) => {
@@ -89,6 +92,30 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
Green penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={wireframe}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.wireframe = checked;
});
}}
>
Wireframe
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={opaque}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.opaque = checked;
});
}}
>
Opaque
</DropdownMenu.CheckboxItem>
<DropdownMenu.Label>Environment</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={showGrid}
onCheckedChange={(checked) => {
@@ -132,17 +159,6 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.CheckboxItem
checked={wireframe}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.wireframe = checked;
});
}}
>
Wireframe
</DropdownMenu.CheckboxItem>
</DropdownMenu.Content>
</DropdownMenu.Root>
)}
@@ -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<ExternalModuleMaskProps>(
({ ornamental = false }) => {
const protagonist = useDuel((state) => state.protagonist!);
const wrapper = useRef<Group>(null);
const modelDefinitions = useModelDefinitions();
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(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 (
<group
ref={wrapper}
rotation={[-Math.PI / 2, 0, 0]}
visible={initialTankopediaState.mode === 'armor'}
>
<group position={hullOrigin}>
{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 (
<ArmorMeshExternalModuleMask
ornamental={ornamental}
exclude
key={node.uuid}
node={node}
/>
);
})}
</group>
{modelNodes.map((node) => {
const isWheel = node.name.startsWith('chassis_wheel_');
const isTrack = node.name.startsWith('chassis_track_');
const isVisible = isWheel || isTrack;
if (!isVisible) return null;
return (
<ArmorMeshExternalModuleMask
ornamental={ornamental}
maxThickness={maxThickness}
thickness={trackModelDefinition.thickness}
key={node.uuid}
node={node}
/>
);
})}
<group ref={turretContainer}>
<group position={hullOrigin}>
{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 (
<group key={node.uuid} position={turretOrigin}>
<ArmorMeshExternalModuleMask
ornamental={ornamental}
exclude
node={node}
/>
</group>
);
})}
</group>
<group ref={gunContainer}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name);
const { thickness, spaced } = resolveArmor(
gunModelDefinition.armor,
armorId,
);
if (!isVisible || thickness === undefined || spaced) return null;
return (
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<ArmorMeshExternalModuleMask
ornamental={ornamental}
exclude
node={node}
/>
</group>
);
})}
{modelNodes.map((node) => {
const isCurrentGun =
node.name ===
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
const isVisible = isCurrentGun;
if (!isVisible) return null;
return (
<ArmorMeshExternalModuleMask
ornamental={ornamental}
maxThickness={maxThickness}
thickness={gunModelDefinition.thickness}
key={node.uuid}
node={node}
/>
);
})}
</group>
</group>
</group>
);
},
);
@@ -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<SpacedArmorDepthProps>(
({ ornamental = false }) => {
const protagonist = useDuel((state) => state.protagonist!);
const wrapper = useRef<Group>(null);
const modelDefinitions = useModelDefinitions();
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(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 (
<group
ref={wrapper}
rotation={[-Math.PI / 2, 0, 0]}
visible={initialTankopediaState.mode === 'armor'}
>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
if (!isVisible || thickness === undefined) return null;
return (
<ArmorMeshSpacedArmorDepth
ornamental={ornamental}
include={spaced}
isExternalModule={false}
thickness={thickness}
maxThickness={maxThickness}
key={node.uuid}
node={node}
/>
);
})}
</group>
{modelNodes.map((node) => {
const isWheel = node.name.startsWith('chassis_wheel_');
const isTrack = node.name.startsWith('chassis_track_');
const isVisible = isWheel || isTrack;
if (!isVisible) return null;
return (
<ArmorMeshSpacedArmorDepth
ornamental={ornamental}
isExternalModule
key={node.uuid}
node={node}
/>
);
})}
<group ref={turretContainer}>
<group position={hullOrigin}>
{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 (
<group key={node.uuid} position={turretOrigin}>
<ArmorMeshSpacedArmorDepth
ornamental={ornamental}
include={spaced}
isExternalModule={false}
thickness={thickness}
maxThickness={maxThickness}
node={node}
/>
</group>
);
})}
</group>
<group ref={gunContainer}>
<group position={hullOrigin}>
{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 (
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<ArmorMeshSpacedArmorDepth
ornamental={ornamental}
include={spaced}
isExternalModule={false}
thickness={thickness}
maxThickness={maxThickness}
node={node}
/>
</group>
);
})}
</group>
{modelNodes.map((node) => {
const isCurrentGun =
node.name ===
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
const isVisible = isCurrentGun;
if (!isVisible) return null;
return (
<ArmorMeshSpacedArmorDepth
ornamental={ornamental}
isExternalModule
key={node.uuid}
node={node}
/>
);
})}
</group>
</group>
</group>
);
},
);
@@ -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(
<ExternalModuleMask />,
externalModuleMaskScene,
);
const spacedArmorDepthPortal = createPortal(
<SpacedArmorDepth />,
spacedArmorDepthScene,
);
const armorHighlightingPortal = createPortal(
<ArmorHighlighting />,
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}
</>
);
});
+2
View File
@@ -63,6 +63,8 @@ export default function Page({ params }: { params: { id: string } }) {
};
}, [initialId, assigned]);
return (
<PageWrapper color="purple" size="double">
{assigned && (
@@ -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<Group>(null);
const modelDefinitions = useModelDefinitions();
const gunContainer = useRef<Group>(null);
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(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 (
<group
ref={wrapper}
rotation={[-Math.PI / 2, 0, 0]}
visible={initialTankopediaState.mode === 'armor'}
position={hullOrigin}
>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
if (!isVisible || thickness === undefined || spaced) return null;
return (
<ArmorMesh
maxThickness={maxThickness}
key={node.uuid}
geometry={(node as Mesh).geometry}
thickness={thickness}
/>
);
})}
<group ref={turretContainer}>
<group position={hullOrigin}>
{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 (
<ArmorMesh
maxThickness={maxThickness}
<CoreArmorSceneComponent
key={node.uuid}
geometry={(node as Mesh).geometry}
position={turretOrigin}
thickness={thickness}
node={node}
/>
);
})}
</group>
<group ref={gunContainer}>
<group ref={turretContainer}>
<group position={hullOrigin}>
{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 (
<ArmorMesh
maxThickness={maxThickness}
key={node.uuid}
geometry={(node as Mesh).geometry}
position={turretOrigin.clone().add(gunOrigin)}
thickness={thickness}
/>
<group key={node.uuid} position={turretOrigin}>
<CoreArmorSceneComponent
key={node.uuid}
thickness={thickness}
node={node}
/>
</group>
);
})}
</group>
<group ref={gunContainer}>
<group position={hullOrigin}>
{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 (
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<CoreArmorSceneComponent thickness={thickness} node={node} />
</group>
);
})}
</group>
</group>
</group>
</group>
);
}
});
@@ -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,
})}
</>
);
}
@@ -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);
}
}
@@ -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 | 'null', string> = {
[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<Mesh>(null);
const postMidPointTracer = useRef<Mesh>(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<ShotLayer, ShotLayerGap>;
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 (
<>
<group
position={midPointLayer.point}
rotation={normalToEuler(midPointLayer.shellNormal)}
>
<mesh position={[0, preMidPointGap! / 2, 0]}>
<cylinderGeometry args={[TRACER_THIN, TRACER_THIN, preMidPointGap]} />
<meshBasicMaterial depthTest={false} />
</mesh>
<mesh position={[0, preMidPointGap! / 2, 0]} ref={preMidPointTracer}>
<cylinderGeometry
args={[TRACER_THICK, TRACER_THICK, preMidPointGap]}
/>
<meshBasicMaterial
color={
lastLayer.status === 'blocked'
? 0xff4040
: lastLayer.status === 'penetration'
? 0x00ff00
: 0xffff00
}
/>
</mesh>
</group>
{midPointLayer.status === 'ricochet' && (
<group
position={midPointLayer.point}
rotation={normalToEuler(
midPointLayer.shellNormal
.clone()
.reflect(midPointLayer.surfaceNormal)
.multiplyScalar(-1),
)}
>
<mesh position={[0, postMidPointGap! / 2, 0]}>
<cylinderGeometry
args={[TRACER_THIN, TRACER_THIN, postMidPointGap]}
/>
<meshBasicMaterial depthTest={false} />
</mesh>
<mesh
position={[0, postMidPointGap! / 2, 0]}
ref={postMidPointTracer}
>
<cylinderGeometry
args={[TRACER_THICK, TRACER_THICK, postMidPointGap]}
/>
<meshBasicMaterial color={0xffff00} />
</mesh>
</group>
)}
{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 (
<>
<group
key={`${index}-line`}
position={layer.point}
rotation={shellRotation}
>
<Html
center
style={{
pointerEvents: 'none',
}}
>
<Card
style={{
backgroundColor:
trueStatus === 'penetration'
? '#00ff00c0'
: trueStatus === 'blocked'
? '#ff0000c0'
: '#ffff00c0',
border: 'none',
}}
>
<Inset>
{hasMultipleLayers && (
<Text
style={{
width: '100%',
display: 'block',
textAlign: 'center',
color:
trueStatus === 'ricochet' ? 'black' : undefined,
}}
>
<b>{layer.index + 1}</b>
</Text>
)}
</Inset>
</Card>
</Html>
</group>
</>
);
})}
<Html
position={midPointLayer.point}
style={{
transform: 'translateY(-50%)',
marginLeft: 32,
pointerEvents: 'none',
}}
>
<Card style={{ width: 256 + 32 }}>
<Flex direction="column">
<Text>
{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`}
</Text>
{shot.layers.map((layer, index) => {
if (layer.type === null && !explosive) return null;
return (
<Flex key={index} align="center" gap="2">
<Flex>
{hasMultipleLayers && (
<Text size="2" style={{ width: 16 }}>
{layer.type === null ? null : `${layer.index + 1}.`}
</Text>
)}
<Text size="2">{layerTypeNames[`${layer.type}`]}</Text>
</Flex>
{layer.type !== null && (
<>
<Flex align="center" style={{ width: 64 }}>
<NominalPenetration width={16} height={16} />
<Text size="2">{Math.round(layer.thickness)}mm</Text>
</Flex>
{layer.type !== ArmorType.External && (
<Flex align="center">
{layer.status === 'ricochet' ? (
<Ricochet width={16} height={16} />
) : layer.status === 'blocked' ? (
<Block width={16} height={16} />
) : (
<AngledPenetration width={16} height={16} />
)}
<Text size="2">
{Math.round(layer.thicknessAngled)}mm (
{Math.round(radToDeg(layer.angle))}°)
</Text>
</Flex>
)}
</>
)}
{layer.type === null && (
<Text size="2">
{Math.round(layer.distance * 1000)}mm (-
{Math.round(Math.min(100, layer.distance * 50))}%
penetration)
</Text>
)}
</Flex>
);
})}
</Flex>
</Card>
</Html>
</>
);
}
@@ -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<SpacedArmorSceneProps>(({ scene }) => {
const wrapper = useRef<Group>(null);
const modelDefinitions = useModelDefinitions();
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(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 (
<group
ref={wrapper}
rotation={[-Math.PI / 2, 0, 0]}
visible={initialTankopediaState.mode === 'armor'}
>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
if (!isVisible || thickness === undefined) return null;
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Core}
thickness={thickness}
node={node}
/>
);
})}
</group>
{modelNodes.map((node) => {
const isWheel = node.name.startsWith('chassis_wheel_');
const isTrack = node.name.startsWith('chassis_track_');
const isVisible = isWheel || isTrack;
if (!isVisible) return null;
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={ArmorType.External}
thickness={trackModelDefinition.thickness}
variant="track"
node={node}
/>
);
})}
<group ref={turretContainer}>
<group position={hullOrigin}>
{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 (
<group key={node.uuid} position={turretOrigin}>
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Core}
thickness={thickness}
node={node}
/>
</group>
);
})}
</group>
<group ref={gunContainer}>
<group position={hullOrigin}>
{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 (
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<SpacedArmorSceneComponent
scene={scene}
type={spaced ? ArmorType.Spaced : ArmorType.Core}
thickness={thickness}
node={node}
/>
</group>
);
})}
</group>
{modelNodes.map((node) => {
const isCurrentGun =
node.name ===
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
const isVisible = isCurrentGun;
if (!isVisible) return null;
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={ArmorType.External}
thickness={gunModelDefinition.thickness}
variant="gun"
node={node}
/>
);
})}
</group>
</group>
</group>
);
});
@@ -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,
})}
</>
);
}
@@ -0,0 +1,6 @@
uniform float thickness;
uniform float penetration;
void main() {
gl_FragColor = vec4(thickness / penetration, 0.0, 0.0, 1.0);
}
@@ -0,0 +1,4 @@
void main() {
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * vViewPosition4;
}
@@ -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,
})}
</>
);
}
@@ -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);
}
@@ -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;
}
@@ -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<ArmorType, ArmorType.External>;
}
| {
type: ArmorType.External;
variant: ExternalModuleVariant;
}
);
export type ExternalModuleVariant = 'gun' | 'track';
export type ArmorUserData = {
thickness: number;
} & (
| {
type: Exclude<ArmorType, ArmorType.External>;
}
| {
type: ArmorType.External;
variant: ExternalModuleVariant;
}
);
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 && (
<SpacedArmorSubSpaced node={node} thickness={thickness} />
)}
{props.type === ArmorType.External && (
<SpacedArmorSubExternal
node={node}
thickness={thickness}
variant={props.variant}
/>
)}
</>
);
}
+52
View File
@@ -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 scene={spacedArmorScene} />,
spacedArmorScene,
);
const coreArmorPortal = createPortal(<CoreArmorScene />, 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}
</>
);
});
@@ -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<ArmorMeshProps>(
({ thickness, maxThickness, ...props }) => {
const camera = useThree((state) => state.camera);
const initialShell = useDuel.getState().antagonist!.shell;
const initialTankopedia = useTankopediaPersistent.getState();
const material = useRef<ShaderMaterial>(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<Mesh>(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<Vector2>
).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 (
<>
<mesh {...props} renderOrder={0}>
<meshBasicMaterial colorWrite={false} />
</mesh>
<mesh
{...props}
renderOrder={1}
userData={
{
type: 'coreArmor',
thickness,
} satisfies ArmorMeshUserData
}
ref={mesh}
onClick={async (event) => {
// 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<ArmorMeshUserData['type']>();
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,
};
});
}}
>
<shaderMaterial
wireframe={wireframe}
ref={material}
fragmentShader={fragmentShader}
vertexShader={vertexShader}
transparent
depthWrite={false}
uniforms={{
resolution: { value: new Vector2() },
externalModuleMask: { value: null },
spacedArmorDepth: { value: null },
spacedArmorMask: { value: null },
projectionMatrixInverse: { value: null },
zNear: { value: camera.near },
zFar: { value: camera.far },
greenPenetration: {
value: initialTankopedia.model.visual.greenPenetration,
},
maxThickness: { value: maxThickness },
isExplosive: { value: explosionCapable },
canRicochet: { value: ricochetCapable },
canSplash: { value: canSplash(initialShell.type) },
thickness: {
value: thickness * (hasEnhancedArmor ? 1.04 : 1),
},
penetration: {
value:
resolveNearPenetration(initialShell.penetration) *
(hasCalibratedShells ? (explosionCapable ? 1.1 : 1.05) : 1),
},
caliber: { value: initialShell.caliber },
ricochetAngle: { value: degToRad(initialShell.ricochet ?? 90) },
normalization: {
value: degToRad(initialShell.normalization ?? 0),
},
damage: { value: initialShell.damage.armor },
explosionRadius: { value: initialShell.explosionRadius ?? 0 },
wireframe: { value: wireframe },
}}
/>
</mesh>
</>
);
},
() => true,
);
@@ -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);
}
}
@@ -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,
}),
})}
</>
);
}
@@ -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 },
},
}),
})}
</>
);
}
@@ -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);
}
-2
View File
@@ -1,2 +0,0 @@
export * from './components/Armor';
export * from './components/ExternalModuleMask';
-53
View File
@@ -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;
}
-226
View File
@@ -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<Shot['type'], string> = {
ricochet: 'Ricochet',
penetration: 'Penetration',
block: 'Blocked',
};
export const SHOT_ICONS: Record<Shot['type'], ReactNode> = {
ricochet: <Ricochet width={24} height={24} />,
block: <Block width={24} height={24} />,
penetration: <NominalPenetration width={24} height={24} />,
};
const THICKNESS = 0.05;
const LENGTH = 4;
export function ShotDisplay() {
const shot = useTankopediaTemporary((state) => state.shot);
const entryWrapper = useRef<Group>(null);
const entryLine = useRef<Mesh>(null);
const circle = useRef<Mesh>(null);
const ricochetCircle = useRef<Mesh>(null);
const exitWrapper = useRef<Group>(null);
const exitLine = useRef<Mesh>(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 (
<>
<mesh ref={circle}>
<torusGeometry args={[0.05, 0.0125, 4, 16]} />
<meshBasicMaterial
color={shot.type === 'penetration' ? 'green' : 'red'}
depthTest={false}
/>
</mesh>
<group ref={entryWrapper}>
<Html
position={[0, 0, 0]}
style={{
paddingLeft: 16,
}}
>
<Card
style={{
width: number ? 300 : 200,
userSelect: 'none',
}}
>
<Flex direction="column" gap="1">
<Flex align="center" gap="2">
{SHOT_ICONS[shot.type]}
<Text>
{SHOT_NAMES[shot.type]} at {Math.round(radToDeg(shot.angle))}°
</Text>
</Flex>
<Flex
direction="column"
style={{
paddingLeft: number ? 8 : undefined,
}}
>
{shot.thicknesses.map((layer, index) => (
<Flex gap="2" key={index}>
{number && (
<Text>
{index + 1}. {layer.type[0].toUpperCase()}
{layer.type.slice(1)}
</Text>
)}
<Flex gap="1">
{layer.type !== 'gap' && (
<NominalPenetration width={24} height={24} />
)}
<Text>
{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)`
: ''}
</Text>
</Flex>
{layer.type !== 'external' && layer.type !== 'gap' && (
<Flex gap="1">
<AngledPenetration width={24} height={24} />
<Text>{Math.round(layer.angled)}mm</Text>
</Flex>
)}
</Flex>
))}
{shot.ricochet && (
<>
<Flex gap="1">
<Text>{shot.thicknesses.length + 1}. Ricochet</Text>
<Ricochet width={24} height={24} />
<Text>(-25% penetration)</Text>
</Flex>
{shot.ricochet.penetration ? (
<Flex gap="1">
<Text>{shot.thicknesses.length + 2}. Penetration</Text>
<NominalPenetration width={24} height={24} />
</Flex>
) : (
<Flex gap="1">
<Text>{shot.thicknesses.length + 2}. Blocked</Text>
<Block width={24} height={24} />
</Flex>
)}
</>
)}
</Flex>
</Flex>
</Card>
</Html>
<mesh
renderOrder={1}
position={[0, 0, LENGTH / 2]}
rotation={[Math.PI / 2, 0, 0]}
ref={entryLine}
>
<cylinderGeometry args={[THICKNESS / 2, THICKNESS / 2, LENGTH, 8]} />
<meshBasicMaterial color="#ffff80" />
</mesh>
<mesh position={[0, 0, LENGTH / 2]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[THICKNESS / 4, THICKNESS / 4, LENGTH, 8]} />
<meshBasicMaterial color="#ffffff" depthTest={false} />
</mesh>
</group>
{shot.type === 'ricochet' && (
<group ref={exitWrapper}>
<mesh
renderOrder={1}
position={[0, 0, secondLineLength / 2]}
rotation={[Math.PI / 2, 0, 0]}
ref={exitLine}
>
<cylinderGeometry
args={[THICKNESS / 2, THICKNESS / 2, secondLineLength, 8]}
/>
<meshBasicMaterial color="#ff8080" />
</mesh>
<mesh
position={[0, 0, secondLineLength / 2]}
rotation={[Math.PI / 2, 0, 0]}
>
<cylinderGeometry
args={[THICKNESS / 4, THICKNESS / 4, secondLineLength, 8]}
/>
<meshBasicMaterial color="#ffffff" depthTest={false} />
</mesh>
{shot.ricochet && (
<mesh ref={ricochetCircle} position={[0, 0, secondLineLength]}>
<icosahedronGeometry args={[0.05, 1]} />
<meshBasicMaterial
color={shot.ricochet.penetration ? 'green' : 'red'}
depthTest={false}
/>
</mesh>
)}
</group>
)}
</>
);
}
+1
View File
@@ -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);
+9 -7
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
export function NaNFallback(value: number, fallback: number) {
return isNaN(value) ? fallback : value;
}
+14
View File
@@ -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),
),
);
}
+2 -1
View File
@@ -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<App>()(
(set) => ({
devBuildAgreementTime: 0,
}),
{ name: 'app' },
{ name: 'app', merge: (a, b) => merge(b, a) },
),
);
+2 -1
View File
@@ -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<Session>()(
],
color: 'slate',
}),
{ name: 'session' },
{ name: 'session', merge: (a, b) => merge(b, a) },
),
);
+36 -15
View File
@@ -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<ArmorType, ArmorType.External>;
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<TankopediaPersistent>()(
model: {
visual: {
wireframe: false,
opaque: false,
environment: ENVIRONMENTS[0],
controlsEnabled: true,
showGrid: true,
@@ -125,7 +146,7 @@ export const useTankopediaPersistent = create<TankopediaPersistent>()(
},
mode: 'model',
})),
{ name: 'tankopedia', merge },
{ name: 'tankopedia', merge: (a, b) => merge(b, a) },
),
);