mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-24 20:19:49 +02:00
feat: out tracer
This commit is contained in:
1 parent
7f8797a449
commit
e16f3653bb
3 files changed
+484
-333
No files matched your search
@@ -1,10 +1,15 @@
|
|||||||
import { Card, Flex, Kbd, Text } from '@radix-ui/themes';
|
import { Card, Flex, Inset, Kbd, Separator, Text } from '@radix-ui/themes';
|
||||||
import { Html } from '@react-three/drei';
|
import { Html } from '@react-three/drei';
|
||||||
import { useFrame } from '@react-three/fiber';
|
import { useFrame } from '@react-three/fiber';
|
||||||
import { ComponentProps, useRef } from 'react';
|
import { ComponentProps, useRef } from 'react';
|
||||||
import { Euler, Mesh, Quaternion } from 'three';
|
import { Euler, Mesh, Quaternion } from 'three';
|
||||||
|
import { radToDeg } from 'three/src/math/MathUtils';
|
||||||
import { J_HAT } from '../../../constants/axis';
|
import { J_HAT } from '../../../constants/axis';
|
||||||
import { ShotStatus, useTankopediaTemporary } from '../../../stores/tankopedia';
|
import {
|
||||||
|
ShotLayer,
|
||||||
|
ShotStatus,
|
||||||
|
useTankopediaTemporary,
|
||||||
|
} from '../../../stores/tankopedia';
|
||||||
import { ArmorType } from './SpacedArmorScene';
|
import { ArmorType } from './SpacedArmorScene';
|
||||||
import { ExternalModuleVariant } from './SpacedArmorSceneComponent';
|
import { ExternalModuleVariant } from './SpacedArmorSceneComponent';
|
||||||
|
|
||||||
@@ -22,8 +27,8 @@ const externalLayerNames: Record<ExternalModuleVariant, string> = {
|
|||||||
|
|
||||||
const shotStatusNames: Record<ShotStatus, string> = {
|
const shotStatusNames: Record<ShotStatus, string> = {
|
||||||
blocked: 'Blocked',
|
blocked: 'Blocked',
|
||||||
|
ricochet: 'Ricochet',
|
||||||
penetration: 'penetration',
|
penetration: 'penetration',
|
||||||
ricochet: 'ricochet',
|
|
||||||
splash: 'splash',
|
splash: 'splash',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,18 +46,107 @@ const LENGTH_INFINITY = 4;
|
|||||||
const TRACER_THICK = 1 / 64;
|
const TRACER_THICK = 1 / 64;
|
||||||
const TRACER_THIN = TRACER_THICK / 2;
|
const TRACER_THIN = TRACER_THICK / 2;
|
||||||
|
|
||||||
|
function LayerEntry({
|
||||||
|
layerIndex,
|
||||||
|
shotStatusColor,
|
||||||
|
layerName,
|
||||||
|
layer,
|
||||||
|
angle,
|
||||||
|
}: {
|
||||||
|
layerIndex?: string;
|
||||||
|
shotStatusColor: ComponentProps<typeof Text>['color'];
|
||||||
|
layerName: string;
|
||||||
|
layer: ShotLayer;
|
||||||
|
angle?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Flex align="center" gap="2">
|
||||||
|
<Text
|
||||||
|
size="1"
|
||||||
|
color="gray"
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layerIndex}
|
||||||
|
</Text>
|
||||||
|
<Text size="2" color={shotStatusColor} style={{ width: 47 }}>
|
||||||
|
{layerName}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{layer.type === null && (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
size="2"
|
||||||
|
color={shotStatusColor}
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(layer.distance * 1000).toLocaleString()}
|
||||||
|
<Text size="1">mm</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text size="2" color="gray">
|
||||||
|
{Math.max(-100, Math.round(-50 * layer.distance))}% penetration
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{layer.type === ArmorType.External && (
|
||||||
|
<Text
|
||||||
|
size="2"
|
||||||
|
color={shotStatusColor}
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(layer.thickness).toLocaleString()}
|
||||||
|
<Text size="1">mm</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(layer.type === ArmorType.Core || layer.type === ArmorType.Spaced) && (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
size="2"
|
||||||
|
color={shotStatusColor}
|
||||||
|
style={{
|
||||||
|
width: 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(layer.thicknessAngled).toLocaleString()}
|
||||||
|
<Text size="1">{`mm${angle === undefined ? '' : ` ${Math.round(radToDeg(angle))}°`}`}</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text size="2" color="gray">
|
||||||
|
{Math.round(layer.thickness).toLocaleString()}
|
||||||
|
<Text size="1">mm</Text> nominal
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ShotDisplay() {
|
export function ShotDisplay() {
|
||||||
const shot = useTankopediaTemporary((state) => state.shot);
|
const shot = useTankopediaTemporary((state) => state.shot);
|
||||||
const inTracer = useRef<Mesh>(null);
|
const inTracer = useRef<Mesh>(null);
|
||||||
|
const outTracer = useRef<Mesh>(null);
|
||||||
|
|
||||||
useFrame(({ clock }) => {
|
useFrame(({ clock }) => {
|
||||||
if (!shot || !inTracer.current) return;
|
if (!shot || !inTracer.current) return;
|
||||||
|
|
||||||
const t = clock.elapsedTime % 1;
|
const t1 = clock.elapsedTime % 1;
|
||||||
const scale1 = -Math.abs(2 * t - 1) + 1;
|
const t2 = (clock.elapsedTime + 0.5) % 1;
|
||||||
|
|
||||||
inTracer.current.scale.set(1, scale1, 1);
|
inTracer.current.scale.set(1, 1 - 2 * Math.abs(t1 - 0.5), 1);
|
||||||
inTracer.current.position.set(0, (1 - t) * inLength, 0);
|
inTracer.current.position.set(0, (1 - t1) * inLength, 0);
|
||||||
|
|
||||||
|
if (!outTracer.current) return;
|
||||||
|
|
||||||
|
outTracer.current.scale.set(1, 1 - 2 * Math.abs(t2 - 0.5), 1);
|
||||||
|
outTracer.current.position.set(0, t2 * inLength, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!shot) return null;
|
if (!shot) return null;
|
||||||
@@ -74,108 +168,103 @@ export function ShotDisplay() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card ml="9" style={{ width: 300 }}>
|
<Card ml="9" style={{ width: 300 }}>
|
||||||
<Flex direction="column" gap="1">
|
<Flex direction="column" gap="2">
|
||||||
<Text color={shotStatusColors[shot.in.status]} weight="bold">
|
<Flex direction="column" gap="1">
|
||||||
{shot.in.status !== 'blocked' && (
|
<Text color={shotStatusColors[shot.in.status]} weight="bold">
|
||||||
<>
|
{shot.in.status !== 'blocked' &&
|
||||||
{Math.round(shot.damage).toLocaleString()}
|
shot.in.status !== 'ricochet' && (
|
||||||
<Text size="1">hp</Text>
|
<>
|
||||||
</>
|
{Math.round(shot.damage).toLocaleString()}
|
||||||
)}
|
<Text size="1">hp</Text>
|
||||||
{` ${shotStatusNames[shot.in.status]} `}
|
</>
|
||||||
</Text>
|
)}
|
||||||
|
{` ${shotStatusNames[shot.in.status]} `}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Flex direction="column">
|
<Flex direction="column">
|
||||||
{shot.in.layers.map((layer, index) => {
|
{shot.in.layers.map((layer, index) => {
|
||||||
const layerName =
|
const layerName =
|
||||||
layer.type === ArmorType.External
|
layer.type === ArmorType.External
|
||||||
? externalLayerNames[layer.variant]
|
? externalLayerNames[layer.variant]
|
||||||
: layerTypeNames[`${layer.type}`];
|
: layerTypeNames[`${layer.type}`];
|
||||||
const layerIndex =
|
const layerIndex =
|
||||||
layer.type === null
|
layer.type === null
|
||||||
? null
|
? undefined
|
||||||
: `${(shot.containsGaps ? index / 2 : index) + 1}.`;
|
: `${(shot.containsGaps ? index / 2 : index) + 1}.`;
|
||||||
const shotStatusColor =
|
const shotStatusColor =
|
||||||
shot.in.status === 'splash'
|
shot.in.status === 'splash'
|
||||||
? shotStatusColors.splash
|
? shotStatusColors.splash
|
||||||
: shotStatusColors[layer.status];
|
: shotStatusColors[layer.status];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex align="center" gap="2">
|
<LayerEntry
|
||||||
<Text
|
layer={layer}
|
||||||
size="1"
|
layerIndex={layerIndex}
|
||||||
color="gray"
|
layerName={layerName}
|
||||||
style={{
|
shotStatusColor={shotStatusColor}
|
||||||
width: 12,
|
angle={
|
||||||
}}
|
layer.type === ArmorType.Core ||
|
||||||
>
|
layer.type === ArmorType.Spaced
|
||||||
{layerIndex}
|
? layer.angle
|
||||||
</Text>
|
: undefined
|
||||||
<Text
|
}
|
||||||
size="2"
|
/>
|
||||||
color={shotStatusColor}
|
);
|
||||||
style={{ width: 47 }}
|
})}
|
||||||
>
|
</Flex>
|
||||||
{layerName}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{layer.type === null && (
|
|
||||||
<>
|
|
||||||
<Text
|
|
||||||
size="2"
|
|
||||||
color={shotStatusColor}
|
|
||||||
style={{
|
|
||||||
width: 64,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Math.round(layer.distance * 1000).toLocaleString()}
|
|
||||||
<Text size="1">mm</Text>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text size="2" color="gray">
|
|
||||||
{Math.max(-100, Math.round(-50 * layer.distance))}%
|
|
||||||
penetration
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{layer.type === ArmorType.External && (
|
|
||||||
<Text
|
|
||||||
size="2"
|
|
||||||
color={shotStatusColor}
|
|
||||||
style={{
|
|
||||||
width: 64,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Math.round(layer.thickness).toLocaleString()}
|
|
||||||
<Text size="1">mm</Text>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(layer.type === ArmorType.Core ||
|
|
||||||
layer.type === ArmorType.Spaced) && (
|
|
||||||
<>
|
|
||||||
<Text
|
|
||||||
size="2"
|
|
||||||
color={shotStatusColor}
|
|
||||||
style={{
|
|
||||||
width: 64,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Math.round(layer.thicknessAngled).toLocaleString()}
|
|
||||||
<Text size="1">mm</Text>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text size="2" color="gray">
|
|
||||||
{Math.round(layer.thickness).toLocaleString()}
|
|
||||||
<Text size="1">mm</Text> nominal
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{shot.out && shot.out.layers.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Inset side="x">
|
||||||
|
<Separator size="4" />
|
||||||
|
</Inset>
|
||||||
|
|
||||||
|
<Text color={shotStatusColors[shot.out.status]} weight="bold">
|
||||||
|
{shot.out.status !== 'blocked' &&
|
||||||
|
shot.out.status !== 'ricochet' && (
|
||||||
|
<>
|
||||||
|
{Math.round(shot.damage).toLocaleString()}
|
||||||
|
<Text size="1">hp</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{` ${shotStatusNames[shot.out.status]} `}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Flex direction="column">
|
||||||
|
{shot.out.layers.map((layer, index) => {
|
||||||
|
const layerName =
|
||||||
|
layer.type === ArmorType.External
|
||||||
|
? externalLayerNames[layer.variant]
|
||||||
|
: layerTypeNames[`${layer.type}`];
|
||||||
|
const shiftedIndex = index + shot.out!.layers.length;
|
||||||
|
const layerIndex =
|
||||||
|
layer.type === null
|
||||||
|
? undefined
|
||||||
|
: `${(shot.containsGaps ? shiftedIndex / 2 : shiftedIndex) + 1}.`;
|
||||||
|
const shotStatusColor =
|
||||||
|
shot.out!.status === 'splash'
|
||||||
|
? shotStatusColors.splash
|
||||||
|
: shotStatusColors[layer.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayerEntry
|
||||||
|
layer={layer}
|
||||||
|
layerIndex={layerIndex}
|
||||||
|
layerName={layerName}
|
||||||
|
shotStatusColor={shotStatusColor}
|
||||||
|
angle={
|
||||||
|
layer.type === ArmorType.Core ||
|
||||||
|
layer.type === ArmorType.Spaced
|
||||||
|
? layer.angle
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
</Html>
|
</Html>
|
||||||
@@ -204,7 +293,7 @@ export function ShotDisplay() {
|
|||||||
<group
|
<group
|
||||||
position={inLast.point}
|
position={inLast.point}
|
||||||
rotation={new Euler().setFromQuaternion(
|
rotation={new Euler().setFromQuaternion(
|
||||||
new Quaternion().setFromUnitVectors(J_HAT, shot.in.cameraNormal),
|
new Quaternion().setFromUnitVectors(J_HAT, shot.in.surfaceNormal),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<mesh position={[0, inLength / 2, 0]}>
|
<mesh position={[0, inLength / 2, 0]}>
|
||||||
@@ -221,6 +310,29 @@ export function ShotDisplay() {
|
|||||||
<meshBasicMaterial color="red" />
|
<meshBasicMaterial color="red" />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
{shot.out && (
|
||||||
|
<group
|
||||||
|
position={inLast.point}
|
||||||
|
rotation={new Euler().setFromQuaternion(
|
||||||
|
new Quaternion().setFromUnitVectors(J_HAT, shot.out.surfaceNormal),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<mesh position={[0, inLength / 2, 0]}>
|
||||||
|
<cylinderGeometry
|
||||||
|
args={[TRACER_THIN / 2, TRACER_THIN / 2, inLength]}
|
||||||
|
/>
|
||||||
|
<meshBasicMaterial depthTest={false} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
<mesh ref={outTracer} position={[0, inLength / 2, 0]}>
|
||||||
|
<cylinderGeometry
|
||||||
|
args={[TRACER_THICK / 2, TRACER_THICK / 2, inLength]}
|
||||||
|
/>
|
||||||
|
<meshBasicMaterial color="red" />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
Object3D,
|
Object3D,
|
||||||
Plane,
|
Plane,
|
||||||
Quaternion,
|
Quaternion,
|
||||||
|
Raycaster,
|
||||||
Scene,
|
Scene,
|
||||||
|
Vector3,
|
||||||
} from 'three';
|
} from 'three';
|
||||||
import { degToRad } from 'three/src/math/MathUtils';
|
import { degToRad } from 'three/src/math/MathUtils';
|
||||||
import { isExplosive } from '../../../../core/blitz/isExplosive';
|
import { isExplosive } from '../../../../core/blitz/isExplosive';
|
||||||
@@ -77,6 +79,262 @@ export function SpacedArmorSceneComponent({
|
|||||||
}: SpacedArmorSceneComponentProps) {
|
}: SpacedArmorSceneComponentProps) {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
|
async function shoot(
|
||||||
|
point: Vector3,
|
||||||
|
intersections: Intersection[],
|
||||||
|
allowRicochet: boolean,
|
||||||
|
) {
|
||||||
|
const { antagonist, protagonist } = useDuel.getState();
|
||||||
|
const shell = antagonist!.shell;
|
||||||
|
const cameraNormal = camera.position.clone().sub(point).normalize();
|
||||||
|
const shot: Shot = {
|
||||||
|
damage: -1,
|
||||||
|
containsGaps: shell.type === ShellType.HEAT,
|
||||||
|
in: {
|
||||||
|
surfaceNormal: cameraNormal,
|
||||||
|
layers: [],
|
||||||
|
status: 'blocked',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasCalibratedShells = await hasEquipment(
|
||||||
|
103,
|
||||||
|
antagonist!.tank.equipment,
|
||||||
|
antagonist!.equipmentMatrix,
|
||||||
|
);
|
||||||
|
const hasEnhancedArmor = await hasEquipment(
|
||||||
|
110,
|
||||||
|
protagonist!.tank.equipment,
|
||||||
|
protagonist!.equipmentMatrix,
|
||||||
|
);
|
||||||
|
const penetration =
|
||||||
|
resolveNearPenetration(shell.penetration) *
|
||||||
|
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
|
||||||
|
const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1;
|
||||||
|
const filteredIntersections = intersections.filter(
|
||||||
|
(intersection) => 'type' in intersection.object.userData,
|
||||||
|
) as unknown as Intersection<Object3D & { userData: ArmorUserData }>[];
|
||||||
|
const encounteredExternalModuleVariants: ExternalModuleVariant[] = [];
|
||||||
|
const noDuplicateIntersections: typeof filteredIntersections = [];
|
||||||
|
|
||||||
|
for (const intersection of filteredIntersections) {
|
||||||
|
if (intersection.object.userData.type === ArmorType.External) {
|
||||||
|
if (
|
||||||
|
!encounteredExternalModuleVariants.includes(
|
||||||
|
intersection.object.userData.variant,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
noDuplicateIntersections.push(intersection);
|
||||||
|
encounteredExternalModuleVariants.push(
|
||||||
|
intersection.object.userData.variant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
noDuplicateIntersections.push(intersection);
|
||||||
|
|
||||||
|
if (intersection.object.userData.type === ArmorType.Core) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let remainingPenetration = penetration;
|
||||||
|
let index = 0;
|
||||||
|
for (const intersection of noDuplicateIntersections) {
|
||||||
|
const layer = intersection.object.userData;
|
||||||
|
const surfaceNormal = intersection
|
||||||
|
.normal!.clone()
|
||||||
|
.applyQuaternion(
|
||||||
|
intersection.object.getWorldQuaternion(new Quaternion()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shell.type === ShellType.HEAT && index > 0) {
|
||||||
|
const lastLayer = shot.in.layers.at(-1) as ShotLayerBase;
|
||||||
|
const distance = lastLayer.point.distanceTo(intersection.point);
|
||||||
|
remainingPenetration -= 0.5 * remainingPenetration * distance;
|
||||||
|
const blocked = remainingPenetration <= 0;
|
||||||
|
|
||||||
|
shot.in.layers.push({
|
||||||
|
type: null,
|
||||||
|
distance,
|
||||||
|
status: blocked ? 'blocked' : 'penetration',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (blocked) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layer.type === ArmorType.External) {
|
||||||
|
const thickness = layer.thickness * thicknessCoefficient;
|
||||||
|
remainingPenetration -= thickness;
|
||||||
|
const blocked =
|
||||||
|
remainingPenetration <= 0 || shell.type === ShellType.HE;
|
||||||
|
|
||||||
|
shot.in.layers.push({
|
||||||
|
type: ArmorType.External,
|
||||||
|
index,
|
||||||
|
thickness,
|
||||||
|
point: intersection.point,
|
||||||
|
surfaceNormal,
|
||||||
|
status: blocked ? 'blocked' : 'penetration',
|
||||||
|
variant: layer.variant,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const thickness = layer.thickness;
|
||||||
|
const ricochet = degToRad(
|
||||||
|
isExplosive(shell.type) ? 90 : shell.ricochet!,
|
||||||
|
);
|
||||||
|
const angle = surfaceNormal.angleTo(cameraNormal);
|
||||||
|
const threeCalibersRule =
|
||||||
|
shell.caliber > thickness * 3 || index > 0 || !allowRicochet;
|
||||||
|
const twoCalibersRule = shell.caliber > thickness * 2;
|
||||||
|
const normalization = degToRad(shell.normalization ?? 0);
|
||||||
|
const finalNormalization = twoCalibersRule
|
||||||
|
? (1.4 * normalization * shell.caliber) / (2.0 * thickness)
|
||||||
|
: normalization;
|
||||||
|
const finalThickness =
|
||||||
|
thickness / Math.cos(Math.max(0, angle - finalNormalization));
|
||||||
|
|
||||||
|
if (!threeCalibersRule && angle >= ricochet) {
|
||||||
|
remainingPenetration *= 0.75;
|
||||||
|
|
||||||
|
shot.in.layers.push({
|
||||||
|
type: layer.type,
|
||||||
|
index,
|
||||||
|
thickness,
|
||||||
|
thicknessAngled: finalThickness,
|
||||||
|
point: intersection.point,
|
||||||
|
surfaceNormal,
|
||||||
|
status: 'ricochet',
|
||||||
|
angle,
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
remainingPenetration -= finalThickness;
|
||||||
|
const blocked = remainingPenetration <= 0;
|
||||||
|
|
||||||
|
shot.in.layers.push({
|
||||||
|
type: layer.type,
|
||||||
|
index,
|
||||||
|
thickness,
|
||||||
|
thicknessAngled: finalThickness,
|
||||||
|
point: intersection.point,
|
||||||
|
surfaceNormal: intersection.face!.normal,
|
||||||
|
status: blocked ? 'blocked' : 'penetration',
|
||||||
|
angle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shell.type !== ShellType.HE && remainingPenetration <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastLayer = shot.in.layers.at(-1) as ShotLayerNonExternal | undefined;
|
||||||
|
const firstLayer = shot.in.layers[0] as ShotLayerBase | undefined;
|
||||||
|
|
||||||
|
if (!lastLayer || !firstLayer) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
if (shell.type === ShellType.HE) {
|
||||||
|
const totalSpacedArmorThickness = shot.in.layers.reduce(
|
||||||
|
(acc, layer) => {
|
||||||
|
if (layer.type === null || layer.type === ArmorType.Core) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layer.type === ArmorType.External) {
|
||||||
|
return acc + layer.thickness;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc + layer.thicknessAngled;
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const distanceFromSpacedArmor = firstLayer.point.distanceTo(
|
||||||
|
lastLayer.point,
|
||||||
|
);
|
||||||
|
const finalDamage = Math.max(
|
||||||
|
0,
|
||||||
|
0.5 *
|
||||||
|
shell.damage.armor *
|
||||||
|
(1 - distanceFromSpacedArmor / shell.explosionRadius!) -
|
||||||
|
1.1 *
|
||||||
|
(lastLayer.thicknessAngled +
|
||||||
|
Math.min(penetration, totalSpacedArmorThickness)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Above is some peculiar math. We're multiplying 1.1 by the
|
||||||
|
* thickness of the core plate plus the total spaced armor
|
||||||
|
* thickness capped to the penetration itself. This is because
|
||||||
|
* the shader is normalized by the penetration value; hence,
|
||||||
|
* the total spaced armor thickness cannot ever be thicker
|
||||||
|
* than the penetration. This is a precision error on the
|
||||||
|
* shader-side and I do not intend on fixing it. Instead,
|
||||||
|
* for parity's sake, I am capping the value here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (shot.in.layers.length > 1 || lastLayer.status === 'blocked') {
|
||||||
|
shot.in.status = finalDamage > 0 ? 'splash' : 'blocked';
|
||||||
|
shot.damage = finalDamage;
|
||||||
|
} else {
|
||||||
|
shot.in.status = 'penetration';
|
||||||
|
shot.damage = shell.damage.armor;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (lastLayer.status === 'blocked') {
|
||||||
|
shot.in.status = 'blocked';
|
||||||
|
shot.damage = 0;
|
||||||
|
} else if (lastLayer.status === 'penetration') {
|
||||||
|
shot.in.status = 'penetration';
|
||||||
|
shot.damage = shell.damage.armor;
|
||||||
|
} else {
|
||||||
|
const caster = new Raycaster();
|
||||||
|
// https://math.stackexchange.com/a/13263/1222875
|
||||||
|
const shellNormal = cameraNormal.clone().multiplyScalar(-1);
|
||||||
|
const ricochetNormal = shellNormal
|
||||||
|
.clone()
|
||||||
|
.sub(
|
||||||
|
lastLayer.surfaceNormal
|
||||||
|
.clone()
|
||||||
|
.multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal)),
|
||||||
|
);
|
||||||
|
|
||||||
|
caster.set(lastLayer.point, ricochetNormal);
|
||||||
|
|
||||||
|
const ricochetIntersections = caster.intersectObjects(
|
||||||
|
scene.children,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const outShot = await shoot(
|
||||||
|
lastLayer.point,
|
||||||
|
ricochetIntersections,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
shot.in.status = 'ricochet';
|
||||||
|
if (outShot === null) {
|
||||||
|
shot.damage = 0;
|
||||||
|
shot.out = {
|
||||||
|
surfaceNormal: ricochetNormal,
|
||||||
|
layers: [],
|
||||||
|
status: 'ricochet',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
shot.damage = outShot.damage;
|
||||||
|
shot.out = outShot.in;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shot;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.type === ArmorType.Core &&
|
{props.type === ArmorType.Core &&
|
||||||
@@ -93,231 +351,12 @@ export function SpacedArmorSceneComponent({
|
|||||||
async onClick(event) {
|
async onClick(event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
const { antagonist, protagonist } = useDuel.getState();
|
const shot = (await shoot(
|
||||||
const shell = antagonist!.shell;
|
event.point,
|
||||||
const cameraNormal = camera.position
|
event.intersections,
|
||||||
.clone()
|
true,
|
||||||
.sub(event.point)
|
))!;
|
||||||
.normalize();
|
|
||||||
const shot: Shot = {
|
|
||||||
damage: -1,
|
|
||||||
containsGaps: shell.type === ShellType.HEAT,
|
|
||||||
in: {
|
|
||||||
cameraNormal,
|
|
||||||
layers: [],
|
|
||||||
status: 'blocked',
|
|
||||||
},
|
|
||||||
point: event.point,
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasCalibratedShells = await hasEquipment(
|
|
||||||
103,
|
|
||||||
antagonist!.tank.equipment,
|
|
||||||
antagonist!.equipmentMatrix,
|
|
||||||
);
|
|
||||||
const hasEnhancedArmor = await hasEquipment(
|
|
||||||
110,
|
|
||||||
protagonist!.tank.equipment,
|
|
||||||
protagonist!.equipmentMatrix,
|
|
||||||
);
|
|
||||||
const penetration =
|
|
||||||
resolveNearPenetration(shell.penetration) *
|
|
||||||
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
|
|
||||||
const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1;
|
|
||||||
const filteredIntersections = event.intersections.filter(
|
|
||||||
(intersection) => 'type' in intersection.object.userData,
|
|
||||||
) as unknown as Intersection<
|
|
||||||
Object3D & { userData: ArmorUserData }
|
|
||||||
>[];
|
|
||||||
const encounteredExternalModuleVariants: ExternalModuleVariant[] =
|
|
||||||
[];
|
|
||||||
const noDuplicateIntersections: typeof filteredIntersections = [];
|
|
||||||
|
|
||||||
for (const intersection of filteredIntersections) {
|
|
||||||
if (intersection.object.userData.type === ArmorType.External) {
|
|
||||||
if (
|
|
||||||
!encounteredExternalModuleVariants.includes(
|
|
||||||
intersection.object.userData.variant,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
noDuplicateIntersections.push(intersection);
|
|
||||||
encounteredExternalModuleVariants.push(
|
|
||||||
intersection.object.userData.variant,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
noDuplicateIntersections.push(intersection);
|
|
||||||
|
|
||||||
if (intersection.object.userData.type === ArmorType.Core) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let remainingPenetration = penetration;
|
|
||||||
let index = 0;
|
|
||||||
for (const intersection of noDuplicateIntersections) {
|
|
||||||
const layer = intersection.object.userData;
|
|
||||||
|
|
||||||
if (shell.type === ShellType.HEAT && index > 0) {
|
|
||||||
const lastLayer = shot.in.layers.at(-1) as ShotLayerBase;
|
|
||||||
const distance = lastLayer.point.distanceTo(
|
|
||||||
intersection.point,
|
|
||||||
);
|
|
||||||
remainingPenetration -= 0.5 * remainingPenetration * distance;
|
|
||||||
const blocked = remainingPenetration <= 0;
|
|
||||||
|
|
||||||
shot.in.layers.push({
|
|
||||||
type: null,
|
|
||||||
distance,
|
|
||||||
status: blocked ? 'blocked' : 'penetration',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (blocked) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (layer.type === ArmorType.External) {
|
|
||||||
const thickness = layer.thickness * thicknessCoefficient;
|
|
||||||
remainingPenetration -= thickness;
|
|
||||||
const blocked =
|
|
||||||
remainingPenetration <= 0 || shell.type === ShellType.HE;
|
|
||||||
|
|
||||||
shot.in.layers.push({
|
|
||||||
type: ArmorType.External,
|
|
||||||
index,
|
|
||||||
thickness,
|
|
||||||
point: intersection.point,
|
|
||||||
surfaceNormal: intersection.face!.normal,
|
|
||||||
status: blocked ? 'blocked' : 'penetration',
|
|
||||||
variant: layer.variant,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const thickness = layer.thickness;
|
|
||||||
const ricochet = degToRad(
|
|
||||||
isExplosive(shell.type) ? 90 : shell.ricochet!,
|
|
||||||
);
|
|
||||||
const surfaceNormal = intersection
|
|
||||||
.normal!.clone()
|
|
||||||
.applyQuaternion(
|
|
||||||
event.object.getWorldQuaternion(new Quaternion()),
|
|
||||||
);
|
|
||||||
const angle = surfaceNormal.angleTo(cameraNormal);
|
|
||||||
const threeCalibersRule =
|
|
||||||
shell.caliber > thickness * 3 || index > 0;
|
|
||||||
const twoCalibersRule = shell.caliber > thickness * 2;
|
|
||||||
const normalization = degToRad(shell.normalization ?? 0);
|
|
||||||
const finalNormalization = twoCalibersRule
|
|
||||||
? (1.4 * normalization * shell.caliber) / (2.0 * thickness)
|
|
||||||
: normalization;
|
|
||||||
const finalThickness =
|
|
||||||
thickness /
|
|
||||||
Math.cos(Math.max(0, angle - finalNormalization));
|
|
||||||
|
|
||||||
if (!threeCalibersRule && angle >= ricochet) {
|
|
||||||
remainingPenetration *= 0.75;
|
|
||||||
|
|
||||||
shot.in.layers.push({
|
|
||||||
type: layer.type,
|
|
||||||
index,
|
|
||||||
thickness,
|
|
||||||
thicknessAngled: finalThickness,
|
|
||||||
point: intersection.point,
|
|
||||||
surfaceNormal: intersection.face!.normal,
|
|
||||||
status: 'ricochet',
|
|
||||||
angle,
|
|
||||||
});
|
|
||||||
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
remainingPenetration -= finalThickness;
|
|
||||||
const blocked = remainingPenetration <= 0;
|
|
||||||
|
|
||||||
shot.in.layers.push({
|
|
||||||
type: layer.type,
|
|
||||||
index,
|
|
||||||
thickness,
|
|
||||||
thicknessAngled: finalThickness,
|
|
||||||
point: intersection.point,
|
|
||||||
surfaceNormal: intersection.face!.normal,
|
|
||||||
status: blocked ? 'blocked' : 'penetration',
|
|
||||||
angle,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shell.type !== ShellType.HE && remainingPenetration <= 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastLayer = shot.in.layers.at(-1) as ShotLayerNonExternal;
|
|
||||||
const firstLayer = shot.in.layers[0] as ShotLayerBase;
|
|
||||||
|
|
||||||
if (shell.type === ShellType.HE) {
|
|
||||||
const totalSpacedArmorThickness = shot.in.layers.reduce(
|
|
||||||
(acc, layer) => {
|
|
||||||
if (layer.type === null || layer.type === ArmorType.Core) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (layer.type === ArmorType.External) {
|
|
||||||
return acc + layer.thickness;
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc + layer.thicknessAngled;
|
|
||||||
},
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const distanceFromSpacedArmor = firstLayer.point.distanceTo(
|
|
||||||
lastLayer.point,
|
|
||||||
);
|
|
||||||
const finalDamage = Math.max(
|
|
||||||
0,
|
|
||||||
0.5 *
|
|
||||||
shell.damage.armor *
|
|
||||||
(1 - distanceFromSpacedArmor / shell.explosionRadius!) -
|
|
||||||
1.1 *
|
|
||||||
(lastLayer.thicknessAngled +
|
|
||||||
Math.min(penetration, totalSpacedArmorThickness)),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Above is some peculiar math. We're multiplying 1.1 by the
|
|
||||||
* thickness of the core plate plus the total spaced armor
|
|
||||||
* thickness capped to the penetration itself. This is because
|
|
||||||
* the shader is normalized by the penetration value; hence,
|
|
||||||
* the total spaced armor thickness cannot ever be thicker
|
|
||||||
* than the penetration. This is a precision error on the
|
|
||||||
* shader-side and I do not intend on fixing it. Instead,
|
|
||||||
* for parity's sake, I am capping the value here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
if (
|
|
||||||
shot.in.layers.length > 1 ||
|
|
||||||
lastLayer.status === 'blocked'
|
|
||||||
) {
|
|
||||||
shot.in.status = finalDamage > 0 ? 'splash' : 'blocked';
|
|
||||||
shot.damage = finalDamage;
|
|
||||||
} else {
|
|
||||||
shot.in.status = 'penetration';
|
|
||||||
shot.damage = shell.damage.armor;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (lastLayer.status === 'blocked') {
|
|
||||||
shot.in.status = 'blocked';
|
|
||||||
shot.damage = 0;
|
|
||||||
} else if (lastLayer.status === 'penetration') {
|
|
||||||
shot.in.status = 'penetration';
|
|
||||||
shot.damage = shell.damage.armor;
|
|
||||||
} else {
|
|
||||||
// check second array for result
|
|
||||||
shot.in.status = 'ricochet';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(shot);
|
|
||||||
useTankopediaTemporary.setState({ shot });
|
useTankopediaTemporary.setState({ shot });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,14 +77,14 @@ export type ShotLayer = ShotLayerExternal | ShotLayerNonExternal | ShotLayerGap;
|
|||||||
export type Shot = {
|
export type Shot = {
|
||||||
containsGaps: boolean;
|
containsGaps: boolean;
|
||||||
damage: number;
|
damage: number;
|
||||||
point: Vector3;
|
|
||||||
|
|
||||||
in: {
|
in: {
|
||||||
cameraNormal: Vector3;
|
surfaceNormal: Vector3;
|
||||||
status: ShotStatus;
|
status: ShotStatus;
|
||||||
layers: ShotLayer[];
|
layers: ShotLayer[];
|
||||||
};
|
};
|
||||||
out?: {
|
out?: {
|
||||||
|
surfaceNormal: Vector3;
|
||||||
status: ShotStatus;
|
status: ShotStatus;
|
||||||
layers: ShotLayer[];
|
layers: ShotLayer[];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user