feat: shot display

This commit is contained in:
TrèsAbhi committed 2024-02-28 22:29:21 -06:00
1 parent 69c388f243
commit f55313e0cd
10 files changed
+333 -351

No files matched your search

@@ -0,0 +1,88 @@
import { Text } from '@radix-ui/themes';
import { Html } from '@react-three/drei';
import { DoubleSide, Euler, Quaternion, Vector3 } from 'three';
import { J_HAT } from '../../../constants/axis';
import {
ShotLayerGap,
useTankopediaTemporary,
} from '../../../stores/tankopedia';
// export const SHOT_NAMES: Record<Shot['type'], string> = {
// ricochet: 'Ricochet',
// penetration: 'Penetration',
// block: 'Blocked',
// };
// export const SHOT_ICONS: Record<Shot['type'], ReactNode> = {
// ricochet: <Ricochet width={24} height={24} />,
// block: <Block width={24} height={24} />,
// penetration: <NominalPenetration width={24} height={24} />,
// };
// const THICKNESS = 0.05;
// const LENGTH = 4;
export function ShotScene() {
const shot = useTankopediaTemporary((state) => state.shot);
return (
<>
{shot?.map((layer, index) => {
if (layer.type === null) return;
const shellRotation = new Euler().setFromQuaternion(
new Quaternion().setFromAxisAngle(
new Vector3().crossVectors(J_HAT, layer.shellNormal).normalize(),
J_HAT.angleTo(layer.shellNormal),
),
);
return (
<>
<mesh renderOrder={1} key={`${index}-point`} position={layer.point}>
<icosahedronGeometry args={[1 / 16, 1]} />
<meshBasicMaterial
side={DoubleSide}
color={
layer.status === 'penetration'
? 0x00ff00
: layer.status === 'blocked'
? 0xff0000
: 0xffff00
}
/>
</mesh>
<group
key={`${index}-line`}
position={layer.point}
rotation={shellRotation}
>
{shot.length > 1 && (
<Html>
<Text>{layer.index + 1}</Text>
</Html>
)}
{(() => {
let length = 4;
if (layer.index !== 0) {
const gap = shot[index - 1] as ShotLayerGap;
length = gap.distance;
}
return (
<mesh position={[0, length / 2, 0]} renderOrder={0}>
<cylinderGeometry args={[1 / 64, 1 / 64, length]} />
<meshBasicMaterial color={0xffffff} />
</mesh>
);
})()}
</group>
</>
);
})}
</>
);
}
@@ -1,5 +1,5 @@
import { memo, useEffect, useRef } from 'react';
import { Euler, Group, Vector3 } from 'three';
import { Euler, Group, Scene, Vector3 } from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { I_HAT, J_HAT, K_HAT } from '../../../constants/axis';
import {
@@ -21,7 +21,11 @@ export enum ArmorType {
External,
}
export const SpacedArmorScene = memo(() => {
interface SpacedArmorSceneProps {
scene: Scene;
}
export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
const wrapper = useRef<Group>(null);
const modelDefinitions = useModelDefinitions();
const turretContainer = useRef<Group>(null);
@@ -162,6 +166,7 @@ export const SpacedArmorScene = memo(() => {
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Core}
thickness={thickness}
@@ -179,6 +184,7 @@ export const SpacedArmorScene = memo(() => {
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={ArmorType.External}
thickness={trackModelDefinition.thickness}
@@ -206,6 +212,7 @@ export const SpacedArmorScene = memo(() => {
return (
<group key={node.uuid} position={turretOrigin}>
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Core}
thickness={thickness}
@@ -236,6 +243,7 @@ export const SpacedArmorScene = memo(() => {
position={turretOrigin.clone().add(gunOrigin)}
>
<SpacedArmorSceneComponent
scene={scene}
type={spaced ? ArmorType.Spaced : ArmorType.Core}
thickness={thickness}
node={node}
@@ -255,6 +263,7 @@ export const SpacedArmorScene = memo(() => {
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={ArmorType.External}
thickness={gunModelDefinition.thickness}
@@ -12,6 +12,7 @@ import { hasEquipment } from '../../../../../../core/blitzkrieg/hasEquipment';
import { jsxTree } from '../../../../../../core/blitzkrieg/jsxTree';
import { ShellDefinition } from '../../../../../../core/blitzkrieg/tankDefinitions';
import { EquipmentMatrix, useDuel } from '../../../../../../stores/duel';
import { ArmorType } from '../../../SpacedArmorScene';
import fragmentShader from './shaders/fragment.glsl';
import vertexShader from './shaders/vertex.glsl';
@@ -95,7 +96,7 @@ export function SpacedArmorSubExternal({
}),
onClick() {},
userData: {
type: 'external',
type: ArmorType.External,
thickness,
variant,
} satisfies ArmorUserData,
@@ -13,6 +13,7 @@ import { hasEquipment } from '../../../../../../core/blitzkrieg/hasEquipment';
import { jsxTree } from '../../../../../../core/blitzkrieg/jsxTree';
import { ShellDefinition } from '../../../../../../core/blitzkrieg/tankDefinitions';
import { EquipmentMatrix, useDuel } from '../../../../../../stores/duel';
import { ArmorType } from '../../../SpacedArmorScene';
import fragmentShader from './shaders/fragment.glsl';
import vertexShader from './shaders/vertex.glsl';
@@ -103,7 +104,7 @@ export function SpacedArmorSubSpaced({
material,
onClick() {},
userData: {
type: 'spaced',
type: ArmorType.Spaced,
thickness,
} satisfies ArmorUserData,
})}
@@ -1,12 +1,20 @@
import { useThree } from '@react-three/fiber';
import { Intersection, MeshBasicMaterial, Object3D, Vector3 } from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { I_HAT } from '../../../../constants/axis';
import {
Intersection,
MeshBasicMaterial,
Object3D,
Quaternion,
Raycaster,
Scene,
Vector3,
} from 'three';
import { degToRad, radToDeg } from 'three/src/math/MathUtils';
import { isExplosive } from '../../../../core/blitz/isExplosive';
import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
import { hasEquipment } from '../../../../core/blitzkrieg/hasEquipment';
import { jsxTree } from '../../../../core/blitzkrieg/jsxTree';
import { useDuel } from '../../../../stores/duel';
import { Shot, useTankopediaTemporary } from '../../../../stores/tankopedia';
import { ArmorType } from '../SpacedArmorScene';
import { SpacedArmorSubExternal } from './components/SpacedArmorSubExternal';
import { SpacedArmorSubSpaced } from './components/SpacedArmorSubSpaced';
@@ -14,6 +22,7 @@ import { SpacedArmorSubSpaced } from './components/SpacedArmorSubSpaced';
type SpacedArmorSceneComponentProps = {
node: Object3D;
thickness: number;
scene: Scene;
} & (
| {
type: Exclude<ArmorType, ArmorType.External>;
@@ -29,15 +38,14 @@ export type ArmorUserData = {
thickness: number;
} & (
| {
type: 'core' | 'spaced';
type: Exclude<ArmorType, ArmorType.External>;
}
| {
type: 'external';
type: ArmorType.External;
variant: ExternalModuleVariant;
}
);
type ArmorMeshIntersection = Intersection & {
eventObject: Object3D;
object: Object3D & { userData: ArmorUserData };
};
@@ -56,6 +64,7 @@ const omitMaterial = new MeshBasicMaterial({
export function SpacedArmorSceneComponent({
node,
thickness,
scene,
...props
}: SpacedArmorSceneComponentProps) {
const camera = useThree((state) => state.camera);
@@ -68,53 +77,13 @@ export function SpacedArmorSceneComponent({
renderOrder: 0,
material: omitMaterial,
userData: {
type: 'core',
type: ArmorType.Core,
thickness,
} satisfies ArmorUserData,
async onClick(event) {
event.stopPropagation();
const intersectionsAll = event.intersections.filter(
({ object }) => object.userData.type !== undefined,
) as ArmorMeshIntersection[];
const intersectionsWithDuplicates = intersectionsAll.slice(
0,
intersectionsAll.findIndex(
({ object }) => object.userData.type === 'core',
),
);
const firstGunIntersectionIndex =
intersectionsWithDuplicates.findIndex(
({ object }) =>
object.userData.type === 'external' &&
object.userData.variant === 'gun',
);
const firstTrackIntersectionIndex =
intersectionsWithDuplicates.findIndex(
({ object }) =>
object.userData.type === 'external' &&
object.userData.variant === 'track',
);
let intersections = intersectionsWithDuplicates;
if (firstGunIntersectionIndex !== -1) {
intersections = intersections.filter(({ object }, index) => {
return object.userData.type === 'external' &&
object.userData.variant === 'gun'
? index === firstGunIntersectionIndex
: true;
});
}
if (firstTrackIntersectionIndex !== -1) {
intersections = intersections.filter(({ object }, index) => {
return object.userData.type === 'external' &&
object.userData.variant === 'track'
? index === firstTrackIntersectionIndex
: true;
});
}
const hasCalibratedShells = await hasEquipment(103, true);
const hasEnhancedArmor = await hasEquipment(110);
const thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1;
@@ -126,73 +95,189 @@ export function SpacedArmorSceneComponent({
? 1.1
: 1.05
: 1);
const shotLog: ({
thickness: number;
status: 'blocked' | 'penetration' | 'ricochet';
} & (
| {
type: ArmorType.External;
}
| {
type: Exclude<ArmorType, ArmorType.External>;
thicknessAngled: number;
}
))[] = []; // TODO: type this
const shot: Shot = []; // TODO: type this
let remainingPenetration = penetration;
let penetrationChance = -1;
let splashChance = -1;
camera.getWorldDirection(cameraNormal).multiplyScalar(-1);
const shellNormal = camera.position
.clone()
.sub(event.point)
.normalize();
shellNormal
.copy(camera.getWorldDirection(cameraNormal))
.multiplyScalar(-1);
pushLogs(
shellNormal,
filterIntersections(event.intersections),
true,
);
let index = 0;
for (const intersection of intersections) {
const thickness =
thicknessCoefficient * intersection.object.userData.thickness;
console.clear();
console.log(
JSON.stringify(
shot.map((i) => ({
...i,
...(i.angle ? { angle: radToDeg(i.angle) } : {}),
})),
null,
2,
),
);
if (intersection.object.userData.type === 'external') {
remainingPenetration -= thickness;
const blocked = remainingPenetration < 0;
shotLog.push({
type: ArmorType.External,
thickness: intersection.object.userData.thickness,
status: blocked ? 'blocked' : 'penetration',
});
if (blocked) break;
} else if (intersection.object.userData.type === 'spaced') {
const normal = intersection.normal!.applyAxisAngle(
I_HAT,
-Math.PI / 2,
function filterIntersections(intersectionsRaw: Intersection[]) {
const intersectionsAll = intersectionsRaw.filter(
({ object }) => object.userData.type !== undefined,
) as ArmorMeshIntersection[];
const intersectionsWithDuplicates = intersectionsAll.slice(
0,
intersectionsAll.findIndex(
({ object }) => object.userData.type === ArmorType.Core,
) + 1,
);
const firstGunIntersectionIndex =
intersectionsWithDuplicates.findIndex(
({ object }) =>
object.userData.type === ArmorType.External &&
object.userData.variant === 'gun',
);
const angle = normal.angleTo(cameraNormal);
const ricochet = degToRad(shell.ricochet ?? 90);
const normalization = degToRad(shell.normalization ?? 0);
const threeCalibersRule =
shell.caliber > thickness || index > 0;
const twoCalibersRule = shell.caliber > thickness * 2;
const finalNormalization = twoCalibersRule
? (1.4 * normalization * shell.caliber) / (2.0 * thickness)
: normalization;
const finalThickness =
thickness / Math.cos(angle - finalNormalization);
remainingPenetration -= finalThickness;
const firstTrackIntersectionIndex =
intersectionsWithDuplicates.findIndex(
({ object }) =>
object.userData.type === ArmorType.External &&
object.userData.variant === 'track',
);
let intersections = intersectionsWithDuplicates;
if (!threeCalibersRule && angle >= ricochet) {
shotLog.push({
type: ArmorType.Spaced,
thickness,
thicknessAngled: finalThickness,
status: 'ricochet',
});
continue;
}
} else throw new Error('Core should be omitted');
if (firstGunIntersectionIndex !== -1) {
intersections = intersections.filter(({ object }, index) => {
return object.userData.type === ArmorType.External &&
object.userData.variant === 'gun'
? index === firstGunIntersectionIndex
: true;
});
}
if (firstTrackIntersectionIndex !== -1) {
intersections = intersections.filter(({ object }, index) => {
return object.userData.type === ArmorType.External &&
object.userData.variant === 'track'
? index === firstTrackIntersectionIndex
: true;
});
}
index++;
return intersections;
}
console.log(JSON.stringify(shotLog, null, 2));
function pushLogs(
shellNormal: Vector3,
intersections: ArmorMeshIntersection[],
allowRicochets: boolean,
) {
let index = 0;
for (const intersection of intersections) {
const surfaceNormal = intersection
.normal!.clone()
.applyQuaternion(
event.object.getWorldQuaternion(new Quaternion()),
);
const thickness =
thicknessCoefficient * intersection.object.userData.thickness;
if (index !== 0) {
const previousIntersection = intersections[index - 1];
shot.push({
type: null,
distance:
intersection.distance - previousIntersection.distance,
});
}
if (intersection.object.userData.type === ArmorType.External) {
remainingPenetration -= thickness;
const blocked = remainingPenetration < 0;
shot.push({
type: ArmorType.External,
index,
shellNormal,
surfaceNormal,
point: intersection.point,
thickness: intersection.object.userData.thickness,
status: blocked ? 'blocked' : 'penetration',
});
if (blocked) break;
} else {
const type = intersection.object.userData.type;
shellNormal.copy(
cameraNormal
.copy(camera.position)
.sub(event.point)
.normalize(),
);
const angle = surfaceNormal.angleTo(shellNormal);
const ricochet = degToRad(shell.ricochet ?? 90);
const normalization = degToRad(shell.normalization ?? 0);
const threeCalibersRule =
shell.caliber > thickness * 3 ||
index > 0 ||
!allowRicochets;
const twoCalibersRule = shell.caliber > thickness * 2;
const finalNormalization = twoCalibersRule
? (1.4 * normalization * shell.caliber) / (2.0 * thickness)
: normalization;
const finalThickness =
thickness / Math.cos(angle - finalNormalization);
remainingPenetration -= finalThickness;
if (!threeCalibersRule && angle >= ricochet) {
shot.push({
type,
index,
shellNormal,
surfaceNormal,
point: intersection.point,
thickness,
thicknessAngled: finalThickness,
angle,
status: 'ricochet',
});
const raycaster = new Raycaster();
const newShellNormal = cameraNormal
.clone()
.reflect(surfaceNormal);
raycaster.set(intersection.point, newShellNormal);
pushLogs(
newShellNormal,
raycaster.intersectObjects(scene.children, true),
false,
);
break;
} else {
const blocked = remainingPenetration < 0;
shot.push({
type,
index,
shellNormal,
surfaceNormal,
point: intersection.point,
thickness,
thicknessAngled: finalThickness,
angle,
status: blocked ? 'blocked' : 'penetration',
});
}
}
index++;
}
}
useTankopediaTemporary.setState({ shot });
},
})}
+11 -4
View File
@@ -1,18 +1,21 @@
import { createPortal, useFrame } from '@react-three/fiber';
import { memo } from 'react';
import { memo, useState } from 'react';
import { DepthTexture, Scene, Vector2 } from 'three';
import { CoreArmorScene } from './components/CoreArmorScene';
import { spacedArmorRenderTarget } from './components/CoreArmorSceneComponent';
import { ShotScene } from './components/ShotScene';
import { SpacedArmorScene } from './components/SpacedArmorScene';
export const Armor = memo(() => {
const spacedArmorScene = new Scene();
const coreArmorScene = new Scene();
const [spacedArmorScene] = useState(() => new Scene());
const [coreArmorScene] = useState(() => new Scene());
const [shotScene] = useState(() => new Scene());
const spacedArmorPortal = createPortal(
<SpacedArmorScene />,
<SpacedArmorScene scene={spacedArmorScene} />,
spacedArmorScene,
);
const coreArmorPortal = createPortal(<CoreArmorScene />, coreArmorScene);
const shotPortal = createPortal(<ShotScene />, shotScene);
const renderSize = new Vector2();
const newRenderSize = new Vector2();
@@ -41,12 +44,16 @@ export const Armor = memo(() => {
gl.clearDepth();
gl.render(coreArmorScene, camera);
gl.clearDepth();
gl.render(shotScene, camera);
}, 1);
return (
<>
{spacedArmorPortal}
{coreArmorPortal}
{shotPortal}
</>
);
});
-226
View File
@@ -1,226 +0,0 @@
import { Card, Flex, Text } from '@radix-ui/themes';
import { Html } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import { ReactNode, useEffect, useRef } from 'react';
import { Group, Mesh, Vector3 } from 'three';
import { radToDeg } from 'three/src/math/MathUtils';
import { isExplosive } from '../core/blitz/isExplosive';
import { AngledPenetration } from '../icons/AngledPenetration';
import { Block } from '../icons/Block';
import { NominalPenetration } from '../icons/NominalPenetration';
import { Ricochet } from '../icons/Ricochet';
import { useDuel } from '../stores/duel';
import { Shot, useTankopediaTemporary } from '../stores/tankopedia';
export const SHOT_NAMES: Record<Shot['type'], string> = {
ricochet: 'Ricochet',
penetration: 'Penetration',
block: 'Blocked',
};
export const SHOT_ICONS: Record<Shot['type'], ReactNode> = {
ricochet: <Ricochet width={24} height={24} />,
block: <Block width={24} height={24} />,
penetration: <NominalPenetration width={24} height={24} />,
};
const THICKNESS = 0.05;
const LENGTH = 4;
export function ShotDisplay() {
const shot = useTankopediaTemporary((state) => state.shot);
const entryWrapper = useRef<Group>(null);
const entryLine = useRef<Mesh>(null);
const circle = useRef<Mesh>(null);
const ricochetCircle = useRef<Mesh>(null);
const exitWrapper = useRef<Group>(null);
const exitLine = useRef<Mesh>(null);
const secondLineLength = shot?.ricochet ? shot?.ricochet.distance : LENGTH;
const number = shot && (shot.thicknesses.length > 1 || shot.ricochet);
const shell = useDuel((state) => state.antagonist!.shell);
useFrame(({ clock }) => {
const t1 = clock.elapsedTime % 1;
const t2 = (clock.elapsedTime + 0.5) % 1;
entryLine.current?.position.set(0, 0, LENGTH - t1 * LENGTH);
entryLine.current?.scale.set(1, -2 * Math.abs(t1 - 0.5) + 1, 1);
exitLine.current?.position.set(0, 0, t2 * secondLineLength);
exitLine.current?.scale.set(1, -2 * Math.abs(t2 - 0.5) + 1, 1);
});
useEffect(() => {
if (!shot) return;
const point = new Vector3(...shot.point);
const shellNormal = new Vector3(...shot.shellNormal);
const surfaceNormal = new Vector3(...shot.surfaceNormal);
const bounceNormal = shellNormal
.clone()
.multiplyScalar(-1)
.sub(
surfaceNormal
.clone()
.multiplyScalar(
2 * surfaceNormal.dot(shellNormal.clone().multiplyScalar(-1)),
),
);
entryWrapper.current?.position.set(...shot.point);
entryWrapper.current?.lookAt(point.clone().add(shellNormal));
exitWrapper.current?.position.set(...shot.point);
exitWrapper.current?.lookAt(point.clone().add(bounceNormal));
circle.current?.position.set(...shot.point);
circle.current?.lookAt(point.clone().add(surfaceNormal));
}, [shot]);
if (!shot) return null;
return (
<>
<mesh ref={circle}>
<torusGeometry args={[0.05, 0.0125, 4, 16]} />
<meshBasicMaterial
color={shot.type === 'penetration' ? 'green' : 'red'}
depthTest={false}
/>
</mesh>
<group ref={entryWrapper}>
<Html
position={[0, 0, 0]}
style={{
paddingLeft: 16,
}}
>
<Card
style={{
width: number ? 300 : 200,
userSelect: 'none',
}}
>
<Flex direction="column" gap="1">
<Flex align="center" gap="2">
{SHOT_ICONS[shot.type]}
<Text>
{SHOT_NAMES[shot.type]} at {Math.round(radToDeg(shot.angle))}°
</Text>
</Flex>
<Flex
direction="column"
style={{
paddingLeft: number ? 8 : undefined,
}}
>
{shot.thicknesses.map((layer, index) => (
<Flex gap="2" key={index}>
{number && (
<Text>
{index + 1}. {layer.type[0].toUpperCase()}
{layer.type.slice(1)}
</Text>
)}
<Flex gap="1">
{layer.type !== 'gap' && (
<NominalPenetration width={24} height={24} />
)}
<Text>
{Math.round(
layer.type === 'gap'
? layer.gap * 1000
: layer.nominal,
)}
mm
{layer.type === 'gap' && isExplosive(shell.type)
? ` (-${Math.round(Math.min(100, 0.5 * layer.gap * 100))}% penetration)`
: ''}
</Text>
</Flex>
{layer.type !== 'external' && layer.type !== 'gap' && (
<Flex gap="1">
<AngledPenetration width={24} height={24} />
<Text>{Math.round(layer.angled)}mm</Text>
</Flex>
)}
</Flex>
))}
{shot.ricochet && (
<>
<Flex gap="1">
<Text>{shot.thicknesses.length + 1}. Ricochet</Text>
<Ricochet width={24} height={24} />
<Text>(-25% penetration)</Text>
</Flex>
{shot.ricochet.penetration ? (
<Flex gap="1">
<Text>{shot.thicknesses.length + 2}. Penetration</Text>
<NominalPenetration width={24} height={24} />
</Flex>
) : (
<Flex gap="1">
<Text>{shot.thicknesses.length + 2}. Blocked</Text>
<Block width={24} height={24} />
</Flex>
)}
</>
)}
</Flex>
</Flex>
</Card>
</Html>
<mesh
renderOrder={1}
position={[0, 0, LENGTH / 2]}
rotation={[Math.PI / 2, 0, 0]}
ref={entryLine}
>
<cylinderGeometry args={[THICKNESS / 2, THICKNESS / 2, LENGTH, 8]} />
<meshBasicMaterial color="#ffff80" />
</mesh>
<mesh position={[0, 0, LENGTH / 2]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[THICKNESS / 4, THICKNESS / 4, LENGTH, 8]} />
<meshBasicMaterial color="#ffffff" depthTest={false} />
</mesh>
</group>
{shot.type === 'ricochet' && (
<group ref={exitWrapper}>
<mesh
renderOrder={1}
position={[0, 0, secondLineLength / 2]}
rotation={[Math.PI / 2, 0, 0]}
ref={exitLine}
>
<cylinderGeometry
args={[THICKNESS / 2, THICKNESS / 2, secondLineLength, 8]}
/>
<meshBasicMaterial color="#ff8080" />
</mesh>
<mesh
position={[0, 0, secondLineLength / 2]}
rotation={[Math.PI / 2, 0, 0]}
>
<cylinderGeometry
args={[THICKNESS / 4, THICKNESS / 4, secondLineLength, 8]}
/>
<meshBasicMaterial color="#ffffff" depthTest={false} />
</mesh>
{shot.ricochet && (
<mesh ref={ricochetCircle} position={[0, 0, secondLineLength]}>
<icosahedronGeometry args={[0.05, 1]} />
<meshBasicMaterial
color={shot.ricochet.penetration ? 'green' : 'red'}
depthTest={false}
/>
</mesh>
)}
</group>
)}
</>
);
}