From f55313e0cd3c436eca184022d9688cf988f49404 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tr=C3=A8sAbhi?= <43380238+tresabhi@users.noreply.github.com>
Date: Wed, 28 Feb 2024 22:29:21 -0600
Subject: [PATCH] feat: shot display
---
.../[id]/components/Model/TankSandbox.tsx | 3 +-
src/components/Armor/components/ShotScene.tsx | 88 ++++++
.../Armor/components/SpacedArmorScene.tsx | 13 +-
.../SpacedArmorSubExternal/index.tsx | 3 +-
.../components/SpacedArmorSubSpaced/index.tsx | 3 +-
.../SpacedArmorSceneComponent/index.tsx | 291 +++++++++++-------
src/components/Armor/index.tsx | 15 +-
src/components/ShotDisplay.tsx | 226 --------------
src/constants/axis.ts | 1 +
src/stores/tankopedia.ts | 41 ++-
10 files changed, 333 insertions(+), 351 deletions(-)
create mode 100644 src/components/Armor/components/ShotScene.tsx
delete mode 100644 src/components/ShotDisplay.tsx
diff --git a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx
index 5f24b01eb..ed1d98ae0 100644
--- a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx
+++ b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx
@@ -5,6 +5,7 @@ import { Canvas } from '@react-three/fiber';
import { Suspense, use, useEffect, useRef, useState } from 'react';
import { isMobile } from 'react-device-detect';
import { Armor } from '../../../../../../components/Armor';
+import { ShotScene } from '../../../../../../components/Armor/components/ShotScene';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTransform';
@@ -195,7 +196,7 @@ export function TankSandbox() {
}>
- {/* */}
+ {/* TODO: add rotation cube to the top right */}
{/* */}
diff --git a/src/components/Armor/components/ShotScene.tsx b/src/components/Armor/components/ShotScene.tsx
new file mode 100644
index 000000000..9fa423a3c
--- /dev/null
+++ b/src/components/Armor/components/ShotScene.tsx
@@ -0,0 +1,88 @@
+import { Text } from '@radix-ui/themes';
+import { Html } from '@react-three/drei';
+import { DoubleSide, Euler, Quaternion, Vector3 } from 'three';
+import { J_HAT } from '../../../constants/axis';
+import {
+ ShotLayerGap,
+ useTankopediaTemporary,
+} from '../../../stores/tankopedia';
+
+// export const SHOT_NAMES: Record = {
+// ricochet: 'Ricochet',
+// penetration: 'Penetration',
+// block: 'Blocked',
+// };
+
+// export const SHOT_ICONS: Record = {
+// ricochet: ,
+// block: ,
+// penetration: ,
+// };
+
+// const THICKNESS = 0.05;
+// const LENGTH = 4;
+
+export function ShotScene() {
+ const shot = useTankopediaTemporary((state) => state.shot);
+
+ return (
+ <>
+ {shot?.map((layer, index) => {
+ if (layer.type === null) return;
+
+ const shellRotation = new Euler().setFromQuaternion(
+ new Quaternion().setFromAxisAngle(
+ new Vector3().crossVectors(J_HAT, layer.shellNormal).normalize(),
+ J_HAT.angleTo(layer.shellNormal),
+ ),
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+ {shot.length > 1 && (
+
+ {layer.index + 1}
+
+ )}
+
+ {(() => {
+ let length = 4;
+
+ if (layer.index !== 0) {
+ const gap = shot[index - 1] as ShotLayerGap;
+ length = gap.distance;
+ }
+
+ return (
+
+
+
+
+ );
+ })()}
+
+ >
+ );
+ })}
+ >
+ );
+}
diff --git a/src/components/Armor/components/SpacedArmorScene.tsx b/src/components/Armor/components/SpacedArmorScene.tsx
index 99af51133..56337dfe9 100644
--- a/src/components/Armor/components/SpacedArmorScene.tsx
+++ b/src/components/Armor/components/SpacedArmorScene.tsx
@@ -1,5 +1,5 @@
import { memo, useEffect, useRef } from 'react';
-import { Euler, Group, Vector3 } from 'three';
+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 {
@@ -21,7 +21,11 @@ export enum ArmorType {
External,
}
-export const SpacedArmorScene = memo(() => {
+interface SpacedArmorSceneProps {
+ scene: Scene;
+}
+
+export const SpacedArmorScene = memo(({ scene }) => {
const wrapper = useRef(null);
const modelDefinitions = useModelDefinitions();
const turretContainer = useRef(null);
@@ -162,6 +166,7 @@ export const SpacedArmorScene = memo(() => {
return (
{
return (
{
return (
{
position={turretOrigin.clone().add(gunOrigin)}
>
{
return (
;
@@ -29,15 +38,14 @@ export type ArmorUserData = {
thickness: number;
} & (
| {
- type: 'core' | 'spaced';
+ type: Exclude;
}
| {
- type: 'external';
+ type: ArmorType.External;
variant: ExternalModuleVariant;
}
);
type ArmorMeshIntersection = Intersection & {
- eventObject: Object3D;
object: Object3D & { userData: ArmorUserData };
};
@@ -56,6 +64,7 @@ const omitMaterial = new MeshBasicMaterial({
export function SpacedArmorSceneComponent({
node,
thickness,
+ scene,
...props
}: SpacedArmorSceneComponentProps) {
const camera = useThree((state) => state.camera);
@@ -68,53 +77,13 @@ export function SpacedArmorSceneComponent({
renderOrder: 0,
material: omitMaterial,
userData: {
- type: 'core',
+ type: ArmorType.Core,
thickness,
} satisfies ArmorUserData,
async onClick(event) {
event.stopPropagation();
- const intersectionsAll = event.intersections.filter(
- ({ object }) => object.userData.type !== undefined,
- ) as ArmorMeshIntersection[];
- const intersectionsWithDuplicates = intersectionsAll.slice(
- 0,
- intersectionsAll.findIndex(
- ({ object }) => object.userData.type === 'core',
- ),
- );
- const firstGunIntersectionIndex =
- intersectionsWithDuplicates.findIndex(
- ({ object }) =>
- object.userData.type === 'external' &&
- object.userData.variant === 'gun',
- );
- const firstTrackIntersectionIndex =
- intersectionsWithDuplicates.findIndex(
- ({ object }) =>
- object.userData.type === 'external' &&
- object.userData.variant === 'track',
- );
- let intersections = intersectionsWithDuplicates;
-
- if (firstGunIntersectionIndex !== -1) {
- intersections = intersections.filter(({ object }, index) => {
- return object.userData.type === 'external' &&
- object.userData.variant === 'gun'
- ? index === firstGunIntersectionIndex
- : true;
- });
- }
- if (firstTrackIntersectionIndex !== -1) {
- intersections = intersections.filter(({ object }, index) => {
- return object.userData.type === 'external' &&
- object.userData.variant === 'track'
- ? index === firstTrackIntersectionIndex
- : true;
- });
- }
-
const hasCalibratedShells = await hasEquipment(103, true);
const hasEnhancedArmor = await hasEquipment(110);
const thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1;
@@ -126,73 +95,189 @@ export function SpacedArmorSceneComponent({
? 1.1
: 1.05
: 1);
- const shotLog: ({
- thickness: number;
- status: 'blocked' | 'penetration' | 'ricochet';
- } & (
- | {
- type: ArmorType.External;
- }
- | {
- type: Exclude;
- thicknessAngled: number;
- }
- ))[] = []; // TODO: type this
+ const shot: Shot = []; // TODO: type this
let remainingPenetration = penetration;
let penetrationChance = -1;
let splashChance = -1;
- camera.getWorldDirection(cameraNormal).multiplyScalar(-1);
+ const shellNormal = camera.position
+ .clone()
+ .sub(event.point)
+ .normalize();
+ shellNormal
+ .copy(camera.getWorldDirection(cameraNormal))
+ .multiplyScalar(-1);
+ pushLogs(
+ shellNormal,
+ filterIntersections(event.intersections),
+ true,
+ );
- let index = 0;
- for (const intersection of intersections) {
- const thickness =
- thicknessCoefficient * intersection.object.userData.thickness;
+ console.clear();
+ console.log(
+ JSON.stringify(
+ shot.map((i) => ({
+ ...i,
+ ...(i.angle ? { angle: radToDeg(i.angle) } : {}),
+ })),
+ null,
+ 2,
+ ),
+ );
- if (intersection.object.userData.type === 'external') {
- remainingPenetration -= thickness;
- const blocked = remainingPenetration < 0;
-
- shotLog.push({
- type: ArmorType.External,
- thickness: intersection.object.userData.thickness,
- status: blocked ? 'blocked' : 'penetration',
- });
-
- if (blocked) break;
- } else if (intersection.object.userData.type === 'spaced') {
- const normal = intersection.normal!.applyAxisAngle(
- I_HAT,
- -Math.PI / 2,
+ 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 angle = normal.angleTo(cameraNormal);
- const ricochet = degToRad(shell.ricochet ?? 90);
- const normalization = degToRad(shell.normalization ?? 0);
- const threeCalibersRule =
- shell.caliber > thickness || index > 0;
- const twoCalibersRule = shell.caliber > thickness * 2;
- const finalNormalization = twoCalibersRule
- ? (1.4 * normalization * shell.caliber) / (2.0 * thickness)
- : normalization;
- const finalThickness =
- thickness / Math.cos(angle - finalNormalization);
- remainingPenetration -= finalThickness;
+ const firstTrackIntersectionIndex =
+ intersectionsWithDuplicates.findIndex(
+ ({ object }) =>
+ object.userData.type === ArmorType.External &&
+ object.userData.variant === 'track',
+ );
+ let intersections = intersectionsWithDuplicates;
- if (!threeCalibersRule && angle >= ricochet) {
- shotLog.push({
- type: ArmorType.Spaced,
- thickness,
- thicknessAngled: finalThickness,
- status: 'ricochet',
- });
- continue;
- }
- } else throw new Error('Core should be omitted');
+ 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;
+ });
+ }
- index++;
+ return intersections;
}
- console.log(JSON.stringify(shotLog, null, 2));
+ function pushLogs(
+ shellNormal: Vector3,
+ intersections: ArmorMeshIntersection[],
+ allowRicochets: boolean,
+ ) {
+ let index = 0;
+ for (const intersection of intersections) {
+ const surfaceNormal = intersection
+ .normal!.clone()
+ .applyQuaternion(
+ event.object.getWorldQuaternion(new Quaternion()),
+ );
+ const thickness =
+ thicknessCoefficient * intersection.object.userData.thickness;
+
+ if (index !== 0) {
+ const previousIntersection = intersections[index - 1];
+ shot.push({
+ type: null,
+ distance:
+ intersection.distance - previousIntersection.distance,
+ });
+ }
+
+ if (intersection.object.userData.type === ArmorType.External) {
+ remainingPenetration -= thickness;
+ const blocked = remainingPenetration < 0;
+
+ shot.push({
+ type: ArmorType.External,
+ index,
+ shellNormal,
+ surfaceNormal,
+ point: intersection.point,
+ thickness: intersection.object.userData.thickness,
+ status: blocked ? 'blocked' : 'penetration',
+ });
+
+ if (blocked) break;
+ } else {
+ const type = intersection.object.userData.type;
+ shellNormal.copy(
+ cameraNormal
+ .copy(camera.position)
+ .sub(event.point)
+ .normalize(),
+ );
+ const angle = surfaceNormal.angleTo(shellNormal);
+ const ricochet = degToRad(shell.ricochet ?? 90);
+ const normalization = degToRad(shell.normalization ?? 0);
+ const threeCalibersRule =
+ shell.caliber > thickness * 3 ||
+ index > 0 ||
+ !allowRicochets;
+ const twoCalibersRule = shell.caliber > thickness * 2;
+ const finalNormalization = twoCalibersRule
+ ? (1.4 * normalization * shell.caliber) / (2.0 * thickness)
+ : normalization;
+ const finalThickness =
+ thickness / Math.cos(angle - finalNormalization);
+ remainingPenetration -= finalThickness;
+
+ if (!threeCalibersRule && angle >= ricochet) {
+ shot.push({
+ type,
+ index,
+ shellNormal,
+ surfaceNormal,
+ point: intersection.point,
+ thickness,
+ thicknessAngled: finalThickness,
+ angle,
+ status: 'ricochet',
+ });
+
+ const raycaster = new Raycaster();
+ const newShellNormal = cameraNormal
+ .clone()
+ .reflect(surfaceNormal);
+
+ raycaster.set(intersection.point, newShellNormal);
+ pushLogs(
+ newShellNormal,
+ raycaster.intersectObjects(scene.children, true),
+ false,
+ );
+
+ break;
+ } else {
+ const blocked = remainingPenetration < 0;
+ shot.push({
+ type,
+ index,
+ shellNormal,
+ surfaceNormal,
+ point: intersection.point,
+ thickness,
+ thicknessAngled: finalThickness,
+ angle,
+ status: blocked ? 'blocked' : 'penetration',
+ });
+ }
+ }
+
+ index++;
+ }
+ }
+
+ useTankopediaTemporary.setState({ shot });
},
})}
diff --git a/src/components/Armor/index.tsx b/src/components/Armor/index.tsx
index 909c3e0fd..c3a889805 100644
--- a/src/components/Armor/index.tsx
+++ b/src/components/Armor/index.tsx
@@ -1,18 +1,21 @@
import { createPortal, useFrame } from '@react-three/fiber';
-import { memo } from 'react';
+import { memo, useState } from 'react';
import { DepthTexture, Scene, Vector2 } from 'three';
import { CoreArmorScene } from './components/CoreArmorScene';
import { spacedArmorRenderTarget } from './components/CoreArmorSceneComponent';
+import { ShotScene } from './components/ShotScene';
import { SpacedArmorScene } from './components/SpacedArmorScene';
export const Armor = memo(() => {
- const spacedArmorScene = new Scene();
- const coreArmorScene = new Scene();
+ const [spacedArmorScene] = useState(() => new Scene());
+ const [coreArmorScene] = useState(() => new Scene());
+ const [shotScene] = useState(() => new Scene());
const spacedArmorPortal = createPortal(
- ,
+ ,
spacedArmorScene,
);
const coreArmorPortal = createPortal(, coreArmorScene);
+ const shotPortal = createPortal(, shotScene);
const renderSize = new Vector2();
const newRenderSize = new Vector2();
@@ -41,12 +44,16 @@ export const Armor = memo(() => {
gl.clearDepth();
gl.render(coreArmorScene, camera);
+
+ gl.clearDepth();
+ gl.render(shotScene, camera);
}, 1);
return (
<>
{spacedArmorPortal}
{coreArmorPortal}
+ {shotPortal}
>
);
});
diff --git a/src/components/ShotDisplay.tsx b/src/components/ShotDisplay.tsx
deleted file mode 100644
index 2e2c29a05..000000000
--- a/src/components/ShotDisplay.tsx
+++ /dev/null
@@ -1,226 +0,0 @@
-import { Card, Flex, Text } from '@radix-ui/themes';
-import { Html } from '@react-three/drei';
-import { useFrame } from '@react-three/fiber';
-import { ReactNode, useEffect, useRef } from 'react';
-import { Group, Mesh, Vector3 } from 'three';
-import { radToDeg } from 'three/src/math/MathUtils';
-import { isExplosive } from '../core/blitz/isExplosive';
-import { AngledPenetration } from '../icons/AngledPenetration';
-import { Block } from '../icons/Block';
-import { NominalPenetration } from '../icons/NominalPenetration';
-import { Ricochet } from '../icons/Ricochet';
-import { useDuel } from '../stores/duel';
-import { Shot, useTankopediaTemporary } from '../stores/tankopedia';
-
-export const SHOT_NAMES: Record = {
- ricochet: 'Ricochet',
- penetration: 'Penetration',
- block: 'Blocked',
-};
-
-export const SHOT_ICONS: Record = {
- ricochet: ,
- block: ,
- penetration: ,
-};
-
-const THICKNESS = 0.05;
-const LENGTH = 4;
-
-export function ShotDisplay() {
- const shot = useTankopediaTemporary((state) => state.shot);
- const entryWrapper = useRef(null);
- const entryLine = useRef(null);
- const circle = useRef(null);
- const ricochetCircle = useRef(null);
- const exitWrapper = useRef(null);
- const exitLine = useRef(null);
- const secondLineLength = shot?.ricochet ? shot?.ricochet.distance : LENGTH;
- const number = shot && (shot.thicknesses.length > 1 || shot.ricochet);
- const shell = useDuel((state) => state.antagonist!.shell);
-
- useFrame(({ clock }) => {
- const t1 = clock.elapsedTime % 1;
- const t2 = (clock.elapsedTime + 0.5) % 1;
-
- entryLine.current?.position.set(0, 0, LENGTH - t1 * LENGTH);
- entryLine.current?.scale.set(1, -2 * Math.abs(t1 - 0.5) + 1, 1);
- exitLine.current?.position.set(0, 0, t2 * secondLineLength);
- exitLine.current?.scale.set(1, -2 * Math.abs(t2 - 0.5) + 1, 1);
- });
-
- useEffect(() => {
- if (!shot) return;
-
- const point = new Vector3(...shot.point);
- const shellNormal = new Vector3(...shot.shellNormal);
- const surfaceNormal = new Vector3(...shot.surfaceNormal);
- const bounceNormal = shellNormal
- .clone()
- .multiplyScalar(-1)
- .sub(
- surfaceNormal
- .clone()
- .multiplyScalar(
- 2 * surfaceNormal.dot(shellNormal.clone().multiplyScalar(-1)),
- ),
- );
-
- entryWrapper.current?.position.set(...shot.point);
- entryWrapper.current?.lookAt(point.clone().add(shellNormal));
- exitWrapper.current?.position.set(...shot.point);
- exitWrapper.current?.lookAt(point.clone().add(bounceNormal));
- circle.current?.position.set(...shot.point);
- circle.current?.lookAt(point.clone().add(surfaceNormal));
- }, [shot]);
-
- if (!shot) return null;
-
- return (
- <>
-
-
-
-
-
-
-
-
-
-
- {SHOT_ICONS[shot.type]}
-
- {SHOT_NAMES[shot.type]} at {Math.round(radToDeg(shot.angle))}°
-
-
-
-
- {shot.thicknesses.map((layer, index) => (
-
- {number && (
-
- {index + 1}. {layer.type[0].toUpperCase()}
- {layer.type.slice(1)}
-
- )}
-
-
- {layer.type !== 'gap' && (
-
- )}
-
- {Math.round(
- layer.type === 'gap'
- ? layer.gap * 1000
- : layer.nominal,
- )}
- mm
- {layer.type === 'gap' && isExplosive(shell.type)
- ? ` (-${Math.round(Math.min(100, 0.5 * layer.gap * 100))}% penetration)`
- : ''}
-
-
- {layer.type !== 'external' && layer.type !== 'gap' && (
-
-
- {Math.round(layer.angled)}mm
-
- )}
-
- ))}
-
- {shot.ricochet && (
- <>
-
- {shot.thicknesses.length + 1}. Ricochet
-
- (-25% penetration)
-
- {shot.ricochet.penetration ? (
-
- {shot.thicknesses.length + 2}. Penetration
-
-
- ) : (
-
- {shot.thicknesses.length + 2}. Blocked
-
-
- )}
- >
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {shot.type === 'ricochet' && (
-
-
-
-
-
-
-
-
-
-
- {shot.ricochet && (
-
-
-
-
- )}
-
- )}
- >
- );
-}
diff --git a/src/constants/axis.ts b/src/constants/axis.ts
index 9d600d2be..784321114 100644
--- a/src/constants/axis.ts
+++ b/src/constants/axis.ts
@@ -1,5 +1,6 @@
import { Vector3 } from 'three';
+export const ZERO_VECTOR = new Vector3();
export const I_HAT = new Vector3(1, 0, 0);
export const J_HAT = new Vector3(0, 1, 0);
export const K_HAT = new Vector3(0, 0, 1);
diff --git a/src/stores/tankopedia.ts b/src/stores/tankopedia.ts
index 206e7451d..c83f6d8fe 100644
--- a/src/stores/tankopedia.ts
+++ b/src/stores/tankopedia.ts
@@ -1,9 +1,10 @@
import { produce } from 'immer';
import { merge } from 'lodash';
-import { Vector3Tuple } from 'three';
+import { Vector3 } from 'three';
import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import { ENVIRONMENTS } from '../app/tools/tankopedia/[id]/components/Lighting';
+import { ArmorType } from '../components/Armor/components/SpacedArmorScene';
import { TankType, TreeType } from '../components/Tanks';
import { Tier } from '../core/blitzkrieg/tankDefinitions';
@@ -43,20 +44,34 @@ 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;
+}
+
+interface ShotLayerNonExternal extends ShotLayerBase {
+ type: Exclude;
+ angle: number;
+ thicknessAngled: number;
+}
+
+export interface ShotLayerGap {
+ type: null;
+ distance: number;
+}
+
+type ShotLayer = ShotLayerExternal | ShotLayerNonExternal | ShotLayerGap;
+
+export type Shot = ShotLayer[];
+
export type ArmorPiercingLayer =
| {
distance: number;