feat: out tracer

This commit is contained in:
TrèsAbhi committed 2024-05-26 14:39:16 -05:00
1 parent 7f8797a449
commit e16f3653bb
3 files changed
+284 -133

No files matched your search

+194 -82
View File
@@ -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 { Html } from '@react-three/drei';
import { useFrame } from '@react-three/fiber'; import { useFrame } from '@react-three/fiber';
import { ComponentProps, useRef } from 'react'; import { ComponentProps, useRef } from 'react';
import { Euler, Mesh, Quaternion } from 'three'; import { Euler, Mesh, Quaternion } from 'three';
import { radToDeg } from 'three/src/math/MathUtils';
import { J_HAT } from '../../../constants/axis'; import { J_HAT } from '../../../constants/axis';
import { ShotStatus, useTankopediaTemporary } from '../../../stores/tankopedia'; import {
ShotLayer,
ShotStatus,
useTankopediaTemporary,
} from '../../../stores/tankopedia';
import { ArmorType } from './SpacedArmorScene'; import { ArmorType } from './SpacedArmorScene';
import { ExternalModuleVariant } from './SpacedArmorSceneComponent'; import { ExternalModuleVariant } from './SpacedArmorSceneComponent';
@@ -22,8 +27,8 @@ const externalLayerNames: Record<ExternalModuleVariant, string> = {
const shotStatusNames: Record<ShotStatus, string> = { const shotStatusNames: Record<ShotStatus, string> = {
blocked: 'Blocked', blocked: 'Blocked',
ricochet: 'Ricochet',
penetration: 'penetration', penetration: 'penetration',
ricochet: 'ricochet',
splash: 'splash', splash: 'splash',
}; };
@@ -41,18 +46,107 @@ const LENGTH_INFINITY = 4;
const TRACER_THICK = 1 / 64; const TRACER_THICK = 1 / 64;
const TRACER_THIN = TRACER_THICK / 2; const TRACER_THIN = TRACER_THICK / 2;
function LayerEntry({
layerIndex,
shotStatusColor,
layerName,
layer,
angle,
}: {
layerIndex?: string;
shotStatusColor: ComponentProps<typeof Text>['color'];
layerName: string;
layer: ShotLayer;
angle?: number;
}) {
return (
<Flex align="center" gap="2">
<Text
size="1"
color="gray"
style={{
width: 12,
}}
>
{layerIndex}
</Text>
<Text size="2" color={shotStatusColor} style={{ width: 47 }}>
{layerName}
</Text>
{layer.type === null && (
<>
<Text
size="2"
color={shotStatusColor}
style={{
width: 64,
}}
>
{Math.round(layer.distance * 1000).toLocaleString()}
<Text size="1">mm</Text>
</Text>
<Text size="2" color="gray">
{Math.max(-100, Math.round(-50 * layer.distance))}% penetration
</Text>
</>
)}
{layer.type === ArmorType.External && (
<Text
size="2"
color={shotStatusColor}
style={{
width: 64,
}}
>
{Math.round(layer.thickness).toLocaleString()}
<Text size="1">mm</Text>
</Text>
)}
{(layer.type === ArmorType.Core || layer.type === ArmorType.Spaced) && (
<>
<Text
size="2"
color={shotStatusColor}
style={{
width: 80,
}}
>
{Math.round(layer.thicknessAngled).toLocaleString()}
<Text size="1">{`mm${angle === undefined ? '' : ` ${Math.round(radToDeg(angle))}°`}`}</Text>
</Text>
<Text size="2" color="gray">
{Math.round(layer.thickness).toLocaleString()}
<Text size="1">mm</Text> nominal
</Text>
</>
)}
</Flex>
);
}
export function ShotDisplay() { export function ShotDisplay() {
const shot = useTankopediaTemporary((state) => state.shot); const shot = useTankopediaTemporary((state) => state.shot);
const inTracer = useRef<Mesh>(null); const inTracer = useRef<Mesh>(null);
const outTracer = useRef<Mesh>(null);
useFrame(({ clock }) => { useFrame(({ clock }) => {
if (!shot || !inTracer.current) return; if (!shot || !inTracer.current) return;
const t = clock.elapsedTime % 1; const t1 = clock.elapsedTime % 1;
const scale1 = -Math.abs(2 * t - 1) + 1; const t2 = (clock.elapsedTime + 0.5) % 1;
inTracer.current.scale.set(1, scale1, 1); inTracer.current.scale.set(1, 1 - 2 * Math.abs(t1 - 0.5), 1);
inTracer.current.position.set(0, (1 - t) * inLength, 0); 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; if (!shot) return null;
@@ -74,9 +168,11 @@ export function ShotDisplay() {
}} }}
> >
<Card ml="9" style={{ width: 300 }}> <Card ml="9" style={{ width: 300 }}>
<Flex direction="column" gap="2">
<Flex direction="column" gap="1"> <Flex direction="column" gap="1">
<Text color={shotStatusColors[shot.in.status]} weight="bold"> <Text color={shotStatusColors[shot.in.status]} weight="bold">
{shot.in.status !== 'blocked' && ( {shot.in.status !== 'blocked' &&
shot.in.status !== 'ricochet' && (
<> <>
{Math.round(shot.damage).toLocaleString()} {Math.round(shot.damage).toLocaleString()}
<Text size="1">hp</Text> <Text size="1">hp</Text>
@@ -93,7 +189,7 @@ export function ShotDisplay() {
: layerTypeNames[`${layer.type}`]; : layerTypeNames[`${layer.type}`];
const layerIndex = const layerIndex =
layer.type === null layer.type === null
? null ? undefined
: `${(shot.containsGaps ? index / 2 : index) + 1}.`; : `${(shot.containsGaps ? index / 2 : index) + 1}.`;
const shotStatusColor = const shotStatusColor =
shot.in.status === 'splash' shot.in.status === 'splash'
@@ -101,82 +197,75 @@ export function ShotDisplay() {
: shotStatusColors[layer.status]; : shotStatusColors[layer.status];
return ( return (
<Flex align="center" gap="2"> <LayerEntry
<Text layer={layer}
size="1" layerIndex={layerIndex}
color="gray" layerName={layerName}
style={{ shotStatusColor={shotStatusColor}
width: 12, angle={
}} layer.type === ArmorType.Core ||
> layer.type === ArmorType.Spaced
{layerIndex} ? layer.angle
</Text> : undefined
<Text }
size="2" />
color={shotStatusColor}
style={{ width: 47 }}
>
{layerName}
</Text>
{layer.type === null && (
<>
<Text
size="2"
color={shotStatusColor}
style={{
width: 64,
}}
>
{Math.round(layer.distance * 1000).toLocaleString()}
<Text size="1">mm</Text>
</Text>
<Text size="2" color="gray">
{Math.max(-100, Math.round(-50 * layer.distance))}%
penetration
</Text>
</>
)}
{layer.type === ArmorType.External && (
<Text
size="2"
color={shotStatusColor}
style={{
width: 64,
}}
>
{Math.round(layer.thickness).toLocaleString()}
<Text size="1">mm</Text>
</Text>
)}
{(layer.type === ArmorType.Core ||
layer.type === ArmorType.Spaced) && (
<>
<Text
size="2"
color={shotStatusColor}
style={{
width: 64,
}}
>
{Math.round(layer.thicknessAngled).toLocaleString()}
<Text size="1">mm</Text>
</Text>
<Text size="2" color="gray">
{Math.round(layer.thickness).toLocaleString()}
<Text size="1">mm</Text> nominal
</Text>
</>
)}
</Flex>
); );
})} })}
</Flex> </Flex>
</Flex> </Flex>
{shot.out && shot.out.layers.length > 0 && (
<>
<Inset side="x">
<Separator size="4" />
</Inset>
<Text color={shotStatusColors[shot.out.status]} weight="bold">
{shot.out.status !== 'blocked' &&
shot.out.status !== 'ricochet' && (
<>
{Math.round(shot.damage).toLocaleString()}
<Text size="1">hp</Text>
</>
)}
{` ${shotStatusNames[shot.out.status]} `}
</Text>
<Flex direction="column">
{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 (
<LayerEntry
layer={layer}
layerIndex={layerIndex}
layerName={layerName}
shotStatusColor={shotStatusColor}
angle={
layer.type === ArmorType.Core ||
layer.type === ArmorType.Spaced
? layer.angle
: undefined
}
/>
);
})}
</Flex>
</>
)}
</Flex>
</Card> </Card>
</Html> </Html>
@@ -204,7 +293,7 @@ export function ShotDisplay() {
<group <group
position={inLast.point} position={inLast.point}
rotation={new Euler().setFromQuaternion( rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(J_HAT, shot.in.cameraNormal), new Quaternion().setFromUnitVectors(J_HAT, shot.in.surfaceNormal),
)} )}
> >
<mesh position={[0, inLength / 2, 0]}> <mesh position={[0, inLength / 2, 0]}>
@@ -221,6 +310,29 @@ export function ShotDisplay() {
<meshBasicMaterial color="red" /> <meshBasicMaterial color="red" />
</mesh> </mesh>
</group> </group>
{shot.out && (
<group
position={inLast.point}
rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(J_HAT, shot.out.surfaceNormal),
)}
>
<mesh position={[0, inLength / 2, 0]}>
<cylinderGeometry
args={[TRACER_THIN / 2, TRACER_THIN / 2, inLength]}
/>
<meshBasicMaterial depthTest={false} />
</mesh>
<mesh ref={outTracer} position={[0, inLength / 2, 0]}>
<cylinderGeometry
args={[TRACER_THICK / 2, TRACER_THICK / 2, inLength]}
/>
<meshBasicMaterial color="red" />
</mesh>
</group>
)}
</group> </group>
); );
} }
@@ -5,7 +5,9 @@ import {
Object3D, Object3D,
Plane, Plane,
Quaternion, Quaternion,
Raycaster,
Scene, Scene,
Vector3,
} from 'three'; } from 'three';
import { degToRad } from 'three/src/math/MathUtils'; import { degToRad } from 'three/src/math/MathUtils';
import { isExplosive } from '../../../../core/blitz/isExplosive'; import { isExplosive } from '../../../../core/blitz/isExplosive';
@@ -77,37 +79,22 @@ export function SpacedArmorSceneComponent({
}: SpacedArmorSceneComponentProps) { }: SpacedArmorSceneComponentProps) {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
return ( async function shoot(
<> point: Vector3,
{props.type === ArmorType.Core && intersections: Intersection[],
jsxTree( allowRicochet: boolean,
node, ) {
{
renderOrder: 0,
material: omitMaterial,
userData: {
type: ArmorType.Core,
thickness,
} satisfies ArmorUserData,
async onClick(event) {
event.stopPropagation();
const { antagonist, protagonist } = useDuel.getState(); const { antagonist, protagonist } = useDuel.getState();
const shell = antagonist!.shell; const shell = antagonist!.shell;
const cameraNormal = camera.position const cameraNormal = camera.position.clone().sub(point).normalize();
.clone()
.sub(event.point)
.normalize();
const shot: Shot = { const shot: Shot = {
damage: -1, damage: -1,
containsGaps: shell.type === ShellType.HEAT, containsGaps: shell.type === ShellType.HEAT,
in: { in: {
cameraNormal, surfaceNormal: cameraNormal,
layers: [], layers: [],
status: 'blocked', status: 'blocked',
}, },
point: event.point,
}; };
const hasCalibratedShells = await hasEquipment( const hasCalibratedShells = await hasEquipment(
@@ -124,13 +111,10 @@ export function SpacedArmorSceneComponent({
resolveNearPenetration(shell.penetration) * resolveNearPenetration(shell.penetration) *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type); resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1; const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1;
const filteredIntersections = event.intersections.filter( const filteredIntersections = intersections.filter(
(intersection) => 'type' in intersection.object.userData, (intersection) => 'type' in intersection.object.userData,
) as unknown as Intersection< ) as unknown as Intersection<Object3D & { userData: ArmorUserData }>[];
Object3D & { userData: ArmorUserData } const encounteredExternalModuleVariants: ExternalModuleVariant[] = [];
>[];
const encounteredExternalModuleVariants: ExternalModuleVariant[] =
[];
const noDuplicateIntersections: typeof filteredIntersections = []; const noDuplicateIntersections: typeof filteredIntersections = [];
for (const intersection of filteredIntersections) { for (const intersection of filteredIntersections) {
@@ -158,12 +142,15 @@ export function SpacedArmorSceneComponent({
let index = 0; let index = 0;
for (const intersection of noDuplicateIntersections) { for (const intersection of noDuplicateIntersections) {
const layer = intersection.object.userData; const layer = intersection.object.userData;
const surfaceNormal = intersection
.normal!.clone()
.applyQuaternion(
intersection.object.getWorldQuaternion(new Quaternion()),
);
if (shell.type === ShellType.HEAT && index > 0) { if (shell.type === ShellType.HEAT && index > 0) {
const lastLayer = shot.in.layers.at(-1) as ShotLayerBase; const lastLayer = shot.in.layers.at(-1) as ShotLayerBase;
const distance = lastLayer.point.distanceTo( const distance = lastLayer.point.distanceTo(intersection.point);
intersection.point,
);
remainingPenetration -= 0.5 * remainingPenetration * distance; remainingPenetration -= 0.5 * remainingPenetration * distance;
const blocked = remainingPenetration <= 0; const blocked = remainingPenetration <= 0;
@@ -187,7 +174,7 @@ export function SpacedArmorSceneComponent({
index, index,
thickness, thickness,
point: intersection.point, point: intersection.point,
surfaceNormal: intersection.face!.normal, surfaceNormal,
status: blocked ? 'blocked' : 'penetration', status: blocked ? 'blocked' : 'penetration',
variant: layer.variant, variant: layer.variant,
}); });
@@ -196,22 +183,16 @@ export function SpacedArmorSceneComponent({
const ricochet = degToRad( const ricochet = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!, isExplosive(shell.type) ? 90 : shell.ricochet!,
); );
const surfaceNormal = intersection
.normal!.clone()
.applyQuaternion(
event.object.getWorldQuaternion(new Quaternion()),
);
const angle = surfaceNormal.angleTo(cameraNormal); const angle = surfaceNormal.angleTo(cameraNormal);
const threeCalibersRule = const threeCalibersRule =
shell.caliber > thickness * 3 || index > 0; shell.caliber > thickness * 3 || index > 0 || !allowRicochet;
const twoCalibersRule = shell.caliber > thickness * 2; const twoCalibersRule = shell.caliber > thickness * 2;
const normalization = degToRad(shell.normalization ?? 0); const normalization = degToRad(shell.normalization ?? 0);
const finalNormalization = twoCalibersRule const finalNormalization = twoCalibersRule
? (1.4 * normalization * shell.caliber) / (2.0 * thickness) ? (1.4 * normalization * shell.caliber) / (2.0 * thickness)
: normalization; : normalization;
const finalThickness = const finalThickness =
thickness / thickness / Math.cos(Math.max(0, angle - finalNormalization));
Math.cos(Math.max(0, angle - finalNormalization));
if (!threeCalibersRule && angle >= ricochet) { if (!threeCalibersRule && angle >= ricochet) {
remainingPenetration *= 0.75; remainingPenetration *= 0.75;
@@ -222,7 +203,7 @@ export function SpacedArmorSceneComponent({
thickness, thickness,
thicknessAngled: finalThickness, thicknessAngled: finalThickness,
point: intersection.point, point: intersection.point,
surfaceNormal: intersection.face!.normal, surfaceNormal,
status: 'ricochet', status: 'ricochet',
angle, angle,
}); });
@@ -252,9 +233,12 @@ export function SpacedArmorSceneComponent({
index++; index++;
} }
const lastLayer = shot.in.layers.at(-1) as ShotLayerNonExternal; const lastLayer = shot.in.layers.at(-1) as ShotLayerNonExternal | undefined;
const firstLayer = shot.in.layers[0] as ShotLayerBase; const firstLayer = shot.in.layers[0] as ShotLayerBase | undefined;
if (!lastLayer || !firstLayer) {
return null;
} else {
if (shell.type === ShellType.HE) { if (shell.type === ShellType.HE) {
const totalSpacedArmorThickness = shot.in.layers.reduce( const totalSpacedArmorThickness = shot.in.layers.reduce(
(acc, layer) => { (acc, layer) => {
@@ -294,10 +278,7 @@ export function SpacedArmorSceneComponent({
* for parity's sake, I am capping the value here. * for parity's sake, I am capping the value here.
*/ */
if ( if (shot.in.layers.length > 1 || lastLayer.status === 'blocked') {
shot.in.layers.length > 1 ||
lastLayer.status === 'blocked'
) {
shot.in.status = finalDamage > 0 ? 'splash' : 'blocked'; shot.in.status = finalDamage > 0 ? 'splash' : 'blocked';
shot.damage = finalDamage; shot.damage = finalDamage;
} else { } else {
@@ -312,12 +293,70 @@ export function SpacedArmorSceneComponent({
shot.in.status = 'penetration'; shot.in.status = 'penetration';
shot.damage = shell.damage.armor; shot.damage = shell.damage.armor;
} else { } else {
// check second array for result 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'; 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;
}
}
} }
} }
console.log(shot); return shot;
}
return (
<>
{props.type === ArmorType.Core &&
jsxTree(
node,
{
renderOrder: 0,
material: omitMaterial,
userData: {
type: ArmorType.Core,
thickness,
} satisfies ArmorUserData,
async onClick(event) {
event.stopPropagation();
const shot = (await shoot(
event.point,
event.intersections,
true,
))!;
useTankopediaTemporary.setState({ shot }); useTankopediaTemporary.setState({ shot });
}, },
}, },
+2 -2
View File
@@ -77,14 +77,14 @@ export type ShotLayer = ShotLayerExternal | ShotLayerNonExternal | ShotLayerGap;
export type Shot = { export type Shot = {
containsGaps: boolean; containsGaps: boolean;
damage: number; damage: number;
point: Vector3;
in: { in: {
cameraNormal: Vector3; surfaceNormal: Vector3;
status: ShotStatus; status: ShotStatus;
layers: ShotLayer[]; layers: ShotLayer[];
}; };
out?: { out?: {
surfaceNormal: Vector3;
status: ShotStatus; status: ShotStatus;
layers: ShotLayer[]; layers: ShotLayer[];
}; };