mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 18:57:03 +02:00
traverse toy
This commit is contained in:
1 parent
de80519f06
commit
cae63a70b0
15 files changed
+346
-36
No files matched your search
@@ -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 {
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { type ReactNode } from "react";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export interface SegmentedControlOption<Value extends string | number> {
|
||||
value: Value;
|
||||
export interface SegmentedControlOption {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
export interface SegmentedControlProps<Value extends string | number> {
|
||||
options: readonly SegmentedControlOption<Value>[];
|
||||
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<Value extends string | number>({
|
||||
export function SegmentedControl({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
}: SegmentedControlProps<Value>) {
|
||||
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<Value extends string | number>({
|
||||
<div
|
||||
className={styles.options}
|
||||
role="radiogroup"
|
||||
style={{ gridTemplateColumns: `repeat(${options.length}, 1fr)` }}
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.indicator}
|
||||
|
||||
+1
-1
@@ -296,7 +296,7 @@ export function GunFlexibilityVisualizer({
|
||||
height="100%"
|
||||
style={{
|
||||
opacity: 2 ** -3,
|
||||
backgroundImage: `url(/media/random/armor-hash.png)`,
|
||||
backgroundImage: `url(/media/toys/armor-hash.png)`,
|
||||
boxShadow: "inset 0 0 4rem 1rem black",
|
||||
}}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -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)`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -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() {
|
||||
<Text lowContrast>{strings.tanks.characteristics.compare.label}</Text>
|
||||
<SegmentedControl
|
||||
options={options.current}
|
||||
value={relativeAgainst}
|
||||
value={`${relativeAgainst}`}
|
||||
onValueChange={(value) => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.relative_against = value;
|
||||
draft.relative_against = Number(value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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) {
|
||||
<div className={styles["shell-path-line"]} />
|
||||
<img
|
||||
className={styles.shell}
|
||||
alt=""
|
||||
src={alias("api", `/icons/shells/${shell.icon}.webp`)}
|
||||
/>
|
||||
</div>
|
||||
@@ -95,14 +98,14 @@ export function RicochetToy({ characteristics }: ToyProps) {
|
||||
</div>
|
||||
|
||||
<CornerStat
|
||||
label={strings.tanks.characteristics.toys.ricochet.angle}
|
||||
label={strings.tanks.toys.ricochet.angle}
|
||||
value={literals(strings.common.units.deg, {
|
||||
value: radToDeg(Math.abs(angle)).toFixed(0),
|
||||
})}
|
||||
position={CornerStatPosition.TopLeft}
|
||||
/>
|
||||
<CornerStat
|
||||
label={strings.tanks.characteristics.toys.ricochet.normalization}
|
||||
label={strings.tanks.toys.ricochet.normalization}
|
||||
value={
|
||||
doesRicochet
|
||||
? "-"
|
||||
@@ -115,7 +118,7 @@ export function RicochetToy({ characteristics }: ToyProps) {
|
||||
position={CornerStatPosition.TopRight}
|
||||
/>
|
||||
<CornerStat
|
||||
label={strings.tanks.characteristics.toys.ricochet.effective}
|
||||
label={strings.tanks.toys.ricochet.effective}
|
||||
value={
|
||||
doesRicochet
|
||||
? "-"
|
||||
@@ -126,7 +129,7 @@ export function RicochetToy({ characteristics }: ToyProps) {
|
||||
position={CornerStatPosition.BottomLeft}
|
||||
/>
|
||||
<CornerStat
|
||||
label={strings.tanks.characteristics.toys.ricochet.armor}
|
||||
label={strings.tanks.toys.ricochet.armor}
|
||||
value={
|
||||
doesRicochet
|
||||
? "-"
|
||||
@@ -139,7 +142,7 @@ export function RicochetToy({ characteristics }: ToyProps) {
|
||||
|
||||
{explodes && (
|
||||
<div className={styles["explosive-overlay"]}>
|
||||
{strings.tanks.characteristics.toys.ricochet.no_ricochet}
|
||||
{strings.tanks.toys.ricochet.no_ricochet}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -169,3 +172,135 @@ function CornerStat({ label, value, position }: CornerStatProps) {
|
||||
</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-visualizer"]}>
|
||||
<div
|
||||
ref={hull}
|
||||
className={styles["traverse-hull"]}
|
||||
data-turretless={isTurretless}
|
||||
>
|
||||
<img
|
||||
draggable="false"
|
||||
className={styles["traverse-hull-image"]}
|
||||
src={`/media/toys/hull${isTurretless ? "-only" : ""}.png`}
|
||||
/>
|
||||
|
||||
{!isTurretless && (
|
||||
<img
|
||||
draggable="false"
|
||||
ref={turret}
|
||||
className={styles["traverse-turret"]}
|
||||
src="/media/toys/turret.png"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles["traverse-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["traverse-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["traverse-effective"]}>
|
||||
<span>{strings.tanks.toys.traverse.effective}</span>
|
||||
<Code size="minor">
|
||||
{literals(strings.units.deg_s, {
|
||||
value: (turretSpeed + hullSpeedEffective).toFixed(1),
|
||||
})}
|
||||
</Code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user