diff --git a/.vscode/settings.json b/.vscode/settings.json index ecbf47ef4..a2e3ce871 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -85,6 +85,7 @@ "Pyogenics", "ratingsleaderboard", "ratingsplot", + "raycaster", "researchable", "Resvg", "roboto", diff --git a/src/app/tools/tankopedia/[id]/components/Characteristics/components/Consumables.tsx b/src/app/tools/tankopedia/[id]/components/Characteristics/components/Consumables.tsx index d1e3e6d70..7d2a63ca6 100644 --- a/src/app/tools/tankopedia/[id]/components/Characteristics/components/Consumables.tsx +++ b/src/app/tools/tankopedia/[id]/components/Characteristics/components/Consumables.tsx @@ -36,6 +36,7 @@ export function Consumables() { return ( ( 0} name={index > 0 ? `Shell ${index + 1}` : 'Reload on shell 1'} unit="s" @@ -144,25 +145,25 @@ export function Characteristics() { {gun.shells.map((shell) => ( - + {resolveNearPenetration(shell.penetration)} ))} {gun.shells.map((shell) => ( - + {shell.damage.armor} ))} {gun.shells.map((shell, index) => ( - + {shell.damage.module} ))} {gun.shells.map((shell, index) => ( - + {shell.speed} ))} diff --git a/src/components/ArmorMesh/components/Armor/index.tsx b/src/components/ArmorMesh/components/Armor/index.tsx index 3a70006ba..a46b6f96e 100644 --- a/src/components/ArmorMesh/components/Armor/index.tsx +++ b/src/components/ArmorMesh/components/Armor/index.tsx @@ -4,6 +4,7 @@ import { IUniform, Mesh, Quaternion, + Raycaster, ShaderMaterial, Vector2, Vector3, @@ -16,6 +17,7 @@ import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetr import { useDuel } from '../../../../stores/duel'; import { ArmorPiercingLayer, + Shot, mutateTankopediaTemporary, useTankopediaPersistent, } from '../../../../stores/tankopedia'; @@ -49,6 +51,7 @@ export function ArmorMesh({ const shellNormal = new Vector3(); const surfaceNormal = new Vector3(); const mesh = useRef(null); + const scene = useThree((state) => state.scene); useEffect(() => { function updateQuickEquipments() { @@ -189,8 +192,11 @@ export function ArmorMesh({ if (mesh.current !== (coreArmorMesh as unknown as Mesh)) return; - const armorPiercingLayersRaw = intersectionsTillFirstCoreArmor.map( - (event, index) => { + let hasRicochet = false; + const armorPiercingLayersRaw = intersectionsTillFirstCoreArmor + .map((event, index) => { + if (hasRicochet) return null; + switch (event.object.userData.type) { case 'externalModule': { return { @@ -198,9 +204,9 @@ export function ArmorMesh({ event.object.userData.thickness * thicknessCoefficient, angled: event.object.userData.thickness * thicknessCoefficient, - ricochet: false, type: 'external', distance: event.distance, + ricochet: false, } satisfies ArmorPiercingLayer; } @@ -210,7 +216,6 @@ export function ArmorMesh({ shell.caliber > 3 * (event.object.userData.thickness * thicknessCoefficient); - let ricochet = false; if ( index === 0 && @@ -218,7 +223,7 @@ export function ArmorMesh({ !threeCalibersRule && angle >= degToRad(shell.ricochet!) ) { - ricochet = true; + hasRicochet = true; } const twoCalibersRule = @@ -236,10 +241,10 @@ export function ArmorMesh({ Math.cos(angle - degToRad(normalization)); return { + ricochet: hasRicochet, nominal: event.object.userData.thickness * thicknessCoefficient, angled, - ricochet, type: event.object.userData.type === 'coreArmor' ? 'core' @@ -248,8 +253,8 @@ export function ArmorMesh({ } satisfies ArmorPiercingLayer; } } - }, - ); + }) + .filter(Boolean); const gaps = armorPiercingLayersRaw.map((layerA, aIndex) => { if (aIndex === armorPiercingLayersRaw.length - 1) return null; const layerB = armorPiercingLayersRaw[aIndex + 1]; @@ -273,7 +278,11 @@ export function ArmorMesh({ armorPiercingLayers.forEach((layer) => { if (layer.type !== 'gap') { - remainingPenetration -= layer.angled; + 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; @@ -282,10 +291,79 @@ export function ArmorMesh({ remainingPenetration = Math.max(0, remainingPenetration); }); - const hasRicochet = armorPiercingLayers.some( - (layer) => layer.type !== 'gap' && layer.ricochet, - ); 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 = { @@ -299,6 +377,7 @@ export function ArmorMesh({ shellNormal: shellNormal.toArray(), surfaceNormal: surfaceNormal.toArray(), angle, + ricochet, }; }); }} diff --git a/src/components/ShotDisplay.tsx b/src/components/ShotDisplay.tsx index c68721791..31d17814d 100644 --- a/src/components/ShotDisplay.tsx +++ b/src/components/ShotDisplay.tsx @@ -4,10 +4,12 @@ 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 = { @@ -30,8 +32,12 @@ export function ShotDisplay() { 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; @@ -39,7 +45,7 @@ export function ShotDisplay() { 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 * LENGTH); + exitLine.current?.position.set(0, 0, t2 * secondLineLength); exitLine.current?.scale.set(1, -2 * Math.abs(t2 - 0.5) + 1, 1); }); @@ -47,21 +53,21 @@ export function ShotDisplay() { if (!shot) return; const point = new Vector3(...shot.point); - const shotNormal = new Vector3(...shot.shellNormal); + const shellNormal = new Vector3(...shot.shellNormal); const surfaceNormal = new Vector3(...shot.surfaceNormal); - const bounceNormal = shotNormal + const bounceNormal = shellNormal .clone() .multiplyScalar(-1) .sub( surfaceNormal .clone() .multiplyScalar( - 2 * surfaceNormal.dot(shotNormal.clone().multiplyScalar(-1)), + 2 * surfaceNormal.dot(shellNormal.clone().multiplyScalar(-1)), ), ); entryWrapper.current?.position.set(...shot.point); - entryWrapper.current?.lookAt(point.clone().add(shotNormal)); + 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); @@ -89,7 +95,7 @@ export function ShotDisplay() { > 1 ? 300 : 200, + width: number ? 300 : 200, userSelect: 'none', }} > @@ -104,12 +110,12 @@ export function ShotDisplay() { 1 ? 8 : undefined, + paddingLeft: number ? 8 : undefined, }} > {shot.thicknesses.map((layer, index) => ( - - {shot.thicknesses.length > 1 && ( + + {number && ( {index + 1}. {layer.type[0].toUpperCase()} {layer.type.slice(1)} @@ -127,6 +133,9 @@ export function ShotDisplay() { : layer.nominal, )} mm + {layer.type === 'gap' && isExplosive(shell.type) + ? ` (${Math.round(Math.min(100, 0.5 * layer.gap * 100))}% pen. loss)` + : ''} {layer.type !== 'external' && layer.type !== 'gap' && ( @@ -137,6 +146,26 @@ export function ShotDisplay() { )} ))} + + {shot.ricochet && ( + <> + + {shot.thicknesses.length + 1}. Ricochet + + + {shot.ricochet.penetration ? ( + + {shot.thicknesses.length + 2}. Penetration + + + ) : ( + + {shot.thicknesses.length + 2}. Blocked + + + )} + + )} @@ -161,21 +190,34 @@ export function ShotDisplay() { - + + + {shot.ricochet && ( + + + + + )} )} diff --git a/src/icons/AngledPenetration.tsx b/src/icons/AngledPenetration.tsx index fc72a22ce..58df8fee6 100644 --- a/src/icons/AngledPenetration.tsx +++ b/src/icons/AngledPenetration.tsx @@ -15,8 +15,8 @@ export function AngledPenetration(props: ComponentProps<'svg'>) { fill="currentColor" /> diff --git a/src/stores/tankopedia.ts b/src/stores/tankopedia.ts index 14d7d8425..ae74135fa 100644 --- a/src/stores/tankopedia.ts +++ b/src/stores/tankopedia.ts @@ -69,14 +69,19 @@ interface TankopediaTemporary { shot?: Shot; } -export type 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 type ArmorPiercingLayer = | {