mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-24 20:19:49 +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 { Card, Flex, Inset, Text } from '@radix-ui/themes';
|
||||||
import { Html } from '@react-three/drei';
|
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 { radToDeg } from 'three/src/math/MathUtils';
|
||||||
import { J_HAT } from '../../../constants/axis';
|
|
||||||
import { isExplosive } from '../../../core/blitz/isExplosive';
|
import { isExplosive } from '../../../core/blitz/isExplosive';
|
||||||
import { NaNFallback } from '../../../core/math/NaNFallback';
|
import { NaNFallback } from '../../../core/math/NaNFallback';
|
||||||
|
import { normalToEuler } from '../../../core/math/normalToEuler';
|
||||||
import { AngledPenetration } from '../../../icons/AngledPenetration';
|
import { AngledPenetration } from '../../../icons/AngledPenetration';
|
||||||
import { Block } from '../../../icons/Block';
|
import { Block } from '../../../icons/Block';
|
||||||
import { NominalPenetration } from '../../../icons/NominalPenetration';
|
import { NominalPenetration } from '../../../icons/NominalPenetration';
|
||||||
@@ -19,18 +21,6 @@ import {
|
|||||||
} from '../../../stores/tankopedia';
|
} from '../../../stores/tankopedia';
|
||||||
import { ArmorType } from './SpacedArmorScene';
|
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> = {
|
const layerTypeNames: Record<ArmorType | 'null', string> = {
|
||||||
[ArmorType.Core]: 'Core',
|
[ArmorType.Core]: 'Core',
|
||||||
[ArmorType.Spaced]: 'Spaced',
|
[ArmorType.Spaced]: 'Spaced',
|
||||||
@@ -38,30 +28,111 @@ const layerTypeNames: Record<ArmorType | 'null', string> = {
|
|||||||
null: 'Gap',
|
null: 'Gap',
|
||||||
};
|
};
|
||||||
|
|
||||||
// const THICKNESS = 0.05;
|
|
||||||
// const LENGTH = 4;
|
|
||||||
|
|
||||||
const LENGTH_INFINITY = 4;
|
const LENGTH_INFINITY = 4;
|
||||||
|
const TRACER_THIN = 1 / 128;
|
||||||
|
const TRACER_THICK = 1 / 64;
|
||||||
|
|
||||||
export function ShotDisplay() {
|
export function ShotDisplay() {
|
||||||
const shot = useTankopediaTemporary((state) => state.shot);
|
const shot = useTankopediaTemporary((state) => state.shot);
|
||||||
|
const preMidPointTracer = useRef<Mesh>(null);
|
||||||
|
const postMidPointTracer = useRef<Mesh>(null);
|
||||||
const hasMultipleLayers = shot?.layers.some(
|
const hasMultipleLayers = shot?.layers.some(
|
||||||
(layer, index) => index !== 0 && layer.type !== null,
|
(layer, index) => index !== 0 && layer.type !== null,
|
||||||
);
|
);
|
||||||
const { shell } = useDuel.getState().antagonist!;
|
const { shell } = useDuel.getState().antagonist!;
|
||||||
const explosive = isExplosive(shell.type);
|
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 (
|
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;
|
if (layer.type === null) return;
|
||||||
|
|
||||||
const shellRotation = new Euler().setFromQuaternion(
|
const shellRotation = normalToEuler(layer.shellNormal);
|
||||||
new Quaternion().setFromAxisAngle(
|
|
||||||
new Vector3().crossVectors(J_HAT, layer.shellNormal).normalize(),
|
|
||||||
J_HAT.angleTo(layer.shellNormal),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const nextLayer = shot.layers[index + 1] as ShotLayerGap | undefined;
|
const nextLayer = shot.layers[index + 1] as ShotLayerGap | undefined;
|
||||||
const trueStatus =
|
const trueStatus =
|
||||||
nextLayer?.status === 'blocked' ? 'blocked' : layer.status;
|
nextLayer?.status === 'blocked' ? 'blocked' : layer.status;
|
||||||
@@ -107,171 +178,97 @@ export function ShotDisplay() {
|
|||||||
</Inset>
|
</Inset>
|
||||||
</Card>
|
</Card>
|
||||||
</Html>
|
</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>
|
</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 &&
|
<Html
|
||||||
(() => {
|
position={midPointLayer.point}
|
||||||
const firstLayer = shot.layers[0] as Exclude<ShotLayer, ShotLayerGap>;
|
style={{
|
||||||
const lastLayer = shot.layers.at(-1)!;
|
transform: 'translateY(-50%)',
|
||||||
const midPointLayer = (shot.layers.findLast(
|
marginLeft: 32,
|
||||||
(layer) => layer.type !== null && layer.status === 'ricochet',
|
pointerEvents: 'none',
|
||||||
) ??
|
}}
|
||||||
shot.layers.findLast(
|
>
|
||||||
(layer) => layer.type !== null,
|
<Card style={{ width: 256 + 32 }}>
|
||||||
)) as ShotLayerBase;
|
<Flex direction="column">
|
||||||
const distanceFromSpacedArmor = firstLayer.point.distanceTo(
|
<Text>
|
||||||
shot.point,
|
{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 (
|
return (
|
||||||
<Html
|
<Flex align="center" gap="2">
|
||||||
position={midPointLayer.point}
|
<Flex>
|
||||||
style={{
|
{hasMultipleLayers && (
|
||||||
transform: 'translateY(-50%)',
|
<Text size="2" style={{ width: 16 }}>
|
||||||
marginLeft: 32,
|
{layer.type === null ? null : `${layer.index + 1}.`}
|
||||||
pointerEvents: 'none',
|
</Text>
|
||||||
}}
|
)}
|
||||||
>
|
<Text size="2">{layerTypeNames[`${layer.type}`]}</Text>
|
||||||
<Card style={{ width: 256 + 32 }}>
|
</Flex>
|
||||||
<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>
|
|
||||||
|
|
||||||
{shot.layers.map((layer) => {
|
{layer.type !== null && (
|
||||||
if (layer.type === null && !explosive) return null;
|
<>
|
||||||
|
<Flex align="center" style={{ width: 64 }}>
|
||||||
|
<NominalPenetration width={16} height={16} />
|
||||||
|
<Text size="2">{Math.round(layer.thickness)}mm</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
return (
|
{layer.type !== ArmorType.External && (
|
||||||
<Flex align="center" gap="2">
|
<Flex align="center">
|
||||||
<Flex>
|
{layer.status === 'ricochet' ? (
|
||||||
{hasMultipleLayers && (
|
<Ricochet width={16} height={16} />
|
||||||
<Text size="2" style={{ width: 16 }}>
|
) : layer.status === 'blocked' ? (
|
||||||
{layer.type === null
|
<Block width={16} height={16} />
|
||||||
? null
|
) : (
|
||||||
: `${layer.index + 1}.`}
|
<AngledPenetration width={16} height={16} />
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
<Text size="2">
|
<Text size="2">
|
||||||
{layerTypeNames[`${layer.type}`]}
|
{Math.round(layer.thicknessAngled)}mm (
|
||||||
|
{Math.round(radToDeg(layer.angle))}°)
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{layer.type !== null && (
|
{layer.type === null && (
|
||||||
<>
|
<Text size="2">
|
||||||
<Flex align="center" style={{ width: 64 }}>
|
{Math.round(layer.distance * 1000)}mm (-
|
||||||
<NominalPenetration width={16} height={16} />
|
{Math.round(Math.min(100, layer.distance * 50))}%
|
||||||
<Text size="2">
|
penetration)
|
||||||
{Math.round(layer.thickness)}mm
|
</Text>
|
||||||
</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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Flex>
|
</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