Files
blitzkit/packages/website/src/components/SpacedArmorSceneComponent.tsx
T

485 lines
15 KiB
TypeScript
Raw Normal View History

2026-09-27 17:19:39 -05:00
import { isExplosive, resolvePenetrationCoefficient } from "@blitzkit/core";
import { ShellType } from "@blitzkit/protos";
2025-09-01 22:56:39 -05:00
import { useThree } from "@react-three/fiber";
import { useCallback } from "react";
2024-02-28 22:29:21 -06:00
import {
2024-09-14 19:43:14 -05:00
type Intersection,
2024-02-28 22:29:21 -06:00
MeshBasicMaterial,
Object3D,
2024-04-13 10:36:13 -05:00
Plane,
2024-02-28 22:29:21 -06:00
Quaternion,
2024-05-26 14:39:16 -05:00
Raycaster,
2024-02-28 22:29:21 -06:00
Scene,
2024-05-26 14:39:16 -05:00
Vector3,
2025-09-01 22:56:39 -05:00
} from "three";
import { degToRad } from "three/src/math/MathUtils.js";
2026-09-30 17:41:46 -05:00
import { defaultEqualizer } from "../config/equalizer";
2026-09-27 16:46:22 -05:00
import { jsxTree } from "../core/blitzkit/jsxTree";
import { discardClippingPlane } from "../core/three/discardClippingPlane";
2026-09-30 17:41:46 -05:00
import { DuelModule, getDuel } from "../hooks/useDuel";
import { hasEquipment } from "../hooks/useEquipment";
2024-09-22 21:12:18 -05:00
import {
type Shot,
type ShotLayerBase,
type ShotLayerNonExternal,
2025-09-01 22:56:39 -05:00
Tankopedia,
2026-09-27 16:46:22 -05:00
} from "../stores/tankopedia";
import { ArmorType } from "./SpacedArmorScene";
import { SpacedArmorSubExternal } from "./SpacedArmorSubExternal";
import { SpacedArmorSubSpaced } from "./SpacedArmorSubSpaced";
2024-02-28 17:41:59 -06:00
type SpacedArmorSceneComponentProps = {
node: Object3D;
thickness: number;
2024-04-13 10:36:13 -05:00
clip?: Plane;
2024-07-31 13:36:56 -05:00
scene: Scene;
2024-02-28 17:41:59 -06:00
} & (
| {
type: Exclude<ArmorType, ArmorType.External>;
}
| {
type: ArmorType.External;
variant: ExternalModuleVariant;
}
2024-07-31 13:36:56 -05:00
);
2024-02-28 17:41:59 -06:00
2025-09-01 22:56:39 -05:00
export type ExternalModuleVariant = "gun" | "track";
2024-02-28 17:41:59 -06:00
export type ArmorUserData = {
thickness: number;
} & (
| {
2024-02-28 22:29:21 -06:00
type: Exclude<ArmorType, ArmorType.External>;
2024-02-28 17:41:59 -06:00
}
| {
2024-02-28 22:29:21 -06:00
type: ArmorType.External;
2024-02-28 17:41:59 -06:00
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)
);
}
2024-02-28 17:41:59 -06:00
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,
2024-04-13 10:36:13 -05:00
clip,
2024-02-28 17:41:59 -06:00
...props
}: SpacedArmorSceneComponentProps) {
const camera = useThree((state) => state.camera);
2024-07-22 20:47:29 -05:00
const shoot = useCallback(
2025-08-29 13:42:44 -05:00
(
2024-07-22 20:47:29 -05:00
point: Vector3,
intersections: Intersection[],
allowRicochet: boolean,
2026-03-31 14:11:45 -05:00
remainingPenetrationInput?: number,
penetrationScale = 1,
2024-07-22 20:47:29 -05:00
) => {
2026-09-30 17:41:46 -05:00
const protagonist = getDuel("protagonist", DuelModule.Tank);
const antagonist = getDuel("antagonist", DuelModule.Tank);
2025-09-01 22:56:39 -05:00
const { customShell } = Tankopedia.state;
2026-09-30 17:41:46 -05:00
const shell = customShell ?? getDuel("protagonist", DuelModule.Shell);
2026-10-04 16:33:53 -05:00
const { equalize } = Tankopedia.state.environment;
2026-04-03 12:57:43 -05:00
const protagonistEqualizer =
2026-09-30 17:41:46 -05:00
(equalize ? protagonist.equalizer : undefined) ?? defaultEqualizer;
2026-04-03 12:57:43 -05:00
const antagonistEqualizer =
2026-09-30 17:41:46 -05:00
(equalize ? antagonist.equalizer : undefined) ?? defaultEqualizer;
2024-07-22 20:47:29 -05:00
const cameraNormal = camera.position.clone().sub(point).normalize();
2024-09-22 21:12:18 -05:00
const shot: Shot = {
2024-11-14 20:53:21 -06:00
splashRadius:
2026-03-31 14:11:45 -05:00
shell.type === ShellType.SHELL_TYPE_HE
? shell.explosion_radius
: undefined,
2024-07-22 20:47:29 -05:00
damage: -1,
2026-03-31 14:11:45 -05:00
containsGaps: shell.type === ShellType.SHELL_TYPE_HEAT,
2024-07-22 20:47:29 -05:00
in: {
surfaceNormal: cameraNormal,
layers: [],
2025-09-01 22:56:39 -05:00
status: "blocked",
2024-07-22 20:47:29 -05:00
},
};
2024-05-26 14:39:16 -05:00
2025-08-29 13:42:44 -05:00
const hasCalibratedShells = hasEquipment(
2024-07-22 20:47:29 -05:00
103,
2026-09-30 17:41:46 -05:00
antagonist.equipment_preset,
Tankopedia.state.antagonist.equipment,
2024-07-22 20:47:29 -05:00
);
2025-08-29 13:42:44 -05:00
const hasEnhancedArmor = hasEquipment(
2024-07-22 20:47:29 -05:00
110,
2026-09-30 17:41:46 -05:00
protagonist.equipment_preset,
Tankopedia.state.protagonist.equipment,
2024-07-22 20:47:29 -05:00
);
const penetration =
penetrationScale *
2026-03-31 14:11:45 -05:00
shell.penetration!.near *
2026-04-03 12:57:43 -05:00
resolvePenetrationCoefficient(
hasCalibratedShells,
equalize,
shell.type,
antagonistEqualizer,
);
const thicknessCoefficient =
2026-07-31 19:48:03 -05:00
(hasEnhancedArmor ? 1.04 : 1) * protagonistEqualizer.armor;
2024-07-22 20:47:29 -05:00
const filteredIntersections = intersections.filter(
2024-07-31 14:18:58 -05:00
(intersection) =>
2025-09-01 22:56:39 -05:00
"type" in intersection.object.userData &&
2026-03-31 14:11:45 -05:00
!discardClippingPlane(intersection.object, intersection.point),
2024-07-22 20:47:29 -05:00
) as unknown as Intersection<Object3D & { userData: ArmorUserData }>[];
const encounteredExternalModuleVariants: ExternalModuleVariant[] = [];
const noDuplicateIntersections: typeof filteredIntersections = [];
2024-05-26 14:39:16 -05:00
2024-07-22 20:47:29 -05:00
for (const intersection of filteredIntersections) {
if (intersection.object.userData.type === ArmorType.External) {
if (
!encounteredExternalModuleVariants.includes(
2026-03-31 14:11:45 -05:00
intersection.object.userData.variant,
2024-07-22 20:47:29 -05:00
)
) {
noDuplicateIntersections.push(intersection);
encounteredExternalModuleVariants.push(
2026-03-31 14:11:45 -05:00
intersection.object.userData.variant,
2024-07-22 20:47:29 -05:00
);
}
2024-05-26 14:39:16 -05:00
} else {
2024-07-22 20:47:29 -05:00
noDuplicateIntersections.push(intersection);
2024-07-29 20:46:57 -05:00
if (intersection.object.userData.type === ArmorType.Primary) {
2024-07-22 20:47:29 -05:00
break;
}
}
}
if (
!allowRicochet &&
2026-07-10 17:51:32 +12:00
noDuplicateIntersections.every(
(intersection) =>
intersection.object.userData.type !== ArmorType.Primary,
)
) {
return null;
}
2024-07-22 20:47:29 -05:00
let remainingPenetration = remainingPenetrationInput ?? penetration;
let index = 0;
for (const intersection of noDuplicateIntersections) {
const layer = intersection.object.userData;
const surfaceNormal = intersection
.normal!.clone()
.applyQuaternion(
2026-03-31 14:11:45 -05:00
intersection.object.getWorldQuaternion(new Quaternion()),
2024-07-22 20:47:29 -05:00
);
2026-03-31 14:11:45 -05:00
if (shell.type === ShellType.SHELL_TYPE_HEAT && index > 0) {
2024-09-22 21:12:18 -05:00
const lastLayer = shot.in.layers.at(-1) as ShotLayerBase;
2024-07-22 20:47:29 -05:00
const distance = lastLayer.point.distanceTo(intersection.point);
remainingPenetration -= 0.5 * remainingPenetration * distance;
2024-05-26 14:39:16 -05:00
const blocked = remainingPenetration <= 0;
shot.in.layers.push({
2024-07-22 20:47:29 -05:00
type: null,
distance,
2025-09-01 22:56:39 -05:00
status: blocked ? "blocked" : "penetration",
2024-07-22 20:47:29 -05:00
});
if (blocked) break;
}
if (layer.type === ArmorType.External) {
const thickness = layer.thickness * thicknessCoefficient;
remainingPenetration -= thickness;
const blocked =
2026-03-31 14:11:45 -05:00
remainingPenetration <= 0 || shell.type === ShellType.SHELL_TYPE_HE;
2024-07-22 20:47:29 -05:00
shot.in.layers.push({
type: ArmorType.External,
2024-05-26 14:39:16 -05:00
index,
thickness,
point: intersection.point,
2024-07-22 20:47:29 -05:00
surfaceNormal,
2024-05-27 21:01:07 -05:00
status:
2026-03-31 14:11:45 -05:00
shell.type === ShellType.SHELL_TYPE_HE
2025-09-01 22:56:39 -05:00
? "blocked"
2024-05-27 21:01:07 -05:00
: blocked
2025-09-01 22:56:39 -05:00
? "blocked"
: "penetration",
2024-07-22 20:47:29 -05:00
variant: layer.variant,
2024-05-26 14:39:16 -05:00
});
} else {
2024-10-24 15:40:58 -05:00
const thickness = layer.thickness * thicknessCoefficient;
2024-07-22 20:47:29 -05:00
const ricochet = degToRad(
2026-03-31 14:11:45 -05:00
isExplosive(shell.type) ? 90 : shell.ricochet!,
2024-07-22 20:47:29 -05:00
);
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,
2025-09-01 22:56:39 -05:00
status: "ricochet",
2024-07-22 20:47:29 -05:00
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:
2026-03-31 14:11:45 -05:00
shell.type === ShellType.SHELL_TYPE_HE &&
layer.type !== ArmorType.Primary
2025-09-01 22:56:39 -05:00
? "blocked"
2024-07-22 20:47:29 -05:00
: blocked
2025-09-01 22:56:39 -05:00
? "blocked"
: "penetration",
2024-07-22 20:47:29 -05:00
angle,
});
}
2024-05-26 14:39:16 -05:00
}
2024-07-22 20:47:29 -05:00
2026-03-31 14:11:45 -05:00
if (
shell.type !== ShellType.SHELL_TYPE_HE &&
remainingPenetration <= 0
) {
2024-07-22 20:47:29 -05:00
break;
}
index++;
}
const lastLayer = shot.in.layers.at(-1) as
2024-09-22 21:12:18 -05:00
| ShotLayerNonExternal
2024-07-22 20:47:29 -05:00
| undefined;
2024-09-22 21:12:18 -05:00
const firstLayer = shot.in.layers[0] as ShotLayerBase | undefined;
2024-07-22 20:47:29 -05:00
if (!lastLayer || !firstLayer) {
return null;
2024-05-26 14:39:16 -05:00
} else {
2026-03-31 14:11:45 -05:00
if (shell.type === ShellType.SHELL_TYPE_HE) {
2024-07-22 20:47:29 -05:00
const totalSpacedArmorThickness = shot.in.layers.reduce(
(acc, layer) => {
2024-07-29 20:46:57 -05:00
if (layer.type === null || layer.type === ArmorType.Primary) {
2024-07-22 20:47:29 -05:00
return acc;
}
if (layer.type === ArmorType.External) {
return acc + layer.thickness;
}
return acc + layer.thicknessAngled;
},
2026-03-31 14:11:45 -05:00
0,
2024-07-22 20:47:29 -05:00
);
const distanceFromSpacedArmor = firstLayer.point.distanceTo(
2026-03-31 14:11:45 -05:00
lastLayer.point,
2024-07-22 20:47:29 -05:00
);
const finalDamage = Math.max(
0,
0.5 *
2024-10-07 18:45:11 -05:00
shell.armor_damage *
(1 - distanceFromSpacedArmor / shell.explosion_radius!) -
2024-07-22 20:47:29 -05:00
1.1 *
(lastLayer.thicknessAngled +
2026-03-31 14:11:45 -05:00
Math.min(penetration, totalSpacedArmorThickness)),
2024-07-22 20:47:29 -05:00
);
/**
* 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.
*/
2025-09-01 22:56:39 -05:00
if (shot.in.layers.length > 1 || lastLayer.status === "blocked") {
shot.in.status = finalDamage > 0 ? "splash" : "blocked";
2024-07-22 20:47:29 -05:00
shot.damage = finalDamage;
} else {
2025-09-01 22:56:39 -05:00
shot.in.status = "penetration";
2024-10-07 18:45:11 -05:00
shot.damage = shell.armor_damage;
2024-07-22 20:47:29 -05:00
}
2024-05-26 14:39:16 -05:00
} else {
2025-09-01 22:56:39 -05:00
if (lastLayer.status === "blocked") {
shot.in.status = "blocked";
2024-07-22 20:47:29 -05:00
shot.damage = 0;
2025-09-01 22:56:39 -05:00
} else if (lastLayer.status === "penetration") {
shot.in.status = "penetration";
2024-10-07 18:45:11 -05:00
shot.damage = shell.armor_damage;
2024-07-22 20:47:29 -05:00
} 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()
2026-03-31 14:11:45 -05:00
.multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal)),
2024-07-22 20:47:29 -05:00
);
caster.set(lastLayer.point, ricochetNormal);
const ricochetIntersections = caster.intersectObjects(
2024-07-29 14:08:52 -05:00
props.scene.children,
2026-03-31 14:11:45 -05:00
true,
2024-07-22 20:47:29 -05:00
);
2025-08-29 13:42:44 -05:00
const outShot = shoot(
2024-07-22 20:47:29 -05:00
lastLayer.point,
ricochetIntersections,
false,
2026-03-31 14:11:45 -05:00
remainingPenetration,
penetrationScale,
2024-05-26 14:39:16 -05:00
);
2025-09-01 22:56:39 -05:00
shot.in.status = "ricochet";
2024-07-22 20:47:29 -05:00
if (outShot === null) {
shot.damage = 0;
shot.out = {
surfaceNormal: ricochetNormal,
layers: [],
2025-09-01 22:56:39 -05:00
status: "ricochet",
2024-07-22 20:47:29 -05:00
};
} else {
shot.damage = outShot.damage;
shot.out = outShot.in;
shot.out.surfaceNormal = ricochetNormal;
}
2024-05-26 14:39:16 -05:00
}
}
}
2026-04-03 12:57:43 -05:00
shot.damage *= antagonistEqualizer.damage;
2026-04-03 12:15:03 -05:00
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;
}
2024-07-22 20:47:29 -05:00
return shot;
},
2026-03-31 14:11:45 -05:00
[camera],
2024-07-22 20:47:29 -05:00
);
2024-05-26 14:39:16 -05:00
2024-02-28 17:41:59 -06:00
return (
<>
2024-07-29 20:46:57 -05:00
{props.type === ArmorType.Primary &&
2025-04-15 00:11:49 -05:00
jsxTree(node, {
2025-04-15 16:29:14 -05:00
mesh(_, props, key) {
2025-04-15 00:11:49 -05:00
return (
<mesh
{...props}
2025-04-15 16:29:14 -05:00
key={`${key}-spaced-component`}
2025-04-15 00:11:49 -05:00
renderOrder={0}
material={omitMaterial}
userData={
{
type: ArmorType.Primary,
thickness,
} satisfies ArmorUserData
}
2025-08-29 13:42:44 -05:00
onClick={(event) => {
2025-04-15 00:11:49 -05:00
event.stopPropagation();
2025-08-29 13:42:44 -05:00
const shot = shoot(event.point, event.intersections, true)!;
shot.penetrationChance = resolvePenetrationChance((scale) =>
shoot(
event.point,
event.intersections,
true,
undefined,
scale,
),
);
2025-09-01 22:56:39 -05:00
Tankopedia.mutate((draft) => {
2025-09-01 18:51:04 -05:00
draft.shot = shot;
});
2025-04-15 00:11:49 -05:00
}}
/>
);
2024-02-28 17:41:59 -06:00
},
2025-04-15 00:11:49 -05:00
})}
2024-02-28 17:41:59 -06:00
{props.type === ArmorType.Spaced && (
<SpacedArmorSubSpaced node={node} thickness={thickness} />
)}
{props.type === ArmorType.External && (
<SpacedArmorSubExternal
node={node}
thickness={thickness}
variant={props.variant}
2024-04-13 10:36:13 -05:00
clip={clip}
2024-02-28 17:41:59 -06:00
/>
)}
</>
);
}