Compare commits

...
4 Commits
Author SHA1 Message Date
tresabhi 1c4ef7c129 add container type to get container widht 2026-10-12 00:28:03 -05:00
tresabhi a649995cdc nest styles 2026-10-12 00:08:11 -05:00
tresabhi df53f687f7 bar trans 2026-10-12 00:06:02 -05:00
tresabhi cae63a70b0 traverse toy 2026-10-12 00:04:47 -05:00
16 changed files with 387 additions and 74 deletions

No files matched your search

+14 -8
View File
@@ -20,14 +20,6 @@
}, },
"tanks": { "tanks": {
"characteristics": {
"compare": {
"label": "Compare against",
"class": "Tier {tier} {class}",
"tier": "All tier {tier}",
"all": "All tanks"
},
"toys": { "toys": {
"ricochet": { "ricochet": {
"angle": "Angle", "angle": "Angle",
@@ -35,9 +27,23 @@
"effective": "Effective", "effective": "Effective",
"armor": "Armor", "armor": "Armor",
"no_ricochet": "Explosive shells don't ricochet" "no_ricochet": "Explosive shells don't ricochet"
},
"traverse": {
"effective": "Effective",
"hull": "Hull",
"turret": "Turret"
} }
}, },
"characteristics": {
"compare": {
"label": "Compare against",
"class": "Tier {tier} {class}",
"tier": "All tier {tier}",
"all": "All tanks"
},
"titles": { "titles": {
"gun": "Gun", "gun": "Gun",
"clip": "Clip", "clip": "Clip",
@@ -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;
}
}
@@ -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 (
<div className={styles.checkbox}>
<input
className={styles.control}
type="checkbox"
id={id}
checked={checked}
onChange={(event) => onCheckedChange(event.target.checked)}
/>
{children && (
<label htmlFor={id}>
<Text lowContrast>{children}</Text>
</label>
)}
</div>
);
}
@@ -1,23 +1,53 @@
.options { .options {
width: max-content;
position: relative; position: relative;
display: grid; display: grid;
border-radius: var(--radius-max); 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 { .indicator {
position: absolute; position: absolute;
top: 0; top: 0;
height: 100%; height: 100%;
background-color: var(--accent-3);
outline: var(--outline-focus); outline: var(--outline-focus);
border-radius: var(--radius-max); border-radius: var(--radius-max);
transition: var(--duration-brief); 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 { .option {
position: relative; position: relative;
@@ -1,22 +1,26 @@
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import styles from "./index.module.css"; import styles from "./index.module.css";
export interface SegmentedControlOption<Value extends string | number> { export interface SegmentedControlOption {
value: Value; value: string;
label: ReactNode; label: ReactNode;
} }
export interface SegmentedControlProps<Value extends string | number> { export interface SegmentedControlProps {
options: readonly SegmentedControlOption<Value>[]; options: readonly SegmentedControlOption[];
value: Value; value: string;
onValueChange(value: Value): void; onValueChange(value: string): void;
size?: "regular" | "minor";
variant?: "solid" | "underline";
} }
export function SegmentedControl<Value extends string | number>({ export function SegmentedControl({
options, options,
value, value,
onValueChange, onValueChange,
}: SegmentedControlProps<Value>) { size = "regular",
variant = "solid",
}: SegmentedControlProps) {
const selectedIndex = options.findIndex((option) => option.value === value); const selectedIndex = options.findIndex((option) => option.value === value);
const segmentWidth = 100 / options.length; const segmentWidth = 100 / options.length;
@@ -24,7 +28,11 @@ export function SegmentedControl<Value extends string | number>({
<div <div
className={styles.options} className={styles.options}
role="radiogroup" role="radiogroup"
style={{ gridTemplateColumns: `repeat(${options.length}, 1fr)` }} data-size={size}
data-variant={variant}
style={{
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
}}
> >
<div <div
className={styles.indicator} className={styles.indicator}
@@ -296,7 +296,7 @@ export function GunFlexibilityVisualizer({
height="100%" height="100%"
style={{ style={{
opacity: 2 ** -3, opacity: 2 ** -3,
backgroundImage: `url(/media/random/armor-hash.png)`, backgroundImage: `url(/media/toys/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(/media/random/armor-hash.png)`, backgroundImage: `url(/media/toys/armor-hash.png)`,
}} }}
/> />
</Box> </Box>
@@ -2,6 +2,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
container-type: inline-size;
gap: var(--space-4); gap: var(--space-4);
margin: var(--space-4); margin: var(--space-4);
@@ -115,6 +117,7 @@
.bar { .bar {
height: 100%; height: 100%;
transition: var(--duration-brief);
} }
} }
} }
@@ -198,20 +198,23 @@ function CompareOptions() {
const options = useRef([ const options = useRef([
{ {
value: TankopediaRelativeAgainst.ClassAndTier, value: `${TankopediaRelativeAgainst.ClassAndTier}`,
relativeAgainst: TankopediaRelativeAgainst.ClassAndTier,
label: literals(strings.tanks.characteristics.compare.class, { label: literals(strings.tanks.characteristics.compare.class, {
tier: TIER_ROMAN_NUMERALS[tank.tier], tier: TIER_ROMAN_NUMERALS[tank.tier],
class: strings.common.tank_class_short_plural_lowercase[tank.class], 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, { label: literals(strings.tanks.characteristics.compare.tier, {
tier: TIER_ROMAN_NUMERALS[tank.tier], tier: TIER_ROMAN_NUMERALS[tank.tier],
}), }),
}, },
{ {
value: TankopediaRelativeAgainst.All, value: `${TankopediaRelativeAgainst.All}`,
relativeAgainst: TankopediaRelativeAgainst.All,
label: strings.tanks.characteristics.compare.all, label: strings.tanks.characteristics.compare.all,
}, },
]); ]);
@@ -221,10 +224,10 @@ function CompareOptions() {
<Text lowContrast>{strings.tanks.characteristics.compare.label}</Text> <Text lowContrast>{strings.tanks.characteristics.compare.label}</Text>
<SegmentedControl <SegmentedControl
options={options.current} options={options.current}
value={relativeAgainst} value={`${relativeAgainst}`}
onValueChange={(value) => { onValueChange={(value) => {
Tankopedia.mutate((draft) => { Tankopedia.mutate((draft) => {
draft.relative_against = value; draft.relative_against = Number(value);
}); });
}} }}
/> />
@@ -6,25 +6,24 @@
touch-action: none; touch-action: none;
border-radius: inherit; border-radius: inherit;
background-color: var(--gray-2); background-color: var(--gray-2);
}
.armor { .armor {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 33%; width: 33%;
height: 100%; height: 100%;
background: linear-gradient(to right, transparent, var(--gray-a4)); background: linear-gradient(to right, transparent, var(--gray-a4));
}
.armor-texture { .texture {
width: 100%; width: 100%;
height: 100%; height: 100%;
opacity: 0.125; opacity: 0.125;
background-image: url("/media/random/armor-hash.png"); background-image: url("/media/toys/armor-hash.png");
} }
}
.zones { .zones {
position: absolute; position: absolute;
top: 0; top: 0;
left: 33%; left: 33%;
@@ -43,23 +42,22 @@
var(--ruby-a1) calc(90deg + var(--ricochet-angle)), var(--ruby-a1) calc(90deg + var(--ricochet-angle)),
var(--ruby-a2) 180deg var(--ruby-a2) 180deg
); );
} }
.shell-path, .shell-path,
.effective-path { .effective-path {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 33%; left: 33%;
transform: translateX(-50%) rotate(var(--rotation-angle)); transform: translateX(-50%) rotate(var(--rotation-angle));
transform-origin: top center; transform-origin: top center;
} }
.shell-path { .shell-path {
width: 0; width: 0;
height: 100%; height: 100%;
}
.shell-path-extension { .extension {
position: absolute; position: absolute;
left: 50%; left: 50%;
bottom: 100%; bottom: 100%;
@@ -67,9 +65,9 @@
height: 100%; height: 100%;
transform: translateX(-50%); transform: translateX(-50%);
background-color: var(--gray-a8); background-color: var(--gray-a8);
} }
.shell-path-line { .line {
position: absolute; position: absolute;
top: 0; top: 0;
left: 50%; left: 50%;
@@ -77,9 +75,9 @@
height: 100%; height: 100%;
transform: translateX(-50%); transform: translateX(-50%);
background: linear-gradient(var(--gray-a11), var(--gray-a4)); background: linear-gradient(var(--gray-a11), var(--gray-a4));
} }
.shell { .shell {
position: absolute; position: absolute;
top: 6rem; top: 6rem;
left: 0; left: 0;
@@ -88,14 +86,14 @@
object-fit: contain; object-fit: contain;
transform: translate(-50%, -50%) rotate(-45deg); transform: translate(-50%, -50%) rotate(-45deg);
filter: drop-shadow(0 0 var(--space-4) black); filter: drop-shadow(0 0 var(--space-4) black);
} }
}
.effective-path { .effective-path {
width: 0; width: 0;
height: 100%; height: 100%;
}
.effective-path-line { .line {
position: absolute; position: absolute;
left: 50%; left: 50%;
top: -100%; top: -100%;
@@ -103,42 +101,43 @@
height: 100%; height: 100%;
transform: translateX(-50%); transform: translateX(-50%);
background: linear-gradient(var(--gray-a4), var(--gray-a11)); background: linear-gradient(var(--gray-a4), var(--gray-a11));
} }
}
.corner-stat { .corner-stat {
position: absolute; position: absolute;
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
color: var(--gray-11); color: var(--gray-11);
font-size: var(--font-size-minor); font-size: var(--font-size-minor);
}
.corner-stat-value { .value {
color: var(--gray-12); color: var(--gray-12);
font-size: inherit; font-size: inherit;
} }
.corner-stat[data-position="top-left"] { &[data-position="top-left"] {
top: var(--space-3); top: var(--space-3);
left: var(--space-3); left: var(--space-3);
} }
.corner-stat[data-position="top-right"] { &[data-position="top-right"] {
top: var(--space-3); top: var(--space-3);
right: var(--space-3); right: var(--space-3);
} }
.corner-stat[data-position="bottom-left"] { &[data-position="bottom-left"] {
bottom: var(--space-3); bottom: var(--space-3);
left: var(--space-3); left: var(--space-3);
} }
.corner-stat[data-position="bottom-right"] { &[data-position="bottom-right"] {
right: var(--space-3); right: var(--space-3);
bottom: var(--space-3); bottom: var(--space-3);
} }
}
.explosive-overlay { .explosive-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
@@ -148,4 +147,78 @@
color: var(--gray-11); color: var(--gray-11);
text-align: center; text-align: center;
background: radial-gradient(var(--black-a7), var(--black-a11)); background: radial-gradient(var(--black-a7), var(--black-a11));
}
}
.traverse {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: inherit;
user-select: none;
.hull {
position: absolute;
top: 50%;
left: 50%;
width: 12rem;
height: 12rem;
transform: translate(-50%, -50%);
&[data-turretless="true"] {
width: 22rem;
height: 22rem;
}
.image {
width: 100%;
height: 100%;
object-fit: contain;
filter: drop-shadow(0 0 var(--space-4) var(--black-a7));
}
.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));
}
}
.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%);
}
.terrain {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
}
.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%);
}
} }
@@ -1,6 +1,7 @@
import { alias, isExplosive } from "@blitzkit/core"; import { alias, isExplosive } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n"; import { literals } from "@blitzkit/i18n";
import { import {
useEffect,
useRef, useRef,
useState, useState,
type CSSProperties, type CSSProperties,
@@ -10,7 +11,10 @@ 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 type { ComputedCharacteristics } from "../../types/characteristics";
import { Checkbox } from "../Checkbox";
import { Code } from "../Code"; import { Code } from "../Code";
import { SegmentedControl } from "../SegmentedControl";
import styles from "./index.module.css"; import styles from "./index.module.css";
export function RicochetToy({ characteristics }: ToyProps) { export function RicochetToy({ characteristics }: ToyProps) {
@@ -53,7 +57,7 @@ export function RicochetToy({ characteristics }: ToyProps) {
onPointerMove={updateAngle} onPointerMove={updateAngle}
> >
<div className={styles.armor}> <div className={styles.armor}>
<div className={styles["armor-texture"]} /> <div className={styles.texture} />
</div> </div>
<div <div
@@ -74,11 +78,10 @@ export function RicochetToy({ characteristics }: ToyProps) {
} as CSSProperties } as CSSProperties
} }
> >
<div className={styles["shell-path-extension"]} /> <div className={styles.extension} />
<div className={styles["shell-path-line"]} /> <div className={styles.line} />
<img <img
className={styles.shell} className={styles.shell}
alt=""
src={alias("api", `/icons/shells/${shell.icon}.webp`)} src={alias("api", `/icons/shells/${shell.icon}.webp`)}
/> />
</div> </div>
@@ -91,18 +94,18 @@ export function RicochetToy({ characteristics }: ToyProps) {
} as CSSProperties } as CSSProperties
} }
> >
<div className={styles["effective-path-line"]} /> <div className={styles.line} />
</div> </div>
<CornerStat <CornerStat
label={strings.tanks.characteristics.toys.ricochet.angle} label={strings.tanks.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),
})} })}
position={CornerStatPosition.TopLeft} position={CornerStatPosition.TopLeft}
/> />
<CornerStat <CornerStat
label={strings.tanks.characteristics.toys.ricochet.normalization} label={strings.tanks.toys.ricochet.normalization}
value={ value={
doesRicochet doesRicochet
? "-" ? "-"
@@ -115,7 +118,7 @@ export function RicochetToy({ characteristics }: ToyProps) {
position={CornerStatPosition.TopRight} position={CornerStatPosition.TopRight}
/> />
<CornerStat <CornerStat
label={strings.tanks.characteristics.toys.ricochet.effective} label={strings.tanks.toys.ricochet.effective}
value={ value={
doesRicochet doesRicochet
? "-" ? "-"
@@ -126,7 +129,7 @@ export function RicochetToy({ characteristics }: ToyProps) {
position={CornerStatPosition.BottomLeft} position={CornerStatPosition.BottomLeft}
/> />
<CornerStat <CornerStat
label={strings.tanks.characteristics.toys.ricochet.armor} label={strings.tanks.toys.ricochet.armor}
value={ value={
doesRicochet doesRicochet
? "-" ? "-"
@@ -139,7 +142,7 @@ export function RicochetToy({ characteristics }: ToyProps) {
{explodes && ( {explodes && (
<div className={styles["explosive-overlay"]}> <div className={styles["explosive-overlay"]}>
{strings.tanks.characteristics.toys.ricochet.no_ricochet} {strings.tanks.toys.ricochet.no_ricochet}
</div> </div>
)} )}
</div> </div>
@@ -163,9 +166,141 @@ function CornerStat({ label, value, position }: CornerStatProps) {
return ( return (
<div className={styles["corner-stat"]} data-position={position}> <div className={styles["corner-stat"]} data-position={position}>
<span>{label}</span> <span>{label}</span>
<Code className={styles["corner-stat-value"]} size="minor"> <Code className={styles.value} size="minor">
{value} {value}
</Code> </Code>
</div> </div>
); );
} }
const terrains = ["hard", "medium", "soft"] as const;
type Terrain = (typeof terrains)[number];
interface TraverseToyProps {
characteristics: ComputedCharacteristics;
}
export function TraverseToy({ characteristics }: TraverseToyProps) {
const hull = useRef<HTMLDivElement>(null);
const turret = useRef<HTMLImageElement>(null);
const [terrain, setTerrain] = useState<Terrain>("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 (
<div className={styles.traverse}>
<div
ref={hull}
className={styles.hull}
data-turretless={isTurretless}
>
<img
draggable="false"
className={styles.image}
src={`/media/toys/hull${isTurretless ? "-only" : ""}.png`}
/>
{!isTurretless && (
<img
draggable="false"
ref={turret}
className={styles.turret}
src="/media/toys/turret.png"
/>
)}
</div>
<div className={styles.toggles}>
{!isTurretless && (
<>
<Checkbox checked={rotateHull} onCheckedChange={setRotateHull}>
{strings.tanks.toys.traverse.hull}
</Checkbox>
<Checkbox checked={rotateTurret} onCheckedChange={setRotateTurret}>
{strings.tanks.toys.traverse.turret}
</Checkbox>
</>
)}
</div>
<div className={styles.terrain}>
<SegmentedControl
options={terrainOptions}
value={terrain}
onValueChange={(value) => {
const nextTerrain = terrains.find((terrain) => terrain === value);
if (nextTerrain) setTerrain(nextTerrain);
}}
variant="underline"
/>
</div>
<div className={styles.effective}>
<span>{strings.tanks.toys.traverse.effective}</span>
<Code size="minor">
{literals(strings.units.deg_s, {
value: (turretSpeed + hullSpeedEffective).toFixed(1),
})}
</Code>
</div>
</div>
);
}
+2 -1
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { RicochetToy } from "../components/TankopediaToys"; import { RicochetToy, TraverseToy } from "../components/TankopediaToys";
import type { ComputedCharacteristics } from "../types/characteristics"; import type { ComputedCharacteristics } from "../types/characteristics";
export interface ToyProps { export interface ToyProps {
@@ -8,4 +8,5 @@ export interface ToyProps {
export const toys: Record<string, (props: ToyProps) => ReactNode> = { export const toys: Record<string, (props: ToyProps) => ReactNode> = {
ricochet: RicochetToy, ricochet: RicochetToy,
traverse: TraverseToy,
}; };