From e16f3653bb3b714a06a46d293cea6ce44af86e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C3=A8sAbhi?= <43380238+tresabhi@users.noreply.github.com> Date: Sun, 26 May 2024 14:39:16 -0500 Subject: [PATCH] feat: out tracer --- .../Armor/components/ShotDisplay.tsx | 326 ++++++++---- .../SpacedArmorSceneComponent/index.tsx | 487 ++++++++++-------- src/stores/tankopedia.ts | 4 +- 3 files changed, 484 insertions(+), 333 deletions(-) diff --git a/src/components/Armor/components/ShotDisplay.tsx b/src/components/Armor/components/ShotDisplay.tsx index d16cde3b7..39f3522c7 100644 --- a/src/components/Armor/components/ShotDisplay.tsx +++ b/src/components/Armor/components/ShotDisplay.tsx @@ -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 { useFrame } from '@react-three/fiber'; import { ComponentProps, useRef } from 'react'; import { Euler, Mesh, Quaternion } from 'three'; +import { radToDeg } from 'three/src/math/MathUtils'; import { J_HAT } from '../../../constants/axis'; -import { ShotStatus, useTankopediaTemporary } from '../../../stores/tankopedia'; +import { + ShotLayer, + ShotStatus, + useTankopediaTemporary, +} from '../../../stores/tankopedia'; import { ArmorType } from './SpacedArmorScene'; import { ExternalModuleVariant } from './SpacedArmorSceneComponent'; @@ -22,8 +27,8 @@ const externalLayerNames: Record = { const shotStatusNames: Record = { blocked: 'Blocked', + ricochet: 'Ricochet', penetration: 'penetration', - ricochet: 'ricochet', splash: 'splash', }; @@ -41,18 +46,107 @@ const LENGTH_INFINITY = 4; const TRACER_THICK = 1 / 64; const TRACER_THIN = TRACER_THICK / 2; +function LayerEntry({ + layerIndex, + shotStatusColor, + layerName, + layer, + angle, +}: { + layerIndex?: string; + shotStatusColor: ComponentProps['color']; + layerName: string; + layer: ShotLayer; + angle?: number; +}) { + return ( + + + {layerIndex} + + + {layerName} + + + {layer.type === null && ( + <> + + {Math.round(layer.distance * 1000).toLocaleString()} + mm + + + + {Math.max(-100, Math.round(-50 * layer.distance))}% penetration + + + )} + + {layer.type === ArmorType.External && ( + + {Math.round(layer.thickness).toLocaleString()} + mm + + )} + + {(layer.type === ArmorType.Core || layer.type === ArmorType.Spaced) && ( + <> + + {Math.round(layer.thicknessAngled).toLocaleString()} + {`mm${angle === undefined ? '' : ` ${Math.round(radToDeg(angle))}°`}`} + + + + {Math.round(layer.thickness).toLocaleString()} + mm nominal + + + )} + + ); +} + export function ShotDisplay() { const shot = useTankopediaTemporary((state) => state.shot); const inTracer = useRef(null); + const outTracer = useRef(null); useFrame(({ clock }) => { if (!shot || !inTracer.current) return; - const t = clock.elapsedTime % 1; - const scale1 = -Math.abs(2 * t - 1) + 1; + const t1 = clock.elapsedTime % 1; + const t2 = (clock.elapsedTime + 0.5) % 1; - inTracer.current.scale.set(1, scale1, 1); - inTracer.current.position.set(0, (1 - t) * inLength, 0); + inTracer.current.scale.set(1, 1 - 2 * Math.abs(t1 - 0.5), 1); + 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; @@ -74,108 +168,103 @@ export function ShotDisplay() { }} > - - - {shot.in.status !== 'blocked' && ( - <> - {Math.round(shot.damage).toLocaleString()} - hp - - )} - {` ${shotStatusNames[shot.in.status]} `} - + + + + {shot.in.status !== 'blocked' && + shot.in.status !== 'ricochet' && ( + <> + {Math.round(shot.damage).toLocaleString()} + hp + + )} + {` ${shotStatusNames[shot.in.status]} `} + - - {shot.in.layers.map((layer, index) => { - const layerName = - layer.type === ArmorType.External - ? externalLayerNames[layer.variant] - : layerTypeNames[`${layer.type}`]; - const layerIndex = - layer.type === null - ? null - : `${(shot.containsGaps ? index / 2 : index) + 1}.`; - const shotStatusColor = - shot.in.status === 'splash' - ? shotStatusColors.splash - : shotStatusColors[layer.status]; + + {shot.in.layers.map((layer, index) => { + const layerName = + layer.type === ArmorType.External + ? externalLayerNames[layer.variant] + : layerTypeNames[`${layer.type}`]; + const layerIndex = + layer.type === null + ? undefined + : `${(shot.containsGaps ? index / 2 : index) + 1}.`; + const shotStatusColor = + shot.in.status === 'splash' + ? shotStatusColors.splash + : shotStatusColors[layer.status]; - return ( - - - {layerIndex} - - - {layerName} - - - {layer.type === null && ( - <> - - {Math.round(layer.distance * 1000).toLocaleString()} - mm - - - - {Math.max(-100, Math.round(-50 * layer.distance))}% - penetration - - - )} - - {layer.type === ArmorType.External && ( - - {Math.round(layer.thickness).toLocaleString()} - mm - - )} - - {(layer.type === ArmorType.Core || - layer.type === ArmorType.Spaced) && ( - <> - - {Math.round(layer.thicknessAngled).toLocaleString()} - mm - - - - {Math.round(layer.thickness).toLocaleString()} - mm nominal - - - )} - - ); - })} + return ( + + ); + })} + + + {shot.out && shot.out.layers.length > 0 && ( + <> + + + + + + {shot.out.status !== 'blocked' && + shot.out.status !== 'ricochet' && ( + <> + {Math.round(shot.damage).toLocaleString()} + hp + + )} + {` ${shotStatusNames[shot.out.status]} `} + + + + {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 ( + + ); + })} + + + )} @@ -204,7 +293,7 @@ export function ShotDisplay() { @@ -221,6 +310,29 @@ export function ShotDisplay() { + + {shot.out && ( + + + + + + + + + + + + )} ); } diff --git a/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx b/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx index 3087c6fa9..0c15881ed 100644 --- a/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx +++ b/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx @@ -5,7 +5,9 @@ import { Object3D, Plane, Quaternion, + Raycaster, Scene, + Vector3, } from 'three'; import { degToRad } from 'three/src/math/MathUtils'; import { isExplosive } from '../../../../core/blitz/isExplosive'; @@ -77,6 +79,262 @@ export function SpacedArmorSceneComponent({ }: SpacedArmorSceneComponentProps) { 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[]; + 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 ( <> {props.type === ArmorType.Core && @@ -93,231 +351,12 @@ export function SpacedArmorSceneComponent({ async onClick(event) { event.stopPropagation(); - const { antagonist, protagonist } = useDuel.getState(); - const shell = antagonist!.shell; - const cameraNormal = camera.position - .clone() - .sub(event.point) - .normalize(); - const shot: Shot = { - damage: -1, - containsGaps: shell.type === ShellType.HEAT, - in: { - cameraNormal, - layers: [], - status: 'blocked', - }, - point: event.point, - }; + const shot = (await shoot( + event.point, + event.intersections, + true, + ))!; - 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 }); }, }, diff --git a/src/stores/tankopedia.ts b/src/stores/tankopedia.ts index 9610c97ff..44a1d4971 100644 --- a/src/stores/tankopedia.ts +++ b/src/stores/tankopedia.ts @@ -77,14 +77,14 @@ export type ShotLayer = ShotLayerExternal | ShotLayerNonExternal | ShotLayerGap; export type Shot = { containsGaps: boolean; damage: number; - point: Vector3; in: { - cameraNormal: Vector3; + surfaceNormal: Vector3; status: ShotStatus; layers: ShotLayer[]; }; out?: { + surfaceNormal: Vector3; status: ShotStatus; layers: ShotLayer[]; };