mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 02:37:05 +02:00
feat: animation
This commit is contained in:
1 parent
199e156e53
commit
7af1fdcf6b
2 files changed
+185
-174
No files matched your search
@@ -1,10 +1,12 @@
|
||||
import { Card, Flex, Inset, Text } from '@radix-ui/themes';
|
||||
import { Html } from '@react-three/drei';
|
||||
import { Euler, Quaternion, Vector3 } from 'three';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { useRef } from 'react';
|
||||
import { Mesh } from 'three';
|
||||
import { radToDeg } from 'three/src/math/MathUtils';
|
||||
import { J_HAT } from '../../../constants/axis';
|
||||
import { isExplosive } from '../../../core/blitz/isExplosive';
|
||||
import { NaNFallback } from '../../../core/math/NaNFallback';
|
||||
import { normalToEuler } from '../../../core/math/normalToEuler';
|
||||
import { AngledPenetration } from '../../../icons/AngledPenetration';
|
||||
import { Block } from '../../../icons/Block';
|
||||
import { NominalPenetration } from '../../../icons/NominalPenetration';
|
||||
@@ -19,18 +21,6 @@ import {
|
||||
} from '../../../stores/tankopedia';
|
||||
import { ArmorType } from './SpacedArmorScene';
|
||||
|
||||
// export const SHOT_NAMES: Record<Shot['type'], string> = {
|
||||
// ricochet: 'Ricochet',
|
||||
// penetration: 'Penetration',
|
||||
// block: 'Blocked',
|
||||
// };
|
||||
|
||||
// export const SHOT_ICONS: Record<ShotLayerBase[''], ReactNode> = {
|
||||
// ricochet: <Ricochet width={24} height={24} />,
|
||||
// block: <Block width={24} height={24} />,
|
||||
// penetration: <NominalPenetration width={24} height={24} />,
|
||||
// };
|
||||
|
||||
const layerTypeNames: Record<ArmorType | 'null', string> = {
|
||||
[ArmorType.Core]: 'Core',
|
||||
[ArmorType.Spaced]: 'Spaced',
|
||||
@@ -38,30 +28,111 @@ const layerTypeNames: Record<ArmorType | 'null', string> = {
|
||||
null: 'Gap',
|
||||
};
|
||||
|
||||
// const THICKNESS = 0.05;
|
||||
// const LENGTH = 4;
|
||||
|
||||
const LENGTH_INFINITY = 4;
|
||||
const TRACER_THIN = 1 / 128;
|
||||
const TRACER_THICK = 1 / 64;
|
||||
|
||||
export function ShotDisplay() {
|
||||
const shot = useTankopediaTemporary((state) => state.shot);
|
||||
const preMidPointTracer = useRef<Mesh>(null);
|
||||
const postMidPointTracer = useRef<Mesh>(null);
|
||||
const hasMultipleLayers = shot?.layers.some(
|
||||
(layer, index) => index !== 0 && layer.type !== null,
|
||||
);
|
||||
const { shell } = useDuel.getState().antagonist!;
|
||||
const explosive = isExplosive(shell.type);
|
||||
const midPointLayer = (shot?.layers.findLast(
|
||||
(layer) => layer.type !== null && layer.status === 'ricochet',
|
||||
) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase;
|
||||
const firstLayer = shot?.layers[0] as Exclude<ShotLayer, ShotLayerGap>;
|
||||
const lastLayer = shot?.layers.at(-1)!;
|
||||
const distanceFromSpacedArmor = shot
|
||||
? firstLayer.point.distanceTo(shot.point)
|
||||
: undefined;
|
||||
const preMidPointLayers = shot?.layers.slice(
|
||||
0,
|
||||
shot.layers.indexOf(midPointLayer as ShotLayer),
|
||||
);
|
||||
const preMidPointGap = shot
|
||||
? preMidPointLayers!.reduce((accumulator, layer) => {
|
||||
if (layer.type === null) return accumulator + layer.distance;
|
||||
return accumulator;
|
||||
}, 0) + LENGTH_INFINITY
|
||||
: undefined;
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!shot || !preMidPointTracer.current) return;
|
||||
|
||||
const t1 = clock.elapsedTime % 1;
|
||||
const t2 = (clock.elapsedTime + 0.5) % 1;
|
||||
|
||||
preMidPointTracer.current.position.set(0, (1 - t1) * preMidPointGap!, 0);
|
||||
preMidPointTracer.current.scale.set(1, 1 - 2 * Math.abs(t1 - 0.5), 1);
|
||||
|
||||
if (!postMidPointTracer.current) return;
|
||||
|
||||
postMidPointTracer.current.position.set(0, t2 * preMidPointGap!, 0);
|
||||
postMidPointTracer.current.scale.set(1, 1 - 2 * Math.abs(t2 - 0.5), 1);
|
||||
});
|
||||
|
||||
if (!shot) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{shot?.layers.map((layer, index) => {
|
||||
<group
|
||||
position={midPointLayer.point}
|
||||
rotation={normalToEuler(midPointLayer.shellNormal)}
|
||||
>
|
||||
<mesh position={[0, preMidPointGap! / 2, 0]}>
|
||||
<cylinderGeometry args={[TRACER_THIN, TRACER_THIN, preMidPointGap]} />
|
||||
<meshBasicMaterial />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, preMidPointGap! / 2, 0]} ref={preMidPointTracer}>
|
||||
<cylinderGeometry
|
||||
args={[TRACER_THICK, TRACER_THICK, preMidPointGap]}
|
||||
/>
|
||||
<meshBasicMaterial
|
||||
color={
|
||||
lastLayer.status === 'blocked'
|
||||
? 0xff4040
|
||||
: lastLayer.status === 'penetration'
|
||||
? 0x00ff00
|
||||
: 0xffff00
|
||||
}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{midPointLayer.status === 'ricochet' && (
|
||||
<group
|
||||
position={midPointLayer.point}
|
||||
rotation={normalToEuler(
|
||||
midPointLayer.shellNormal
|
||||
.clone()
|
||||
.reflect(midPointLayer.surfaceNormal)
|
||||
.multiplyScalar(-1),
|
||||
)}
|
||||
>
|
||||
<mesh position={[0, preMidPointGap! / 2, 0]}>
|
||||
<cylinderGeometry
|
||||
args={[TRACER_THIN, TRACER_THIN, preMidPointGap]}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, preMidPointGap! / 2, 0]} ref={postMidPointTracer}>
|
||||
<cylinderGeometry
|
||||
args={[TRACER_THICK, TRACER_THICK, preMidPointGap]}
|
||||
/>
|
||||
<meshBasicMaterial color={0xffff00} />
|
||||
</mesh>
|
||||
</group>
|
||||
)}
|
||||
|
||||
{shot.layers.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),
|
||||
),
|
||||
);
|
||||
const shellRotation = normalToEuler(layer.shellNormal);
|
||||
const nextLayer = shot.layers[index + 1] as ShotLayerGap | undefined;
|
||||
const trueStatus =
|
||||
nextLayer?.status === 'blocked' ? 'blocked' : layer.status;
|
||||
@@ -107,171 +178,97 @@ export function ShotDisplay() {
|
||||
</Inset>
|
||||
</Card>
|
||||
</Html>
|
||||
|
||||
{(() => {
|
||||
let length = LENGTH_INFINITY;
|
||||
|
||||
if (layer.index !== 0) {
|
||||
const gap = shot.layers[index - 1] as ShotLayerGap;
|
||||
length = gap.distance;
|
||||
}
|
||||
|
||||
return (
|
||||
<mesh position={[0, length / 2, 0]}>
|
||||
<cylinderGeometry args={[1 / 64, 1 / 64, length]} />
|
||||
<meshBasicMaterial color={0xffffff} depthTest={false} />
|
||||
</mesh>
|
||||
);
|
||||
})()}
|
||||
</group>
|
||||
|
||||
{(() => {
|
||||
if (
|
||||
layer.status !== 'ricochet' ||
|
||||
index !== shot.layers.length - 1
|
||||
)
|
||||
return null;
|
||||
const ricochetShellNormal = layer.shellNormal
|
||||
.clone()
|
||||
.reflect(layer.surfaceNormal)
|
||||
.multiplyScalar(-1);
|
||||
const ricochetShellRotation = new Euler().setFromQuaternion(
|
||||
new Quaternion().setFromAxisAngle(
|
||||
new Vector3()
|
||||
.crossVectors(J_HAT, ricochetShellNormal)
|
||||
.normalize(),
|
||||
J_HAT.angleTo(ricochetShellNormal),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<group position={layer.point} rotation={ricochetShellRotation}>
|
||||
<mesh position={[0, LENGTH_INFINITY / 2, 0]}>
|
||||
<cylinderGeometry
|
||||
args={[1 / 64, 1 / 64, LENGTH_INFINITY]}
|
||||
/>
|
||||
<meshBasicMaterial color={0xffffff} depthTest={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
|
||||
{shot &&
|
||||
(() => {
|
||||
const firstLayer = shot.layers[0] as Exclude<ShotLayer, ShotLayerGap>;
|
||||
const lastLayer = shot.layers.at(-1)!;
|
||||
const midPointLayer = (shot.layers.findLast(
|
||||
(layer) => layer.type !== null && layer.status === 'ricochet',
|
||||
) ??
|
||||
shot.layers.findLast(
|
||||
(layer) => layer.type !== null,
|
||||
)) as ShotLayerBase;
|
||||
const distanceFromSpacedArmor = firstLayer.point.distanceTo(
|
||||
shot.point,
|
||||
);
|
||||
<Html
|
||||
position={midPointLayer.point}
|
||||
style={{
|
||||
transform: 'translateY(-50%)',
|
||||
marginLeft: 32,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Card style={{ width: 256 + 32 }}>
|
||||
<Flex direction="column">
|
||||
<Text>
|
||||
{lastLayer.status[0].toUpperCase()}
|
||||
{lastLayer.status.slice(1)}
|
||||
{lastLayer.status === 'penetration' &&
|
||||
`: ${Math.round(shell.damage.armor)}hp`}
|
||||
{lastLayer.status === 'blocked' &&
|
||||
shell.type === 'he' &&
|
||||
`: ${NaNFallback(
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
0.5 *
|
||||
shell.damage.armor *
|
||||
(1 -
|
||||
distanceFromSpacedArmor! / shell.explosionRadius!) -
|
||||
1.1 *
|
||||
(midPointLayer as ShotLayerNonExternal)
|
||||
.thicknessAngled,
|
||||
),
|
||||
),
|
||||
0,
|
||||
)}hp`}
|
||||
</Text>
|
||||
|
||||
console.log(distanceFromSpacedArmor);
|
||||
{shot.layers.map((layer) => {
|
||||
if (layer.type === null && !explosive) return null;
|
||||
|
||||
return (
|
||||
<Html
|
||||
position={midPointLayer.point}
|
||||
style={{
|
||||
transform: 'translateY(-50%)',
|
||||
marginLeft: 32,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Card style={{ width: 256 + 32 }}>
|
||||
<Flex direction="column">
|
||||
<Text>
|
||||
{lastLayer.status[0].toUpperCase()}
|
||||
{lastLayer.status.slice(1)}
|
||||
{lastLayer.status === 'penetration' &&
|
||||
`: ${Math.round(shell.damage.armor)}hp`}
|
||||
{lastLayer.status === 'blocked' &&
|
||||
shell.type === 'he' &&
|
||||
`: ${NaNFallback(
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
0.5 *
|
||||
shell.damage.armor *
|
||||
(1 -
|
||||
distanceFromSpacedArmor /
|
||||
shell.explosionRadius!) -
|
||||
1.1 *
|
||||
(midPointLayer as ShotLayerNonExternal)
|
||||
.thicknessAngled,
|
||||
),
|
||||
),
|
||||
0,
|
||||
)}hp`}
|
||||
</Text>
|
||||
return (
|
||||
<Flex align="center" gap="2">
|
||||
<Flex>
|
||||
{hasMultipleLayers && (
|
||||
<Text size="2" style={{ width: 16 }}>
|
||||
{layer.type === null ? null : `${layer.index + 1}.`}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="2">{layerTypeNames[`${layer.type}`]}</Text>
|
||||
</Flex>
|
||||
|
||||
{shot.layers.map((layer) => {
|
||||
if (layer.type === null && !explosive) return null;
|
||||
{layer.type !== null && (
|
||||
<>
|
||||
<Flex align="center" style={{ width: 64 }}>
|
||||
<NominalPenetration width={16} height={16} />
|
||||
<Text size="2">{Math.round(layer.thickness)}mm</Text>
|
||||
</Flex>
|
||||
|
||||
return (
|
||||
<Flex align="center" gap="2">
|
||||
<Flex>
|
||||
{hasMultipleLayers && (
|
||||
<Text size="2" style={{ width: 16 }}>
|
||||
{layer.type === null
|
||||
? null
|
||||
: `${layer.index + 1}.`}
|
||||
</Text>
|
||||
{layer.type !== ArmorType.External && (
|
||||
<Flex align="center">
|
||||
{layer.status === 'ricochet' ? (
|
||||
<Ricochet width={16} height={16} />
|
||||
) : layer.status === 'blocked' ? (
|
||||
<Block width={16} height={16} />
|
||||
) : (
|
||||
<AngledPenetration width={16} height={16} />
|
||||
)}
|
||||
<Text size="2">
|
||||
{layerTypeNames[`${layer.type}`]}
|
||||
{Math.round(layer.thicknessAngled)}mm (
|
||||
{Math.round(radToDeg(layer.angle))}°)
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{layer.type !== null && (
|
||||
<>
|
||||
<Flex align="center" style={{ width: 64 }}>
|
||||
<NominalPenetration width={16} height={16} />
|
||||
<Text size="2">
|
||||
{Math.round(layer.thickness)}mm
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{layer.type !== ArmorType.External && (
|
||||
<Flex align="center">
|
||||
{layer.status === 'ricochet' ? (
|
||||
<Ricochet width={16} height={16} />
|
||||
) : layer.status === 'blocked' ? (
|
||||
<Block width={16} height={16} />
|
||||
) : (
|
||||
<AngledPenetration width={16} height={16} />
|
||||
)}
|
||||
<Text size="2">
|
||||
{Math.round(layer.thicknessAngled)}mm (
|
||||
{Math.round(radToDeg(layer.angle))}°)
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{layer.type === null && (
|
||||
<Text size="2">
|
||||
{/* why is it x50? i can't pretend to understand this shit */}
|
||||
{Math.round(layer.distance * 1000)}mm (-
|
||||
{Math.round(Math.min(100, layer.distance * 50))}%
|
||||
penetration)
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
{layer.type === null && (
|
||||
<Text size="2">
|
||||
{Math.round(layer.distance * 1000)}mm (-
|
||||
{Math.round(Math.min(100, layer.distance * 50))}%
|
||||
penetration)
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Html>
|
||||
);
|
||||
})()}
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Html>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Euler, Quaternion, Vector3 } from 'three';
|
||||
import { J_HAT } from '../../constants/axis';
|
||||
|
||||
const quaternion = new Quaternion();
|
||||
const axis = new Vector3();
|
||||
|
||||
export function normalToEuler(normal: Vector3, up = J_HAT) {
|
||||
return new Euler().setFromQuaternion(
|
||||
quaternion.setFromAxisAngle(
|
||||
axis.crossVectors(up, normal).normalize(),
|
||||
up.angleTo(normal),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user