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;