mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-22 03:07:04 +02:00
486 lines
15 KiB
TypeScript
486 lines
15 KiB
TypeScript
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<ArmorType, ArmorType.External>;
|
|
}
|
|
| {
|
|
type: ArmorType.External;
|
|
variant: ExternalModuleVariant;
|
|
}
|
|
);
|
|
|
|
export type ExternalModuleVariant = "gun" | "track";
|
|
export type ArmorUserData = {
|
|
thickness: number;
|
|
} & (
|
|
| {
|
|
type: Exclude<ArmorType, ArmorType.External>;
|
|
}
|
|
| {
|
|
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<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.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 (
|
|
<mesh
|
|
{...props}
|
|
key={`${key}-spaced-component`}
|
|
renderOrder={0}
|
|
material={omitMaterial}
|
|
userData={
|
|
{
|
|
type: ArmorType.Primary,
|
|
thickness,
|
|
} satisfies ArmorUserData
|
|
}
|
|
onClick={(event) => {
|
|
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 && (
|
|
<SpacedArmorSubSpaced node={node} thickness={thickness} />
|
|
)}
|
|
|
|
{props.type === ArmorType.External && (
|
|
<SpacedArmorSubExternal
|
|
node={node}
|
|
thickness={thickness}
|
|
variant={props.variant}
|
|
clip={clip}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|