ricochet toy

This commit is contained in:
tresabhi committed 2026-10-11 16:49:54 -05:00
1 parent f935adb70e
commit de80519f06
7 files changed
+271 -166

No files matched your search

+10
View File
@@ -28,6 +28,16 @@
"all": "All tanks" "all": "All tanks"
}, },
"toys": {
"ricochet": {
"angle": "Angle",
"normalization": "Normalization",
"effective": "Effective",
"armor": "Armor",
"no_ricochet": "Explosive shells don't ricochet"
}
},
"titles": { "titles": {
"gun": "Gun", "gun": "Gun",
"clip": "Clip", "clip": "Clip",
@@ -296,7 +296,7 @@ export function GunFlexibilityVisualizer({
height="100%" height="100%"
style={{ style={{
opacity: 2 ** -3, opacity: 2 ** -3,
backgroundImage: `url(/assets/images/tankopedia/visualizers/ricochet/armor-hash.png)`, backgroundImage: `url(/media/random/armor-hash.png)`,
boxShadow: "inset 0 0 4rem 1rem black", boxShadow: "inset 0 0 4rem 1rem black",
}} }}
/> />
@@ -60,7 +60,7 @@ export function RicochetVisualizer({ stats }: StatsAcceptorProps) {
height="100%" height="100%"
style={{ style={{
opacity: 2 ** -3, opacity: 2 ** -3,
backgroundImage: `url(/assets/images/tankopedia/visualizers/ricochet/armor-hash.png)`, backgroundImage: `url(/media/random/armor-hash.png)`,
}} }}
/> />
</Box> </Box>
@@ -0,0 +1,151 @@
.visualizer {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
touch-action: none;
border-radius: inherit;
background-color: var(--gray-2);
}
.armor {
position: absolute;
top: 0;
left: 0;
width: 33%;
height: 100%;
background: linear-gradient(to right, transparent, var(--gray-a4));
}
.armor-texture {
width: 100%;
height: 100%;
opacity: 0.125;
background-image: url("/media/random/armor-hash.png");
}
.zones {
position: absolute;
top: 0;
left: 33%;
width: 67%;
height: 100%;
background: conic-gradient(
at 0 50%,
var(--ruby-a2),
var(--ruby-a1) calc(90deg - var(--ricochet-angle)),
var(--jade-a2) calc(90deg - var(--ricochet-angle)),
var(--jade-a4) calc(90deg - var(--normalization-angle)),
var(--cyan-a5) calc(90deg - var(--normalization-angle)),
var(--cyan-a3) calc(90deg + var(--normalization-angle)),
var(--jade-a2) calc(90deg + var(--normalization-angle)),
var(--jade-a4) calc(90deg + var(--ricochet-angle)),
var(--ruby-a1) calc(90deg + var(--ricochet-angle)),
var(--ruby-a2) 180deg
);
}
.shell-path,
.effective-path {
position: absolute;
top: 50%;
left: 33%;
transform: translateX(-50%) rotate(var(--rotation-angle));
transform-origin: top center;
}
.shell-path {
width: 0;
height: 100%;
}
.shell-path-extension {
position: absolute;
left: 50%;
bottom: 100%;
width: var(--space-0);
height: 100%;
transform: translateX(-50%);
background-color: var(--gray-a8);
}
.shell-path-line {
position: absolute;
top: 0;
left: 50%;
width: var(--space-2);
height: 100%;
transform: translateX(-50%);
background: linear-gradient(var(--gray-a11), var(--gray-a4));
}
.shell {
position: absolute;
top: 6rem;
left: 0;
width: var(--space-5);
height: var(--space-5);
object-fit: contain;
transform: translate(-50%, -50%) rotate(-45deg);
filter: drop-shadow(0 0 var(--space-4) black);
}
.effective-path {
width: 0;
height: 100%;
}
.effective-path-line {
position: absolute;
left: 50%;
top: -100%;
width: var(--space-2);
height: 100%;
transform: translateX(-50%);
background: linear-gradient(var(--gray-a4), var(--gray-a11));
}
.corner-stat {
position: absolute;
display: flex;
gap: var(--space-2);
color: var(--gray-11);
font-size: var(--font-size-minor);
}
.corner-stat-value {
color: var(--gray-12);
font-size: inherit;
}
.corner-stat[data-position="top-left"] {
top: var(--space-3);
left: var(--space-3);
}
.corner-stat[data-position="top-right"] {
top: var(--space-3);
right: var(--space-3);
}
.corner-stat[data-position="bottom-left"] {
bottom: var(--space-3);
left: var(--space-3);
}
.corner-stat[data-position="bottom-right"] {
right: var(--space-3);
bottom: var(--space-3);
}
.explosive-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-3);
color: var(--gray-11);
text-align: center;
background: radial-gradient(var(--black-a7), var(--black-a11));
}
@@ -1,174 +1,108 @@
import { isExplosive } from "@blitzkit/core"; import { alias, isExplosive } from "@blitzkit/core";
import { useRef, useState } from "react"; import { literals } from "@blitzkit/i18n";
import { degToRad } from "three/src/math/MathUtils.js"; import {
useRef,
useState,
type CSSProperties,
type PointerEvent,
} from "react";
import { clamp, degToRad, radToDeg } from "three/src/math/MathUtils.js";
import type { ToyProps } from "../../config/toys"; import type { ToyProps } from "../../config/toys";
import { DuelModule, useDuel } from "../../hooks/useDuel"; import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useStrings } from "../../hooks/useStrings"; import { useStrings } from "../../hooks/useStrings";
import { Code } from "../Code";
import styles from "./index.module.css";
export function RicochetToy({ characteristics }: ToyProps) { export function RicochetToy({ characteristics }: ToyProps) {
const strings = useStrings(); const strings = useStrings();
const shell = useDuel("protagonist", DuelModule.Shell); const shell = useDuel("protagonist", DuelModule.Shell);
const container = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
const normalizationValue = characteristics.get("normalization");
const ricochetValue = characteristics.get("ricochet");
const normalization = degToRad( const normalization = degToRad(
(characteristics.get("normalization") as number) ?? 0, typeof normalizationValue === "number" ? normalizationValue : 0,
);
const ricochet = degToRad(
typeof ricochetValue === "number" ? ricochetValue : 90,
); );
const ricochet = degToRad((characteristics.get("ricochet") as number) ?? 90);
const explodes = isExplosive(shell.type); const explodes = isExplosive(shell.type);
const [rawAngle, setAngle] = useState(degToRad(-25)); const [rawAngle, setAngle] = useState(degToRad(-25));
const angle = explodes ? degToRad(-70) : rawAngle; const angle = explodes ? degToRad(-70) : rawAngle;
const doesRicochet = Math.abs(angle) >= ricochet; const doesRicochet = Math.abs(angle) >= ricochet;
const effectiveAngle = doesRicochet const effectiveAngle = doesRicochet
? -angle + Math.PI ? -angle + Math.PI
: Math.sign(angle) * Math.max(0, Math.abs(angle) - normalization); : Math.sign(angle) * Math.max(0, Math.abs(angle) - normalization);
const armorPercentage = Math.min(999, 100 / Math.cos(effectiveAngle)); const armorPercentage = Math.min(999, 100 / Math.cos(effectiveAngle));
function updateAngle(event: PointerEvent<HTMLDivElement>) {
event.preventDefault();
if (!container.current || explodes) return;
const rect = container.current.getBoundingClientRect();
const x = event.clientX - rect.left - rect.width / 3;
const y = event.clientY - rect.top - rect.height / 2;
setAngle(clamp(Math.atan2(-y, x), -Math.PI / 2, Math.PI / 2));
}
return ( return (
<Box <div
onPointerMove={(event) => { ref={container}
event.preventDefault(); className={styles.visualizer}
onPointerMove={updateAngle}
if (!container.current || explodes) 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 <div className={styles.armor}>
width="33%" <div className={styles["armor-texture"]} />
height="100%" </div>
position="absolute"
top="0"
left="0"
style={{
background: `linear-gradient(to right, transparent, ${Var("gray-a4")})`,
}}
>
<Box
width="100%"
height="100%"
style={{
opacity: 2 ** -3,
backgroundImage: `url(/assets/images/tankopedia/visualizers/ricochet/armor-hash.png)`,
}}
/>
</Box>
<Box <div
left="33%" className={styles.zones}
top="0" style={
width="67%" {
height="100%" "--normalization-angle": `${normalization}rad`,
position="absolute" "--ricochet-angle": `${ricochet}rad`,
style={{ } as CSSProperties
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 <div
position="absolute" className={styles["shell-path"]}
left="33%" style={
top="50%" {
height="18rem" "--rotation-angle": `${-Math.PI / 2 - angle}rad`,
style={{ } as CSSProperties
transformOrigin: "top center", }
transform: `translateX(-50%) rotate(${-Math.PI / 2 - angle}rad)`,
}}
> >
<Box <div className={styles["shell-path-extension"]} />
position="absolute" <div className={styles["shell-path-line"]} />
width="1px"
height="100%"
bottom="100%"
style={{
transform: "translateX(-50%)",
backgroundColor: Var("gray-a8"),
}}
/>
<Box
position="absolute"
width="3px"
height="100%"
style={{
transform: "translateX(-50%)",
background: `linear-gradient(${Var("gray-a11")}, ${Var("gray-a4")})`,
}}
/>
<img <img
className={styles.shell}
alt=""
src={alias("api", `/icons/shells/${shell.icon}.webp`)} src={alias("api", `/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 0.25rem black)",
}}
/> />
</Box> </div>
<Box <div
position="absolute" className={styles["effective-path"]}
left="33%" style={
top="50%" {
height="16rem" "--rotation-angle": `${-Math.PI / 2 - effectiveAngle}rad`,
style={{ } as CSSProperties
transformOrigin: "top center", }
transform: `translateX(-50%) rotate(${-Math.PI / 2 - effectiveAngle}rad)`,
}}
> >
<Box <div className={styles["effective-path-line"]} />
position="absolute" </div>
width="3px"
height="100%"
top="-100%"
style={{
transform: "translateX(-50%)",
background: `linear-gradient(${Var("gray-a4")}, ${Var("gray-a11")})`,
}}
/>
</Box>
<VisualizerCornerStat <CornerStat
label={strings.website.tools.tankopedia.visualizers.ricochet.angle} label={strings.tanks.characteristics.toys.ricochet.angle}
value={literals(strings.common.units.deg, { value={literals(strings.common.units.deg, {
value: radToDeg(Math.abs(angle)).toFixed(0), value: radToDeg(Math.abs(angle)).toFixed(0),
})} })}
side="top-left" position={CornerStatPosition.TopLeft}
/> />
<VisualizerCornerStat <CornerStat
label={ label={strings.tanks.characteristics.toys.ricochet.normalization}
strings.website.tools.tankopedia.visualizers.ricochet.normalization
}
value={ value={
doesRicochet doesRicochet
? "-" ? "-"
@@ -178,12 +112,10 @@ export function RicochetToy({ characteristics }: ToyProps) {
).toFixed(0), ).toFixed(0),
}) })
} }
side="top-right" position={CornerStatPosition.TopRight}
/> />
<VisualizerCornerStat <CornerStat
label={ label={strings.tanks.characteristics.toys.ricochet.effective}
strings.website.tools.tankopedia.visualizers.ricochet.effective_angle
}
value={ value={
doesRicochet doesRicochet
? "-" ? "-"
@@ -191,10 +123,10 @@ export function RicochetToy({ characteristics }: ToyProps) {
value: radToDeg(Math.abs(effectiveAngle)).toFixed(0), value: radToDeg(Math.abs(effectiveAngle)).toFixed(0),
}) })
} }
side="bottom-left" position={CornerStatPosition.BottomLeft}
/> />
<VisualizerCornerStat <CornerStat
label={strings.website.tools.tankopedia.visualizers.ricochet.armor} label={strings.tanks.characteristics.toys.ricochet.armor}
value={ value={
doesRicochet doesRicochet
? "-" ? "-"
@@ -202,28 +134,38 @@ export function RicochetToy({ characteristics }: ToyProps) {
value: armorPercentage.toFixed(0), value: armorPercentage.toFixed(0),
}) })
} }
side="bottom-right" position={CornerStatPosition.BottomRight}
/> />
{explodes && ( {explodes && (
<Flex <div className={styles["explosive-overlay"]}>
p="3" {strings.tanks.characteristics.toys.ricochet.no_ricochet}
justify="center" </div>
align="center"
height="100%"
width="100%"
position="absolute"
top="0"
left="0"
style={{
background: `radial-gradient(${Var("black-a7")}, ${Var("black-a11")})`,
}}
>
<Text color="gray" align="center">
{strings.website.tools.tankopedia.visualizers.ricochet.no_ricochet}
</Text>
</Flex>
)} )}
</Box> </div>
);
}
enum CornerStatPosition {
TopLeft = "top-left",
TopRight = "top-right",
BottomLeft = "bottom-left",
BottomRight = "bottom-right",
}
interface CornerStatProps {
label: string;
value: string;
position: CornerStatPosition;
}
function CornerStat({ label, value, position }: CornerStatProps) {
return (
<div className={styles["corner-stat"]} data-position={position}>
<span>{label}</span>
<Code className={styles["corner-stat-value"]} size="minor">
{value}
</Code>
</div>
); );
} }
@@ -21,11 +21,13 @@ const { id, locale } = Astro.props;
<BaseLayout {locale} accent="violet"> <BaseLayout {locale} accent="violet">
<ColorScale color="amber" alpha /> <ColorScale color="amber" alpha />
<ColorScale color="red" /> <ColorScale color="red" />
<ColorScale color="ruby" alpha />
<ColorScale color="orange" /> <ColorScale color="orange" />
<ColorScale color="amber" /> <ColorScale color="amber" />
<ColorScale color="jade" /> <ColorScale color="jade" />
<ColorScale color="jade" alpha />
<ColorScale color="cyan" alpha />
<Page {id} {locale} client:only="react"> <Page {id} {locale} client:only="react">
<Page slot="fallback" {id} {locale} skeleton /> <Page slot="fallback" {id} {locale} skeleton />