From cae63a70b094c3dad6e9fefc1929d6e2f4e6dc1e Mon Sep 17 00:00:00 2001
From: tresabhi <43380238+tresabhi@users.noreply.github.com>
Date: Mon, 12 Oct 2026 00:04:47 -0500
Subject: [PATCH] traverse toy
---
packages/i18n/strings/en.json | 26 ++--
.../media/{random => toys}/armor-hash.png | Bin
.../toys}/hull-only.png | Bin
.../hull-traverse => media/toys}/hull.png | Bin
.../hull-traverse => media/toys}/turret.png | Bin
.../src/components/Checkbox/index.module.css | 19 +++
.../website/src/components/Checkbox/index.tsx | 35 +++++
.../SegmentedControl/index.module.css | 34 +++-
.../src/components/SegmentedControl/index.tsx | 26 ++--
.../GunFlexibilityVisualizer/index.tsx | 2 +-
.../components/RicochetVisualizer/index.tsx | 2 +-
.../TankopediaCharacteristics/index.tsx | 13 +-
.../TankopediaToys/index.module.css | 75 ++++++++-
.../src/components/TankopediaToys/index.tsx | 147 +++++++++++++++++-
packages/website/src/config/toys.ts | 3 +-
15 files changed, 346 insertions(+), 36 deletions(-)
rename packages/website/public/media/{random => toys}/armor-hash.png (100%)
rename packages/website/public/{assets/images/tankopedia/visualizers/hull-traverse => media/toys}/hull-only.png (100%)
rename packages/website/public/{assets/images/tankopedia/visualizers/hull-traverse => media/toys}/hull.png (100%)
rename packages/website/public/{assets/images/tankopedia/visualizers/hull-traverse => media/toys}/turret.png (100%)
create mode 100644 packages/website/src/components/Checkbox/index.module.css
create mode 100644 packages/website/src/components/Checkbox/index.tsx
diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json
index 0a9358313..8e2f2fdfa 100644
--- a/packages/i18n/strings/en.json
+++ b/packages/i18n/strings/en.json
@@ -20,6 +20,22 @@
},
"tanks": {
+ "toys": {
+ "ricochet": {
+ "angle": "Angle",
+ "normalization": "Normalization",
+ "effective": "Effective",
+ "armor": "Armor",
+ "no_ricochet": "Explosive shells don't ricochet"
+ },
+
+ "traverse": {
+ "effective": "Effective",
+ "hull": "Hull",
+ "turret": "Turret"
+ }
+ },
+
"characteristics": {
"compare": {
"label": "Compare against",
@@ -28,16 +44,6 @@
"all": "All tanks"
},
- "toys": {
- "ricochet": {
- "angle": "Angle",
- "normalization": "Normalization",
- "effective": "Effective",
- "armor": "Armor",
- "no_ricochet": "Explosive shells don't ricochet"
- }
- },
-
"titles": {
"gun": "Gun",
"clip": "Clip",
diff --git a/packages/website/public/media/random/armor-hash.png b/packages/website/public/media/toys/armor-hash.png
similarity index 100%
rename from packages/website/public/media/random/armor-hash.png
rename to packages/website/public/media/toys/armor-hash.png
diff --git a/packages/website/public/assets/images/tankopedia/visualizers/hull-traverse/hull-only.png b/packages/website/public/media/toys/hull-only.png
similarity index 100%
rename from packages/website/public/assets/images/tankopedia/visualizers/hull-traverse/hull-only.png
rename to packages/website/public/media/toys/hull-only.png
diff --git a/packages/website/public/assets/images/tankopedia/visualizers/hull-traverse/hull.png b/packages/website/public/media/toys/hull.png
similarity index 100%
rename from packages/website/public/assets/images/tankopedia/visualizers/hull-traverse/hull.png
rename to packages/website/public/media/toys/hull.png
diff --git a/packages/website/public/assets/images/tankopedia/visualizers/hull-traverse/turret.png b/packages/website/public/media/toys/turret.png
similarity index 100%
rename from packages/website/public/assets/images/tankopedia/visualizers/hull-traverse/turret.png
rename to packages/website/public/media/toys/turret.png
diff --git a/packages/website/src/components/Checkbox/index.module.css b/packages/website/src/components/Checkbox/index.module.css
new file mode 100644
index 000000000..3b33fe81f
--- /dev/null
+++ b/packages/website/src/components/Checkbox/index.module.css
@@ -0,0 +1,19 @@
+.checkbox {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+
+ .control {
+ width: 1.25em;
+ height: 1.25em;
+
+ margin: 0;
+
+ accent-color: var(--accent-9);
+ }
+
+ &,
+ * {
+ cursor: pointer;
+ }
+}
diff --git a/packages/website/src/components/Checkbox/index.tsx b/packages/website/src/components/Checkbox/index.tsx
new file mode 100644
index 000000000..d7f1c597c
--- /dev/null
+++ b/packages/website/src/components/Checkbox/index.tsx
@@ -0,0 +1,35 @@
+import { useId, type ReactNode } from "react";
+import { Text } from "../Text";
+import styles from "./index.module.css";
+
+export interface CheckboxProps {
+ checked: boolean;
+ onCheckedChange(checked: boolean): void;
+ children?: ReactNode;
+}
+
+export function Checkbox({
+ checked,
+ onCheckedChange,
+ children,
+}: CheckboxProps) {
+ const id = useId();
+
+ return (
+
+ onCheckedChange(event.target.checked)}
+ />
+
+ {children && (
+
+ )}
+
+ );
+}
diff --git a/packages/website/src/components/SegmentedControl/index.module.css b/packages/website/src/components/SegmentedControl/index.module.css
index c1d493d42..53cafc06a 100644
--- a/packages/website/src/components/SegmentedControl/index.module.css
+++ b/packages/website/src/components/SegmentedControl/index.module.css
@@ -1,23 +1,53 @@
.options {
+ width: max-content;
+
position: relative;
display: grid;
border-radius: var(--radius-max);
- background-color: var(--gray-3);
+ &[data-size="minor"] {
+ .option {
+ padding: var(--space-2) var(--space-3);
+ font-size: 0.75em;
+ }
+ }
.indicator {
position: absolute;
top: 0;
height: 100%;
- background-color: var(--accent-3);
outline: var(--outline-focus);
border-radius: var(--radius-max);
transition: var(--duration-brief);
}
+ &[data-variant="solid"] {
+ background-color: var(--gray-3);
+
+ .indicator {
+ background-color: var(--accent-3);
+ }
+ }
+
+ &[data-variant="underline"] {
+ border-radius: 0;
+
+ .indicator {
+ top: auto;
+ bottom: 0;
+
+ background: linear-gradient(transparent, var(--accent-a2));
+
+ outline: none;
+ border-radius: 0;
+
+ border-bottom: var(--space-1) solid var(--accent-9);
+ }
+ }
+
.option {
position: relative;
diff --git a/packages/website/src/components/SegmentedControl/index.tsx b/packages/website/src/components/SegmentedControl/index.tsx
index 357a5133e..58c8bb4bd 100644
--- a/packages/website/src/components/SegmentedControl/index.tsx
+++ b/packages/website/src/components/SegmentedControl/index.tsx
@@ -1,22 +1,26 @@
import { type ReactNode } from "react";
import styles from "./index.module.css";
-export interface SegmentedControlOption {
- value: Value;
+export interface SegmentedControlOption {
+ value: string;
label: ReactNode;
}
-export interface SegmentedControlProps {
- options: readonly SegmentedControlOption[];
- value: Value;
- onValueChange(value: Value): void;
+export interface SegmentedControlProps {
+ options: readonly SegmentedControlOption[];
+ value: string;
+ onValueChange(value: string): void;
+ size?: "regular" | "minor";
+ variant?: "solid" | "underline";
}
-export function SegmentedControl({
+export function SegmentedControl({
options,
value,
onValueChange,
-}: SegmentedControlProps) {
+ size = "regular",
+ variant = "solid",
+}: SegmentedControlProps) {
const selectedIndex = options.findIndex((option) => option.value === value);
const segmentWidth = 100 / options.length;
@@ -24,7 +28,11 @@ export function SegmentedControl({
diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/RicochetVisualizer/index.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/RicochetVisualizer/index.tsx
index 81e66c872..726dc95a4 100644
--- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/RicochetVisualizer/index.tsx
+++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/RicochetVisualizer/index.tsx
@@ -60,7 +60,7 @@ export function RicochetVisualizer({ stats }: StatsAcceptorProps) {
height="100%"
style={{
opacity: 2 ** -3,
- backgroundImage: `url(/media/random/armor-hash.png)`,
+ backgroundImage: `url(/media/toys/armor-hash.png)`,
}}
/>
diff --git a/packages/website/src/components/TankopediaCharacteristics/index.tsx b/packages/website/src/components/TankopediaCharacteristics/index.tsx
index 0e8df4ea6..a5cdca439 100644
--- a/packages/website/src/components/TankopediaCharacteristics/index.tsx
+++ b/packages/website/src/components/TankopediaCharacteristics/index.tsx
@@ -198,20 +198,23 @@ function CompareOptions() {
const options = useRef([
{
- value: TankopediaRelativeAgainst.ClassAndTier,
+ value: `${TankopediaRelativeAgainst.ClassAndTier}`,
+ relativeAgainst: TankopediaRelativeAgainst.ClassAndTier,
label: literals(strings.tanks.characteristics.compare.class, {
tier: TIER_ROMAN_NUMERALS[tank.tier],
class: strings.common.tank_class_short_plural_lowercase[tank.class],
}),
},
{
- value: TankopediaRelativeAgainst.Tier,
+ value: `${TankopediaRelativeAgainst.Tier}`,
+ relativeAgainst: TankopediaRelativeAgainst.Tier,
label: literals(strings.tanks.characteristics.compare.tier, {
tier: TIER_ROMAN_NUMERALS[tank.tier],
}),
},
{
- value: TankopediaRelativeAgainst.All,
+ value: `${TankopediaRelativeAgainst.All}`,
+ relativeAgainst: TankopediaRelativeAgainst.All,
label: strings.tanks.characteristics.compare.all,
},
]);
@@ -221,10 +224,10 @@ function CompareOptions() {
{strings.tanks.characteristics.compare.label}
{
Tankopedia.mutate((draft) => {
- draft.relative_against = value;
+ draft.relative_against = Number(value);
});
}}
/>
diff --git a/packages/website/src/components/TankopediaToys/index.module.css b/packages/website/src/components/TankopediaToys/index.module.css
index 9caaedeee..50a731c12 100644
--- a/packages/website/src/components/TankopediaToys/index.module.css
+++ b/packages/website/src/components/TankopediaToys/index.module.css
@@ -21,7 +21,7 @@
width: 100%;
height: 100%;
opacity: 0.125;
- background-image: url("/media/random/armor-hash.png");
+ background-image: url("/media/toys/armor-hash.png");
}
.zones {
@@ -149,3 +149,76 @@
text-align: center;
background: radial-gradient(var(--black-a7), var(--black-a11));
}
+
+.traverse-visualizer {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ border-radius: inherit;
+ user-select: none;
+}
+
+.traverse-hull {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 12rem;
+ height: 12rem;
+ transform: translate(-50%, -50%);
+
+ &[data-turretless="true"] {
+ width: 22rem;
+ height: 22rem;
+ }
+}
+
+.traverse-hull-image {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ filter: drop-shadow(0 0 var(--space-4) var(--black-a7));
+}
+
+.traverse-turret {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 27rem;
+ height: 27rem;
+ object-fit: contain;
+ transform: translate(-50%, -50%);
+ filter: drop-shadow(0 0 var(--space-4) var(--black-a7));
+}
+
+.traverse-toggles {
+ position: absolute;
+ top: var(--space-4);
+ left: 50%;
+ display: flex;
+ justify-content: center;
+ gap: var(--space-5);
+ width: 100%;
+ padding: 0 var(--space-5);
+ transform: translateX(-50%);
+}
+
+.traverse-terrain {
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.traverse-effective {
+ position: absolute;
+ bottom: calc(var(--space-2) + 3rem);
+ left: 50%;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ color: var(--gray-11);
+ font-size: var(--font-size-minor);
+ white-space: nowrap;
+ transform: translateX(-50%);
+}
diff --git a/packages/website/src/components/TankopediaToys/index.tsx b/packages/website/src/components/TankopediaToys/index.tsx
index a08b60e58..c84660fdb 100644
--- a/packages/website/src/components/TankopediaToys/index.tsx
+++ b/packages/website/src/components/TankopediaToys/index.tsx
@@ -1,6 +1,7 @@
import { alias, isExplosive } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import {
+ useEffect,
useRef,
useState,
type CSSProperties,
@@ -10,7 +11,10 @@ import { clamp, degToRad, radToDeg } from "three/src/math/MathUtils.js";
import type { ToyProps } from "../../config/toys";
import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useStrings } from "../../hooks/useStrings";
+import type { ComputedCharacteristics } from "../../types/characteristics";
+import { Checkbox } from "../Checkbox";
import { Code } from "../Code";
+import { SegmentedControl } from "../SegmentedControl";
import styles from "./index.module.css";
export function RicochetToy({ characteristics }: ToyProps) {
@@ -78,7 +82,6 @@ export function RicochetToy({ characteristics }: ToyProps) {
@@ -95,14 +98,14 @@ export function RicochetToy({ characteristics }: ToyProps) {
- {strings.tanks.characteristics.toys.ricochet.no_ricochet}
+ {strings.tanks.toys.ricochet.no_ricochet}
)}
@@ -169,3 +172,135 @@ function CornerStat({ label, value, position }: CornerStatProps) {
);
}
+
+const terrains = ["hard", "medium", "soft"] as const;
+type Terrain = (typeof terrains)[number];
+
+interface TraverseToyProps {
+ characteristics: ComputedCharacteristics;
+}
+
+export function TraverseToy({ characteristics }: TraverseToyProps) {
+ const hull = useRef(null);
+ const turret = useRef(null);
+
+ const [terrain, setTerrain] = useState("hard");
+ const [rotateHull, setRotateHull] = useState(true);
+ const [rotateTurret, setRotateTurret] = useState(true);
+
+ const turretAngle = useRef(0);
+ const hullAngle = useRef(0);
+
+ const strings = useStrings();
+
+ const hullSpeed = characteristics.get(
+ `hull_traverse_speed_${terrain}`,
+ ) as number;
+ const turretTraverseSpeed = characteristics.get(
+ "turret_traverse_speed",
+ ) as number;
+
+ const turretSpeed = rotateTurret ? turretTraverseSpeed : 0;
+ const hullSpeedEffective = rotateHull ? hullSpeed : 0;
+
+ const isTurretless =
+ characteristics.has("azimuth_left") || characteristics.has("azimuth_right");
+
+ useEffect(() => {
+ let cancel = false;
+ const turretSpeedRad = (turretSpeed * Math.PI) / 180;
+ const hullSpeedRad = (hullSpeedEffective * Math.PI) / 180;
+ let lastT = Date.now() / 1000;
+
+ function frame() {
+ const t = Date.now() / 1000;
+ const dt = t - lastT;
+ lastT = t;
+
+ if (turret.current) {
+ turretAngle.current =
+ (turretAngle.current + turretSpeedRad * dt) % (2 * Math.PI);
+ turret.current.style.transform = `translate(-50%, -50%) rotate(${-turretAngle.current}rad)`;
+ }
+
+ if (hull.current) {
+ hullAngle.current =
+ (hullAngle.current + hullSpeedRad * dt) % (2 * Math.PI);
+ hull.current.style.transform = `translate(-50%, -50%) rotate(${-hullAngle.current}rad)`;
+ }
+
+ if (!cancel) requestAnimationFrame(frame);
+ }
+
+ frame();
+
+ return () => {
+ cancel = true;
+ };
+ }, [hullSpeedEffective, turretSpeed]);
+
+ const terrainOptions = terrains.map((terrain) => ({
+ value: terrain,
+ label:
+ strings.tanks.characteristics.items[`hull_traverse_speed_${terrain}`],
+ }));
+
+ return (
+
+
+

+
+ {!isTurretless && (
+

+ )}
+
+
+
+ {!isTurretless && (
+ <>
+
+ {strings.tanks.toys.traverse.hull}
+
+
+ {strings.tanks.toys.traverse.turret}
+
+ >
+ )}
+
+
+
+ {
+ const nextTerrain = terrains.find((terrain) => terrain === value);
+ if (nextTerrain) setTerrain(nextTerrain);
+ }}
+ variant="underline"
+ />
+
+
+
+ {strings.tanks.toys.traverse.effective}
+
+ {literals(strings.units.deg_s, {
+ value: (turretSpeed + hullSpeedEffective).toFixed(1),
+ })}
+
+
+
+ );
+}
diff --git a/packages/website/src/config/toys.ts b/packages/website/src/config/toys.ts
index 2fdb6e8c4..964addb54 100644
--- a/packages/website/src/config/toys.ts
+++ b/packages/website/src/config/toys.ts
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
-import { RicochetToy } from "../components/TankopediaToys";
+import { RicochetToy, TraverseToy } from "../components/TankopediaToys";
import type { ComputedCharacteristics } from "../types/characteristics";
export interface ToyProps {
@@ -8,4 +8,5 @@ export interface ToyProps {
export const toys: Record ReactNode> = {
ricochet: RicochetToy,
+ traverse: TraverseToy,
};