import { isExplosive, resolvePenetrationCoefficient } from "@blitzkit/core"; import { ShellType } from "@blitzkit/protos"; import { useThree } from "@react-three/fiber"; import { useCallback } from "react"; import { type Intersection, MeshBasicMaterial, Object3D, Plane, Quaternion, Raycaster, Scene, Vector3, } from "three"; import { degToRad } from "three/src/math/MathUtils.js"; import { defaultEqualizer } from "../config/equalizer"; import { jsxTree } from "../core/blitzkit/jsxTree"; import { discardClippingPlane } from "../core/three/discardClippingPlane"; import { DuelModule, getDuel } from "../hooks/useDuel"; import { hasEquipment } from "../hooks/useEquipment"; import { type Shot, type ShotLayerBase, type ShotLayerNonExternal, Tankopedia, } from "../stores/tankopedia"; import { ArmorType } from "./SpacedArmorScene"; import { SpacedArmorSubExternal } from "./SpacedArmorSubExternal"; import { SpacedArmorSubSpaced } from "./SpacedArmorSubSpaced"; type SpacedArmorSceneComponentProps = { node: Object3D; thickness: number; clip?: Plane; scene: Scene; } & ( | { type: Exclude; } | { type: ArmorType.External; variant: ExternalModuleVariant; } ); export type ExternalModuleVariant = "gun" | "track"; export type ArmorUserData = { thickness: number; } & ( | { type: Exclude; } | { type: ArmorType.External; variant: ExternalModuleVariant; } ); const PENETRATION_RANDOMIZATION = 0.05; function resolvePenetrationChance(shoot: (scale: number) => Shot | null) { function penetrates(scale: number) { const shot = shoot(scale); return (shot?.out ?? shot?.in)?.status === "penetration"; } let low = 1 - PENETRATION_RANDOMIZATION; let high = 1 + PENETRATION_RANDOMIZATION; if (penetrates(low)) return 1; if (!penetrates(high)) return 0; for (let iteration = 0; iteration < 16; iteration++) { const middle = (low + high) / 2; if (penetrates(middle)) { high = middle; } else { low = middle; } } return ( (1 + PENETRATION_RANDOMIZATION - high) / (2 * PENETRATION_RANDOMIZATION) ); } const omitMaterial = new MeshBasicMaterial({ colorWrite: false, depthWrite: true, }); /** * Render orders allocations: * - 0 : core omission * - 1-2: spaced without depth buffer write but do test to allow additive blending * - 3-4: external with depth buffer write in first pass and read in second to simplify geometry * - 5 : spaced depth write */ export function SpacedArmorSceneComponent({ node, thickness, clip, ...props }: SpacedArmorSceneComponentProps) { const camera = useThree((state) => state.camera); const shoot = useCallback( ( point: Vector3, intersections: Intersection[], allowRicochet: boolean, remainingPenetrationInput?: number, penetrationScale = 1, ) => { const protagonist = getDuel("protagonist", DuelModule.Tank); const antagonist = getDuel("antagonist", DuelModule.Tank); const { customShell } = Tankopedia.state; const shell = customShell ?? getDuel("protagonist", DuelModule.Shell); const { equalize } = Tankopedia.state.environment; const protagonistEqualizer = (equalize ? protagonist.equalizer : undefined) ?? defaultEqualizer; const antagonistEqualizer = (equalize ? antagonist.equalizer : undefined) ?? defaultEqualizer; const cameraNormal = camera.position.clone().sub(point).normalize(); const shot: Shot = { splashRadius: shell.type === ShellType.SHELL_TYPE_HE ? shell.explosion_radius : undefined, damage: -1, containsGaps: shell.type === ShellType.SHELL_TYPE_HEAT, in: { surfaceNormal: cameraNormal, layers: [], status: "blocked", }, }; const hasCalibratedShells = hasEquipment( 103, antagonist.equipment_preset, Tankopedia.state.antagonist.equipment, ); const hasEnhancedArmor = hasEquipment( 110, protagonist.equipment_preset, Tankopedia.state.protagonist.equipment, ); const penetration = penetrationScale * shell.penetration!.near * resolvePenetrationCoefficient( hasCalibratedShells, equalize, shell.type, antagonistEqualizer, ); const thicknessCoefficient = (hasEnhancedArmor ? 1.04 : 1) * protagonistEqualizer.armor; const filteredIntersections = intersections.filter( (intersection) => "type" in intersection.object.userData && !discardClippingPlane(intersection.object, intersection.point), ) 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.Primary) { break; } } } if ( !allowRicochet && noDuplicateIntersections.every( (intersection) => intersection.object.userData.type !== ArmorType.Primary, ) ) { return null; } let remainingPenetration = remainingPenetrationInput ?? 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.SHELL_TYPE_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.SHELL_TYPE_HE; shot.in.layers.push({ type: ArmorType.External, index, thickness, point: intersection.point, surfaceNormal, status: shell.type === ShellType.SHELL_TYPE_HE ? "blocked" : blocked ? "blocked" : "penetration", variant: layer.variant, }); } else { const thickness = layer.thickness * thicknessCoefficient; 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: shell.type === ShellType.SHELL_TYPE_HE && layer.type !== ArmorType.Primary ? "blocked" : blocked ? "blocked" : "penetration", angle, }); } } if ( shell.type !== ShellType.SHELL_TYPE_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.SHELL_TYPE_HE) { const totalSpacedArmorThickness = shot.in.layers.reduce( (acc, layer) => { if (layer.type === null || layer.type === ArmorType.Primary) { 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.armor_damage * (1 - distanceFromSpacedArmor / shell.explosion_radius!) - 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.armor_damage; } } else { if (lastLayer.status === "blocked") { shot.in.status = "blocked"; shot.damage = 0; } else if (lastLayer.status === "penetration") { shot.in.status = "penetration"; shot.damage = shell.armor_damage; } 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( props.scene.children, true, ); const outShot = shoot( lastLayer.point, ricochetIntersections, false, remainingPenetration, penetrationScale, ); 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; shot.out.surfaceNormal = ricochetNormal; } } } } shot.damage *= antagonistEqualizer.damage; const hasSpallLiner = Duel.state.protagonist.provisions.includes( SPALL_LINER_PROVISION_ID, ); if (hasSpallLiner && shell.type === ShellType.SHELL_TYPE_HE) { shot.damage *= 1 + SPALL_LINER_HE_DAMAGE_DELTA; } return shot; }, [camera], ); return ( <> {props.type === ArmorType.Primary && jsxTree(node, { mesh(_, props, key) { return ( { event.stopPropagation(); const shot = shoot(event.point, event.intersections, true)!; shot.penetrationChance = resolvePenetrationChance((scale) => shoot( event.point, event.intersections, true, undefined, scale, ), ); Tankopedia.mutate((draft) => { draft.shot = shot; }); }} /> ); }, })} {props.type === ArmorType.Spaced && ( )} {props.type === ArmorType.External && ( )} ); }