feat: new ricochet visualizer

This commit is contained in:
tresabhi committed 2025-04-22 21:10:22 -05:00
1 parent 1b94748668
commit c3d2b063bc
8 files changed
+244 -649

No files matched your search

+6 -3
View File
@@ -11,7 +11,8 @@
"mm": "%s1mm",
"deg": "%s1°",
"hp": "%s1hp",
"x": "x%s1"
"x": "x%s1",
"percentage": "%s1%"
},
"gun_types": {
@@ -871,8 +872,10 @@
},
"ricochet": {
"safe": "Safe <%s1°",
"unsafe": "Unsafe ≥%s1°"
"angle": "Angle",
"normalization": "Normalization",
"effective_angle": "Effective angle",
"armor": "Armor"
},
"view_range": {
@@ -31,7 +31,7 @@ export function HullTraverseVisualizer({ stats }: VisualizerProps) {
left: '50%',
transform: 'translate(-50%, -50%)',
animationDuration: `${
rotateHull ? 1 / (stats[`hullTraverse${terrain}Terrain`] / 360) : 0
rotateHull ? 360 / stats[`hullTraverse${terrain}Terrain`] : 0
}s`,
}}
>
@@ -59,7 +59,7 @@ export function HullTraverseVisualizer({ stats }: VisualizerProps) {
filter: 'drop-shadow(0px 0px 4px black)',
transform: 'translate(-50%, -50%)',
animationDuration: `${
rotateTurret ? 1 / (stats.turretTraverseSpeed / 360) : 0
rotateTurret ? 360 / stats.turretTraverseSpeed : 0
}s`,
}}
src="/assets/images/tankopedia/visualizers/hull-traverse/turret.png"
@@ -0,0 +1,236 @@
import { asset } from '@blitzkit/core';
import { literals } from '@blitzkit/i18n';
import { Box, Card, Code, Flex, Text } from '@radix-ui/themes';
import { useRef, useState } from 'react';
import { clamp, degToRad, radToDeg } from 'three/src/math/MathUtils.js';
import { Var } from '../../../../../../core/radix/var';
import { useLocale } from '../../../../../../hooks/useLocale';
import { Duel } from '../../../../../../stores/duel';
import type { VisualizerProps } from './HullTraverseVisualizer';
export function RicochetVisualizer({ stats }: VisualizerProps) {
const shell = Duel.use((state) => state.protagonist.shell);
const container = useRef<HTMLDivElement>(null);
const normalization = degToRad(stats.shellNormalization);
const ricochet = degToRad(stats.shellRicochet ?? 90);
const [angle, setAngle] = useState(degToRad(45));
const doesRicochet = Math.abs(angle) >= ricochet;
const effectiveAngle = doesRicochet
? -angle + Math.PI
: Math.sign(angle) * Math.max(0, Math.abs(angle) - normalization);
const { strings } = useLocale();
return (
<Card
variant="classic"
style={{
aspectRatio: '1 / 1',
}}
mb="6"
ref={container}
onPointerMove={(event) => {
if (!container.current) return;
const rect = container.current.getBoundingClientRect();
const x = event.clientX - rect.left - rect.width / 3;
const y = event.clientY - rect.top - rect.height / 2;
const angle = clamp(Math.atan2(-y, x), -Math.PI / 2, Math.PI / 2);
setAngle(angle);
}}
>
<Box
width="33%"
height="100%"
position="absolute"
top="0"
left="0"
style={{
background: `linear-gradient(to right, ${Var('gray-a1')}, ${Var('gray-a5')})`,
}}
>
<Box
width="100%"
height="100%"
style={{
opacity: 2 ** -3,
backgroundImage: `url(/assets/images/tankopedia/visualizers/ricochet/armor-hash.png)`,
}}
/>
</Box>
<Box
left="33%"
top="0"
width="67%"
height="100%"
position="absolute"
style={{
background: `conic-gradient(
at 0 50%,
${Var('ruby-a2')},
${Var('ruby-a1')} ${Math.PI / 2 - ricochet}rad,
${Var('jade-a2')} ${Math.PI / 2 - ricochet}rad,
${Var('jade-a4')} ${Math.PI / 2 - normalization}rad,
${Var('cyan-a5')} ${Math.PI / 2 - normalization}rad,
${Var('cyan-a3')} ${Math.PI / 2 + normalization}rad,
${Var('jade-a2')} ${Math.PI / 2 + normalization}rad,
${Var('jade-a4')} ${Math.PI / 2 + ricochet}rad,
${Var('ruby-a1')} ${Math.PI / 2 + ricochet}rad,
${Var('ruby-a2')} 180deg
`,
}}
/>
<Box
position="absolute"
left="33%"
top="50%"
height="18rem"
style={{
transformOrigin: 'top center',
transform: `translateX(-50%) rotate(${-Math.PI / 2 - angle}rad)`,
}}
>
<Box
position="absolute"
width="1px"
height="100%"
bottom="100%"
style={{
transform: 'translateX(-50%)',
backgroundColor: Var('gray-a5'),
}}
/>
<Box
position="absolute"
width="2px"
height="100%"
style={{
transform: 'translateX(-50%)',
background: `linear-gradient(${Var('gray-a11')}, ${Var('gray-a4')})`,
}}
/>
<img
src={asset(`icons/shells/${shell.icon}.webp`)}
style={{
objectFit: 'contain',
transform: 'translate(-50%, -50%) rotate(-45deg)',
position: 'absolute',
width: '2rem',
height: '2rem',
top: '6rem',
left: 0,
filter: 'drop-shadow(0px 0px 4px black)',
}}
/>
</Box>
<Box
position="absolute"
left="33%"
top="50%"
height="16rem"
style={{
transformOrigin: 'top center',
transform: `translateX(-50%) rotate(${-Math.PI / 2 - effectiveAngle}rad)`,
}}
>
<Box
position="absolute"
width="2px"
height="100%"
top="-100%"
style={{
transform: 'translateX(-50%)',
background: `linear-gradient(${Var('gray-a4')}, ${Var('gray-a11')})`,
}}
/>
</Box>
<Flex
style={{ userSelect: 'none' }}
gap="4"
position="absolute"
top="0"
left="0"
p="3"
width="100%"
justify="between"
>
<Flex gap="2">
<Text color="gray" size="1">
{strings.website.tools.tankopedia.visualizers.ricochet.angle}
</Text>
<Code size="1" variant="ghost">
{literals(strings.common.units.deg, [
radToDeg(Math.abs(angle)).toFixed(0),
])}
</Code>
</Flex>
<Flex gap="2">
<Text color="gray" size="1">
{
strings.website.tools.tankopedia.visualizers.ricochet
.normalization
}
</Text>
<Code size="1" variant="ghost">
{doesRicochet
? '-'
: literals(strings.common.units.deg, [
radToDeg(Math.min(normalization, Math.abs(angle))).toFixed(0),
])}
</Code>
</Flex>
</Flex>
<Flex
style={{ userSelect: 'none' }}
gap="4"
position="absolute"
bottom="0"
left="0"
p="3"
width="100%"
justify="between"
>
<Flex gap="2">
<Text color="gray" size="1">
{
strings.website.tools.tankopedia.visualizers.ricochet
.effective_angle
}
</Text>
<Code size="1" variant="ghost">
{doesRicochet
? '-'
: literals(strings.common.units.deg, [
radToDeg(Math.abs(effectiveAngle)).toFixed(0),
])}
</Code>
</Flex>
<Flex gap="2">
<Text color="gray" size="1">
{strings.website.tools.tankopedia.visualizers.ricochet.armor}
</Text>
<Code size="1" variant="ghost">
{doesRicochet
? '-'
: literals(strings.common.units.percentage, [
(100 / Math.cos(effectiveAngle)).toFixed(2),
])}
</Code>
</Flex>
</Flex>
</Card>
);
}
@@ -1,134 +0,0 @@
@keyframes penetrating-shell-slide {
0% {
margin-top: -9rem;
}
28%,
100% {
margin-top: 8rem;
}
}
.ricochet-visualizer-penetrating-shell {
animation: penetrating-shell-slide infinite;
}
@keyframes ricocheting-shell-slide {
0% {
margin-top: -18rem;
}
38% {
margin-top: -18rem;
}
63%,
69% {
margin-top: 0rem;
}
100% {
margin-top: 8rem;
}
}
@keyframes ricocheting-shell-slide-container {
0% {
transform: translateX(-50%) rotate(80deg);
}
63% {
transform: translateX(-50%) rotate(80deg);
}
69% {
transform: translateX(-50%) rotate(100deg);
}
100% {
transform: translateX(-50%) rotate(100deg);
}
}
.ricochet-visualizer-ricocheting-shell {
animation: ricocheting-shell-slide infinite;
}
.ricochet-visualizer-ricocheting-shell-container {
animation: ricocheting-shell-slide-container infinite;
}
@keyframes surface-impact {
0%,
60% {
opacity: 1;
width: 0;
height: 0;
}
66% {
opacity: 1;
width: 1.5rem;
height: 1.5rem;
}
72%,
100% {
opacity: 0;
width: 3rem;
height: 3rem;
}
}
.ricochet-visualizer-surface-impact {
animation: surface-impact linear infinite;
}
@keyframes puncture {
0%,
10% {
opacity: 1;
margin-top: -6rem;
}
15%,
30% {
opacity: 1;
margin-top: -1rem;
}
45%,
100% {
opacity: 0;
margin-top: -1rem;
}
}
.ricochet-visualizer-puncture {
animation: puncture infinite;
}
@keyframes safe {
0% {
opacity: 0;
}
5%,
28% {
opacity: 1;
}
33%,
100% {
opacity: 0;
}
}
.ricochet-visualizer-safe {
animation: safe infinite;
}
@keyframes unsafe {
0%,
33% {
opacity: 0;
}
38%,
95% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.ricochet-visualizer-unsafe {
animation: unsafe infinite;
}
@@ -1,229 +0,0 @@
import { asset } from '@blitzkit/core';
import { literals } from '@blitzkit/i18n';
import { Box, Card, Text } from '@radix-ui/themes';
import { Var } from '../../../../../../../core/radix/var';
import { useLocale } from '../../../../../../../hooks/useLocale';
import { Duel } from '../../../../../../../stores/duel';
import type { VisualizerProps } from '../HullTraverseVisualizer';
import './index.css';
export function RicochetVisualizer({ stats }: VisualizerProps) {
const ricochet = stats.shellRicochet ?? 90;
const shell = Duel.use((state) => state.protagonist.shell);
const { strings } = useLocale();
const animationDuration = '6s';
return (
<Card
variant="classic"
style={{
aspectRatio: '2 / 1',
}}
mb="6"
>
<Box
width="100%"
height="calc(100% / 3)"
position="absolute"
bottom="0"
left="0"
style={{
background: `linear-gradient(${Var('gray-a4')}, ${Var('gray-a1')})`,
}}
>
<Box
width="100%"
height="100%"
style={{
opacity: 2 ** -3,
backgroundImage: `url(/assets/images/tankopedia/visualizers/ricochet/armor-hash.png)`,
}}
/>
<Box
position="absolute"
left="25%"
top="0"
style={{
transformOrigin: 'top center',
transform: `translateX(-50%) rotate(${ricochet / 2}deg)`,
}}
>
<Box
className="ricochet-visualizer-puncture"
width="1rem"
height="6rem"
style={{
background: `linear-gradient(${Var('gray-1')}, ${Var('gray-a1')})`,
animationDuration,
}}
/>
</Box>
</Box>
<Box
width="100%"
height="calc(100% * 2 / 3)"
position="absolute"
top="0"
left="0"
style={{
backgroundColor: Var('gray-3'),
}}
>
<Box
width="100%"
height="100%"
style={{
background: `
conic-gradient(
from -90deg at 25% 100%,
transparent ${90 - ricochet}deg,
${Var('jade-a7')} ${90 - ricochet}deg,
${Var('jade-9')} ${90 + ricochet}deg,
${Var('tomato-8')} ${90 + ricochet}deg,
${Var('tomato-a3')} 180deg,
transparent 180deg
)
`,
}}
/>
{/* <Box
width="1px"
height="100%"
position="absolute"
left="25%"
top="0"
style={{
transform: 'translateX(-50%)',
backgroundColor: Var('gray-11'),
}}
/> */}
<Box
width="3px"
height="20rem"
position="absolute"
left="25%"
bottom="0"
style={{
transformOrigin: 'bottom center',
transform: `translateX(-50%) rotate(${ricochet}deg)`,
background: `repeating-linear-gradient(${Var('gray-a4')}, ${Var('gray-12')} 15px, transparent 15px, transparent 30px)`,
}}
/>
<Box
width="3px"
height="20rem"
position="absolute"
left="25%"
bottom="0"
style={{
transformOrigin: 'bottom center',
transform: `translateX(-50%) rotate(-${ricochet}deg)`,
background: `repeating-linear-gradient(${Var('gray-11')}, ${Var('gray-a3')} 15px, transparent 15px, transparent 30px)`,
}}
/>
<Box
className="ricochet-visualizer-surface-impact"
position="absolute"
bottom="0"
left="25%"
style={{
transform: `translate(-50%, 50%) rotate(${ricochet}deg)`,
background: `radial-gradient(${Var('gray-a2')}, ${Var('gray-a9')})`,
borderRadius: '50%',
animationDuration,
}}
/>
<Box
width="2rem"
position="absolute"
height="2rem"
left="25%"
bottom="0"
style={{
transformOrigin: 'bottom center',
transform: `translateX(-50%) rotate(${ricochet / 2}deg)`,
}}
>
<img
className="ricochet-visualizer-penetrating-shell"
src={asset(`icons/shells/${shell.icon}.webp`)}
style={{
objectFit: 'contain',
transform: 'rotate(135deg)',
width: '100%',
height: '100%',
position: 'absolute',
animationDuration,
}}
/>
</Box>
<Box
className="ricochet-visualizer-ricocheting-shell-container"
width="2rem"
position="absolute"
height="2rem"
left="25%"
bottom="0"
style={{
transformOrigin: 'bottom center',
animationDuration,
}}
>
<img
className="ricochet-visualizer-ricocheting-shell"
src={asset(`icons/shells/${shell.icon}.webp`)}
style={{
objectFit: 'contain',
transform: 'translateY(50%) rotate(135deg)',
width: '100%',
height: '100%',
position: 'absolute',
animationDuration,
}}
/>
</Box>
<Text
color="gray"
style={{
position: 'absolute',
left: '30%',
bottom: '40%',
transform: 'translateX(-50%)',
animationDuration,
}}
className="ricochet-visualizer-safe"
>
{literals(
strings.website.tools.tankopedia.visualizers.ricochet.safe,
[ricochet.toFixed(0)],
)}
</Text>
<Text
color="gray"
style={{
position: 'absolute',
right: '5%',
bottom: '12%',
animationDuration,
}}
className="ricochet-visualizer-unsafe"
>
{literals(
strings.website.tools.tankopedia.visualizers.ricochet.unsafe,
[ricochet.toFixed(0)],
)}
</Text>
</Box>
</Card>
);
}
@@ -1,134 +0,0 @@
@keyframes penetrating-shell-slide {
0% {
margin-top: -9rem;
}
28%,
100% {
margin-top: 8rem;
}
}
.ricochet-visualizer-penetrating-shell {
animation: penetrating-shell-slide infinite;
}
@keyframes ricocheting-shell-slide {
0% {
margin-top: -18rem;
}
38% {
margin-top: -18rem;
}
63%,
69% {
margin-top: 0rem;
}
100% {
margin-top: 8rem;
}
}
@keyframes ricocheting-shell-slide-container {
0% {
transform: translateX(-50%) rotate(80deg);
}
63% {
transform: translateX(-50%) rotate(80deg);
}
69% {
transform: translateX(-50%) rotate(100deg);
}
100% {
transform: translateX(-50%) rotate(100deg);
}
}
.ricochet-visualizer-ricocheting-shell {
animation: ricocheting-shell-slide infinite;
}
.ricochet-visualizer-ricocheting-shell-container {
animation: ricocheting-shell-slide-container infinite;
}
@keyframes surface-impact {
0%,
60% {
opacity: 1;
width: 0;
height: 0;
}
66% {
opacity: 1;
width: 1.5rem;
height: 1.5rem;
}
72%,
100% {
opacity: 0;
width: 3rem;
height: 3rem;
}
}
.ricochet-visualizer-surface-impact {
animation: surface-impact linear infinite;
}
@keyframes puncture {
0%,
10% {
opacity: 1;
margin-top: -6rem;
}
15%,
30% {
opacity: 1;
margin-top: -1rem;
}
45%,
100% {
opacity: 0;
margin-top: -1rem;
}
}
.ricochet-visualizer-puncture {
animation: puncture infinite;
}
@keyframes safe {
0% {
opacity: 0;
}
5%,
28% {
opacity: 1;
}
33%,
100% {
opacity: 0;
}
}
.ricochet-visualizer-safe {
animation: safe infinite;
}
@keyframes unsafe {
0%,
33% {
opacity: 0;
}
38%,
95% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.ricochet-visualizer-unsafe {
animation: unsafe infinite;
}
@@ -1,145 +0,0 @@
import { asset } from '@blitzkit/core';
import { Box, Card, Text } from '@radix-ui/themes';
import { clamp } from 'lodash-es';
import { useRef } from 'react';
import { degToRad } from 'three/src/math/MathUtils.js';
import { Var } from '../../../../../../../core/radix/var';
import { Duel } from '../../../../../../../stores/duel';
import type { VisualizerProps } from '../HullTraverseVisualizer';
import './index.css';
const MAX_THICKNESS = 300;
const EXAGGERATION = 4;
export function RicochetVisualizer2({ stats }: VisualizerProps) {
const thickness = 150;
const shell = Duel.use((state) => state.protagonist.shell);
const container = useRef<HTMLDivElement>(null);
const incomingTracer = useRef<HTMLDivElement>(null);
const normalizedTracer = useRef<HTMLDivElement>(null);
const normalization = EXAGGERATION * degToRad(stats.shellNormalization);
return (
<Card
variant="classic"
style={{
aspectRatio: '1 / 1',
}}
mb="6"
ref={container}
onPointerMove={(event) => {
if (
!container.current ||
!incomingTracer.current ||
!normalizedTracer.current
)
return;
const rect = container.current.getBoundingClientRect();
const x = event.clientX - rect.left - rect.width / 2;
const y = event.clientY - rect.top - rect.height / 2;
const angle = clamp(Math.atan2(y, x), -Math.PI / 2, Math.PI / 2);
const normalizedAngle =
Math.sign(angle) * Math.max(0, Math.abs(angle) - normalization);
incomingTracer.current.style.transform = `rotate(${-Math.PI / 2 + angle}rad)`;
normalizedTracer.current.style.transform = `rotate(${-Math.PI / 2 + normalizedAngle}rad)`;
}}
>
<Box
width={`${(thickness / MAX_THICKNESS) * 50}%`}
height="100%"
position="absolute"
top="0"
right="50%"
style={{
background: `linear-gradient(to right, ${Var('gray-a2')}, ${Var('gray-a4')})`,
}}
>
<Box
width="100%"
height="100%"
style={{
opacity: 2 ** -3,
backgroundImage: `url(/assets/images/tankopedia/visualizers/ricochet/armor-hash.png)`,
}}
/>
</Box>
<Box
ref={incomingTracer}
position="absolute"
left="50%"
top="50%"
height="13rem"
style={{
transformOrigin: 'top center',
transform: 'translateX(-50%) rotate(-90deg)',
}}
>
<Box
position="absolute"
width="1px"
height="100%"
bottom="100%"
style={{
transform: 'translateX(-50%)',
backgroundColor: Var('gray-a5'),
}}
/>
<Box
position="absolute"
width="2px"
height="100%"
style={{
transform: 'translateX(-50%)',
background: `linear-gradient(${Var('gray-a11')}, ${Var('gray-a4')})`,
}}
/>
<img
className="ricochet-visualizer-penetrating-shell"
src={asset(`icons/shells/${shell.icon}.webp`)}
style={{
objectFit: 'contain',
transform: 'translate(-50%, -50%) rotate(-45deg)',
position: 'absolute',
width: '2rem',
height: '2rem',
top: '6rem',
left: 0,
}}
/>
</Box>
<Box
ref={normalizedTracer}
position="absolute"
left="50%"
top="50%"
height="16rem"
style={{
transformOrigin: 'top center',
transform: 'translateX(-50%) rotate(-90deg)',
}}
>
<Box
position="absolute"
width="2px"
height="100%"
top="-100%"
style={{
transform: 'translateX(-50%)',
background: `linear-gradient(${Var('gray-a4')}, ${Var('gray-a11')})`,
}}
/>
</Box>
<Box position="absolute" right="3" bottom="2">
<Text style={{ opacity: 0.5 }} size="1">
<i>*Not to scale</i>
</Text>
</Box>
</Card>
);
}
@@ -35,7 +35,6 @@ import { HullTraverseVisualizer } from './components/HullTraverseVisualizer';
import { Info } from './components/Info';
import { InfoWithDelta } from './components/InfoWithDelta';
import { RicochetVisualizer } from './components/RicochetVisualizer';
import { RicochetVisualizer2 } from './components/RicochetVisualizer2';
import { ViewRangeVisualizer } from './components/ViewRangeVisualizer';
const [equipmentDefinitions, provisionDefinitions] = await Promise.all([
@@ -390,7 +389,6 @@ export function Characteristics() {
value="shellRicochet"
/>
<RicochetVisualizer stats={stats} />
<RicochetVisualizer2 stats={stats} />
</>
)}
<InfoWithDelta