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, };