antagonist controls

This commit is contained in:
tresabhi committed 2026-11-10 14:54:22 -06:00
1 parent f74b66dd5f
commit f071a77fc0
18 files changed
+316 -259

No files matched your search

@@ -1,5 +1,8 @@
import { alias } from "@blitzkit/core";
import { type Strings } from "@blitzkit/i18n";
import { literals } from "@blitzkit/i18n/src/literals"; import { literals } from "@blitzkit/i18n/src/literals";
import { ShellType } from "@blitzkit/protos"; import { ShellType } from "@blitzkit/protos";
import { CaretDownIcon, DotFilledIcon } from "@radix-ui/react-icons";
import { throttle } from "lodash-es"; import { throttle } from "lodash-es";
import { useEffect, useMemo, useState, type KeyboardEvent } from "react"; import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
import { DuelModule, useDuel } from "../../hooks/useDuel"; import { DuelModule, useDuel } from "../../hooks/useDuel";
@@ -58,15 +61,24 @@ function NumberField({ value, suffix, onValueChange }: NumberFieldProps) {
onValueChange(parsed); onValueChange(parsed);
} }
}} }}
childrenAsSuffix
> >
<Text lowContrast>{suffix}</Text> <Text lowContrast>{suffix}</Text>
</TextField> </TextField>
); );
} }
const COMMON_SHELLS: [ShellType, keyof Strings["common"]["shells"]][] = [
[ShellType.SHELL_TYPE_AP, "ap"],
[ShellType.SHELL_TYPE_APCR, "ap_cr"],
[ShellType.SHELL_TYPE_HEAT, "hc"],
[ShellType.SHELL_TYPE_HE, "he"],
];
export function CustomShellButton() { export function CustomShellButton() {
const customShell = Tankopedia.use((state) => state.customShell); const customShell = Tankopedia.use((state) => state.customShell);
const gun = useDuel("protagonist", DuelModule.Gun); const gun = useDuel("protagonist", DuelModule.Gun);
const antagonistGun = useDuel("antagonist", DuelModule.Gun);
const antagonistShell = useDuel("antagonist", DuelModule.Shell); const antagonistShell = useDuel("antagonist", DuelModule.Shell);
const strings = useStrings(); const strings = useStrings();
@@ -100,7 +112,10 @@ export function CustomShellButton() {
highContrast={customShell !== undefined} highContrast={customShell !== undefined}
size="major" size="major"
> >
<img alt="custom shell" src="/assets/images/icons/custom-shell.png" /> <img
alt={strings.tanks.custom_shell.title}
src="/assets/images/icons/custom-shell.png"
/>
</IconButton> </IconButton>
</Popover.Trigger> </Popover.Trigger>
</Tooltip> </Tooltip>
@@ -126,18 +141,21 @@ export function CustomShellButton() {
modified.explosion_radius = undefined; modified.explosion_radius = undefined;
break; break;
} }
case ShellType.SHELL_TYPE_APCR: { case ShellType.SHELL_TYPE_APCR: {
modified.normalization = 2; modified.normalization = 2;
modified.ricochet = 70; modified.ricochet = 70;
modified.explosion_radius = undefined; modified.explosion_radius = undefined;
break; break;
} }
case ShellType.SHELL_TYPE_HEAT: { case ShellType.SHELL_TYPE_HEAT: {
modified.normalization = undefined; modified.normalization = undefined;
modified.ricochet = undefined; modified.ricochet = undefined;
modified.explosion_radius = undefined; modified.explosion_radius = undefined;
break; break;
} }
case ShellType.SHELL_TYPE_HE: { case ShellType.SHELL_TYPE_HE: {
modified.normalization = undefined; modified.normalization = undefined;
modified.ricochet = undefined; modified.ricochet = undefined;
@@ -156,27 +174,36 @@ export function CustomShellButton() {
> >
<Select.Trigger> <Select.Trigger>
<Select.Value /> <Select.Value />
<Select.Icon>
<CaretDownIcon />
</Select.Icon>
</Select.Trigger> </Select.Trigger>
<Select.Portal> <Select.Portal>
<Select.Content position="popper" sideOffset={8}> <Select.Content>
<Select.Viewport> <Select.Viewport>
{[ {COMMON_SHELLS.map(([type, label]) => {
[ShellType.SHELL_TYPE_AP, strings.common.shells.ap], return (
[ <Select.Item key={type} value={`${type}`}>
ShellType.SHELL_TYPE_APCR, <Select.ItemIndicator>
strings.common.shells.ap_cr, <DotFilledIcon />
], </Select.ItemIndicator>
[
ShellType.SHELL_TYPE_HEAT, <Select.ItemText className="TEST_CLASS">
strings.common.shells.hc, {strings.common.shells[label]}
],
[ShellType.SHELL_TYPE_HE, strings.common.shells.he], <img
].map(([type, label]) => ( alt={strings.common.shells[label]}
<Select.Item key={type} value={`${type}`}> src={alias(
<Select.ItemText>{label}</Select.ItemText> "api",
</Select.Item> `/icons/shells/${label}.webp`,
))} )}
/>
</Select.ItemText>
</Select.Item>
);
})}
</Select.Viewport> </Select.Viewport>
</Select.Content> </Select.Content>
</Select.Portal> </Select.Portal>
@@ -3,10 +3,7 @@
right: var(--space-3); right: var(--space-3);
font-size: var(--font-size-minor); font-size: var(--font-size-minor);
line-height: 1.2;
text-align: right; text-align: right;
text-shadow: 0 0 4px black;
color: var(--white-a12);
pointer-events: none; pointer-events: none;
@@ -75,6 +75,14 @@
border-bottom: var(--space-1) solid var(--gray-2); border-bottom: var(--space-1) solid var(--gray-2);
} }
/**
* This is actually .ItemText, but its class name isn't being passed to the
* html, so I am selecting the span instead.
*/
span {
display: contents;
}
img, img,
svg { svg {
width: 1em; width: 1em;
@@ -1,3 +1,5 @@
@import "../DropdownMenu/index.module.css";
.Trigger { .Trigger {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -18,45 +20,18 @@
outline: var(--outline-focus); outline: var(--outline-focus);
box-shadow: var(--shadow-focus); box-shadow: var(--shadow-focus);
} }
}
.Content { svg,
/* above popovers */ img {
z-index: 2; width: 1em;
height: 1em;
overflow: hidden;
background-color: var(--gray-2);
box-shadow: var(--shadow-subtle);
border-radius: var(--space-4);
outline: var(--outline-interactive) !important;
}
.Item {
position: relative;
display: flex;
align-items: center;
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5);
background-color: var(--gray-3);
font-size: var(--font-size-minor);
cursor: pointer;
user-select: none;
&[data-highlighted] {
outline: none;
background-color: var(--gray-5);
} }
&:not(:last-child) { /*
border-bottom: var(--space-1) solid var(--gray-2); * Another unselectable Radix component, being selected by span instead.
*/
.Icon,
span {
display: contents;
} }
} }
.ItemIndicator {
position: absolute;
left: var(--space-3);
display: flex;
}
@@ -2,4 +2,6 @@ import { Select as _ } from "radix-ui";
import { composeRadixComponents } from "../../ui/compose"; import { composeRadixComponents } from "../../ui/compose";
import styles from "./index.module.css"; import styles from "./index.module.css";
export const Select = composeRadixComponents(_, styles); export const Select = composeRadixComponents(_, styles, {
Content: { position: "popper", align: "center" },
});
@@ -7,6 +7,10 @@
height: var(--space-5); height: var(--space-5);
touch-action: none; touch-action: none;
user-select: none; user-select: none;
html:active & {
cursor: grabbing;
}
} }
.Track { .Track {
@@ -35,6 +39,12 @@
background-color: var(--gray-12); background-color: var(--gray-12);
box-shadow: var(--shadow-subtle); box-shadow: var(--shadow-subtle);
cursor: grab;
html:active & {
cursor: grabbing;
}
&:focus-visible { &:focus-visible {
outline: var(--outline-focus); outline: var(--outline-focus);
box-shadow: var(--shadow-focus); box-shadow: var(--shadow-focus);
@@ -41,7 +41,6 @@ export const TankCard = (props: MaybeSkeletonComponentProps<TankCardProps>) => {
: props.tank.type === TankType.TANK_TYPE_PREMIUM : props.tank.type === TankType.TANK_TYPE_PREMIUM
? "amber" ? "amber"
: "gray"; : "gray";
const lowContrast = props.tank.type !== TankType.TANK_TYPE_RESEARCHABLE;
const content = ( const content = (
<div <div
@@ -68,7 +67,7 @@ export const TankCard = (props: MaybeSkeletonComponentProps<TankCardProps>) => {
justifyContent: "flex-end", justifyContent: "flex-end",
backgroundImage: `url(${alias( backgroundImage: `url(${alias(
"api", "api",
`/flags/scratched/${props.tank.nation}.webp` `/flags/scratched/${props.tank.nation}.webp`,
)})`, )})`,
}} }}
> >
@@ -78,7 +77,7 @@ export const TankCard = (props: MaybeSkeletonComponentProps<TankCardProps>) => {
/> />
</div> </div>
<Text className={styles.name} color={color} lowContrast={lowContrast}> <Text className={styles.name} color={color}>
<ClassIcon class={props.tank.class} className={styles.icon} /> <ClassIcon class={props.tank.class} className={styles.icon} />
<span className={styles.text}>{name}</span> <span className={styles.text}>{name}</span>
</Text> </Text>
@@ -1,6 +1,7 @@
import { alias, romanize, uniqueGuns } from "@blitzkit/core"; import { alias, romanize, uniqueGuns } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n"; import { literals } from "@blitzkit/i18n";
import { useMemo } from "react"; import { useMemo } from "react";
import { useAbsoluteDisplay } from "../../hooks/useAbsoluteDisplay";
import { DuelModule, useDuel } from "../../hooks/useDuel"; import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useEquipment } from "../../hooks/useEquipment"; import { useEquipment } from "../../hooks/useEquipment";
import { useStrings } from "../../hooks/useStrings"; import { useStrings } from "../../hooks/useStrings";
@@ -28,7 +29,9 @@ export function TankopediaAntagonistControl({
const unwrap = useUnwrapper(); const unwrap = useUnwrapper();
const showControls = Tankopedia.use((state) => state.showControls); const showControls = Tankopedia.use((state) => state.showControls);
const display = Tankopedia.use((state) => state.loaders.display.loaded); const isDynamicArmorDisplay = useAbsoluteDisplay(
TankopediaDisplay.DynamicArmor,
);
const hasCustomShell = Tankopedia.use( const hasCustomShell = Tankopedia.use(
(state) => state.customShell !== undefined, (state) => state.customShell !== undefined,
); );
@@ -46,7 +49,7 @@ export function TankopediaAntagonistControl({
[tank.turrets], [tank.turrets],
); );
const isVisible = showControls && display === TankopediaDisplay.DynamicArmor; const isVisible = showControls && isDynamicArmorDisplay;
return ( return (
<div className={styles["antagonist-control"]} data-visible={isVisible}> <div className={styles["antagonist-control"]} data-visible={isVisible}>
@@ -83,7 +86,10 @@ export function TankopediaAntagonistControl({
}); });
}} }}
> >
<img alt={name} src={alias("api", "/icons/modules/gun.webp")} /> <img
alt={name}
src={alias("api", "/icons/modules/gun.webp")}
/>
<Discriminator>{romanize(entry.gun.tier)}</Discriminator> <Discriminator>{romanize(entry.gun.tier)}</Discriminator>
</IconButton> </IconButton>
@@ -127,7 +133,10 @@ export function TankopediaAntagonistControl({
</div> </div>
<div className={styles.group}> <div className={styles.group}>
<Tooltip tooltip={strings.tanks.antagonist_control.calibrated_shells} side="left"> <Tooltip
tooltip={strings.tanks.antagonist_control.calibrated_shells}
side="left"
>
<IconButton <IconButton
parentListChild="vertical" parentListChild="vertical"
color={hasCalibratedShells ? undefined : "gray"} color={hasCalibratedShells ? undefined : "gray"}
@@ -153,7 +162,10 @@ export function TankopediaAntagonistControl({
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip tooltip={strings.tanks.antagonist_control.enhanced_armor} side="left"> <Tooltip
tooltip={strings.tanks.antagonist_control.enhanced_armor}
side="left"
>
<IconButton <IconButton
parentListChild="vertical" parentListChild="vertical"
color={hasEnhancedArmor ? undefined : "gray"} color={hasEnhancedArmor ? undefined : "gray"}
@@ -1,8 +0,0 @@
.quick-inputs {
left: -5rem;
}
.quick-inputs:focus-within,
.tank-sandbox-container:hover .quick-inputs {
left: 1rem;
}
@@ -1,160 +0,0 @@
import { HeightIcon, WidthIcon } from "@radix-ui/react-icons";
import { Flex, TextField } from "@radix-ui/themes";
import type { QuicklimeEvent } from "quicklime";
import { useEffect, useRef } from "react";
import { degToRad, radToDeg } from "three/src/math/MathUtils.js";
import { useTankModelDefinition } from "../../../../../../../hooks/useTankModelDefinition";
import { Duel } from "../../../../../../../stores/duel";
import { applyPitchYawLimits } from "../../../../core/blitz/applyPitchYawLimits";
import {
type ModelTransformEventData,
modelTransformEvent,
} from "../../../../core/blitzkit/modelTransform";
import { useEquipment } from "../../../../hooks/useEquipment";
import "./index.css";
export function QuickInputs() {
const protagonist = Duel.use((state) => state.protagonist);
const yawInput = useRef<HTMLInputElement>(null);
const pitchInput = useRef<HTMLInputElement>(null);
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const initialTurretPitch =
tankModelDefinition.initial_turret_rotation?.pitch ?? 0;
const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122);
const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124);
function displayPitch(pitch: number) {
return radToDeg(pitch) - initialTurretPitch;
}
useEffect(() => {
function syncInputValues(data: ModelTransformEventData) {
if (!pitchInput.current || !yawInput.current) return;
pitchInput.current.value = displayPitch(data.pitch).toFixed(1);
yawInput.current.value = radToDeg(data.yaw).toFixed(1);
}
function handleTransformEvent(
event: QuicklimeEvent<ModelTransformEventData>,
) {
syncInputValues(event.data);
}
modelTransformEvent.on(handleTransformEvent);
syncInputValues(modelTransformEvent.last!);
return () => {
modelTransformEvent.off(handleTransformEvent);
};
}, [initialTurretPitch]);
return (
<Flex
align="center"
gap="2"
width="4rem"
direction="column"
style={{
pointerEvents: "auto",
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
transitionDuration: "0.2s",
}}
className="quick-inputs"
>
<TextField.Root
variant="classic"
size="1"
radius="full"
style={{ flex: 1, textAlign: "right" }}
defaultValue={radToDeg(modelTransformEvent.last!.yaw).toFixed(1)}
onBlur={() => {
const value = Number(yawInput.current!.value);
if (isNaN(value)) {
yawInput.current!.value = radToDeg(
modelTransformEvent.last!.yaw,
).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
modelTransformEvent.last!.pitch,
degToRad(value),
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
yawInput.current!.value = radToDeg(
modelTransformEvent.last!.yaw,
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
yawInput.current?.blur();
}
}}
onFocus={() => yawInput.current?.focus()}
ref={yawInput}
>
<TextField.Slot style={{ userSelect: "none" }}>
<WidthIcon />
</TextField.Slot>
<TextField.Slot />
</TextField.Root>
<TextField.Root
variant="classic"
size="1"
radius="full"
style={{ flex: 1, textAlign: "right" }}
defaultValue={displayPitch(modelTransformEvent.last!.pitch).toFixed(1)}
onBlur={() => {
const value = Number(pitchInput.current!.value);
if (isNaN(value)) {
pitchInput.current!.value = displayPitch(
modelTransformEvent.last!.pitch,
).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
degToRad(value + initialTurretPitch),
modelTransformEvent.last!.yaw,
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
pitchInput.current!.value = displayPitch(
modelTransformEvent.last!.pitch,
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
pitchInput.current?.blur();
}
}}
onFocus={() => pitchInput.current?.focus()}
ref={pitchInput}
>
<TextField.Slot
style={{
userSelect: "none",
}}
>
<HeightIcon />
</TextField.Slot>
<TextField.Slot />
</TextField.Root>
</Flex>
);
}
@@ -0,0 +1,22 @@
.quick-inputs {
pointer-events: auto;
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 5rem;
transition: var(--duration-brief);
position: absolute;
top: 50%;
left: var(--space-4);
transform: translate(calc(-100% - var(--space-4) - var(--space-0)), -50%);
&[data-visible="true"],
&:focus-within {
transform: translate(0, -50%);
}
}
@@ -0,0 +1,138 @@
import { HeightIcon, WidthIcon } from "@radix-ui/react-icons";
import type { QuicklimeEvent } from "quicklime";
import { useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { degToRad, radToDeg } from "three/src/math/MathUtils.js";
import { applyPitchYawLimits } from "../../core/blitz/applyPitchYawLimits";
import {
modelTransformEvent,
type ModelTransformEventData,
} from "../../core/blitzkit/modelTransform";
import { useDuelModel } from "../../hooks/useDuelModel";
import { useEquipment } from "../../hooks/useEquipment";
import { Tankopedia } from "../../stores/tankopedia";
import { TextField } from "../TextField";
import styles from "./index.module.css";
interface AngleFieldProps {
/** Degrees */
value: number;
/** Receives degrees */
onCommit(value: number): void;
children: ReactNode;
}
function AngleField({ children, value, onCommit }: AngleFieldProps) {
const [text, setText] = useState(value.toFixed(1));
const [editing, setEditing] = useState(false);
// follow the tank while dragging, but never fight the user mid-typing
useEffect(() => {
if (!editing) setText(value.toFixed(1));
}, [value, editing]);
function commit(input: HTMLInputElement) {
const parsed = Number(text);
if (text.trim() !== "" && Number.isFinite(parsed)) onCommit(parsed);
setEditing(false);
setText(value.toFixed(1));
input.blur();
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
// keep tankopedia hotkeys from firing while typing
event.stopPropagation();
const input = event.target as HTMLInputElement;
if (event.key === "Enter") {
commit(input);
} else if (event.key === "Escape") {
setEditing(false);
setText(value.toFixed(1));
input.blur();
}
}
return (
<TextField
autoWidth
size="minor"
align="right"
value={text}
childrenAsSuffix
onFocus={(event) => {
setEditing(true);
(event.target as HTMLInputElement).select?.();
}}
onBlur={(event) => commit(event.target as HTMLInputElement)}
onKeyDown={handleKeyDown}
onChange={(event) => setText(event.target.value)}
>
{children}
</TextField>
);
}
export function TankopediaQuickInputs() {
const protagonist = Tankopedia.use((state) => state.protagonist);
const showControls = Tankopedia.use((state) => state.showControls);
const tankModelDefinition = useDuelModel("protagonist");
const turretModelDefinition = tankModelDefinition.turrets[protagonist.turret];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun];
const initialTurretPitch =
tankModelDefinition.initial_turret_rotation?.pitch ?? 0;
const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122);
const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124);
const [transform, setTransform] = useState(modelTransformEvent.last!);
useEffect(() => {
function handleTransform(event: QuicklimeEvent<ModelTransformEventData>) {
setTransform(event.data);
}
modelTransformEvent.on(handleTransform);
setTransform(modelTransformEvent.last!);
return () => {
modelTransformEvent.off(handleTransform);
};
}, []);
function apply(pitch: number, yaw: number) {
const [limitedPitch, limitedYaw] = applyPitchYawLimits(
pitch,
yaw,
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch: limitedPitch, yaw: limitedYaw });
}
return (
<div className={styles["quick-inputs"]} data-visible={showControls}>
<AngleField
value={radToDeg(transform.yaw)}
onCommit={(value) => apply(transform.pitch, degToRad(value))}
>
<WidthIcon />
</AngleField>
<AngleField
value={radToDeg(transform.pitch) - initialTurretPitch}
onCommit={(value) =>
apply(degToRad(value + initialTurretPitch), transform.yaw)
}
>
<HeightIcon />
</AngleField>
</div>
);
}
@@ -23,6 +23,7 @@ import { TankDynamicArmor } from "../TankDynamicArmor";
import { TankModel } from "../TankModel"; import { TankModel } from "../TankModel";
import { TankopediaAntagonistControl } from "../TankopediaAntagonistControl"; import { TankopediaAntagonistControl } from "../TankopediaAntagonistControl";
import { TankopediaOptions } from "../TankopediaOptions"; import { TankopediaOptions } from "../TankopediaOptions";
import { TankopediaQuickInputs } from "../TankopediaQuickInputs";
import { TankopediaSkinSwitcher } from "../TankopediaSkinSwitcher"; import { TankopediaSkinSwitcher } from "../TankopediaSkinSwitcher";
import { TankSandboxLoader } from "../TankSandboxLoader"; import { TankSandboxLoader } from "../TankSandboxLoader";
import { TankStaticArmor, type ThicknessRange } from "../TankStaticArmor"; import { TankStaticArmor, type ThicknessRange } from "../TankStaticArmor";
@@ -177,6 +178,7 @@ export function TankopediaSandbox({
<TankSandboxLoader /> <TankSandboxLoader />
<TankopediaSkinSwitcher /> <TankopediaSkinSwitcher />
<TankopediaQuickInputs />
<Thicknesses thicknessRange={thicknessRange} /> <Thicknesses thicknessRange={thicknessRange} />
<TankopediaAntagonistControl <TankopediaAntagonistControl
antagonistCharacteristics={antagonistCharacteristics} antagonistCharacteristics={antagonistCharacteristics}
@@ -3,6 +3,7 @@ import { PauseIcon, PlayIcon } from "@radix-ui/react-icons";
import { uniqBy } from "lodash-es"; import { uniqBy } from "lodash-es";
import { api } from "../../api/dynamic"; import { api } from "../../api/dynamic";
import { SKIN_NUMBER } from "../../constants/skinNumber"; import { SKIN_NUMBER } from "../../constants/skinNumber";
import { useAbsoluteDisplay } from "../../hooks/useAbsoluteDisplay";
import { DuelModule, useDuel } from "../../hooks/useDuel"; import { DuelModule, useDuel } from "../../hooks/useDuel";
import { hasSkinAnimations } from "../../hooks/useSkin"; import { hasSkinAnimations } from "../../hooks/useSkin";
import { useUnwrapper } from "../../hooks/useUnwrapper"; import { useUnwrapper } from "../../hooks/useUnwrapper";
@@ -21,7 +22,7 @@ export function TankopediaSkinSwitcher() {
const skin = Tankopedia.use((state) => state.loaders.skin.requested); const skin = Tankopedia.use((state) => state.loaders.skin.requested);
const showControls = Tankopedia.use((state) => state.showControls); const showControls = Tankopedia.use((state) => state.showControls);
const display = Tankopedia.use((state) => state.loaders.display.loaded); const isModelDisplay = useAbsoluteDisplay(TankopediaDisplay.Model);
const loadedSkin = Tankopedia.use((state) => state.loaders.skin.loaded); const loadedSkin = Tankopedia.use((state) => state.loaders.skin.loaded);
const loadedAttachment = Tankopedia.use( const loadedAttachment = Tankopedia.use(
(state) => state.loaders.attachment.loaded, (state) => state.loaders.attachment.loaded,
@@ -42,7 +43,7 @@ export function TankopediaSkinSwitcher() {
skins.filter((camouflage) => camouflage.attachment), skins.filter((camouflage) => camouflage.attachment),
].filter((group) => group.length > 0); ].filter((group) => group.length > 0);
const isVisible = showControls && display === TankopediaDisplay.Model; const isVisible = showControls && isModelDisplay;
const hasAnimations = const hasAnimations =
hasSkinAnimations(tank.id, loadedSkin) || hasSkinAnimations(tank.id, loadedSkin) ||
hasSkinAnimations(tank.id, loadedAttachment); hasSkinAnimations(tank.id, loadedAttachment);
@@ -29,6 +29,24 @@
} }
} }
&[data-size="minor"] {
gap: var(--space-1);
font-size: var(--font-size-minor);
&[data-variant="bubble"] input {
padding: var(--space-2) var(--space-3);
}
&[data-variant="form"] input {
padding: var(--space-1);
}
/* the icon's gap already separates it from the input */
&[data-has-icon="true"] input {
padding-left: 0;
}
}
&[data-has-icon="true"] { &[data-has-icon="true"] {
padding-left: var(--space-3); padding-left: var(--space-3);
@@ -37,6 +55,10 @@
} }
} }
&[data-has-suffix="true"] {
padding-right: var(--space-3);
}
&:focus-within { &:focus-within {
outline: var(--outline-focus); outline: var(--outline-focus);
box-shadow: var(--shadow-focus); box-shadow: var(--shadow-focus);
@@ -60,6 +82,7 @@
input { input {
min-width: 0; min-width: 0;
width: auto;
flex: 1; flex: 1;
background: none; background: none;
@@ -84,5 +107,9 @@
&[data-align="right"] { &[data-align="right"] {
text-align: right; text-align: right;
} }
&[data-auto-width="true"] {
field-sizing: content;
}
} }
} }
@@ -1,20 +1,20 @@
import { import {
forwardRef, forwardRef,
useCallback,
useImperativeHandle, useImperativeHandle,
useRef, useRef,
type ChangeEvent,
type ComponentProps, type ComponentProps,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { classNames } from "../../ui/classNames"; import { classNames } from "../../ui/classNames";
import styles from "./index.module.css"; import styles from "./index.module.css";
export interface TextFieldProps extends ComponentProps<"input"> { export interface TextFieldProps extends Omit<ComponentProps<"input">, "size"> {
children?: ReactNode; children?: ReactNode;
align?: "left" | "center" | "right"; align?: "left" | "center" | "right";
autoWidth?: boolean; autoWidth?: boolean;
variant?: "bubble" | "form"; variant?: "bubble" | "form";
size?: "regular" | "minor";
childrenAsSuffix?: boolean;
} }
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>( export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@@ -27,25 +27,15 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
align, align,
disabled, disabled,
value, value,
onChange,
variant = "bubble", variant = "bubble",
size = "regular",
childrenAsSuffix,
...props ...props
}, },
ref, ref,
) => { ) => {
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
if (autoWidth && input.current) {
input.current.style.width = `${event.target.value.length || event.target.placeholder.length}ch`;
}
onChange?.(event);
},
[onChange],
);
useImperativeHandle(ref, () => input.current!, []); useImperativeHandle(ref, () => input.current!, []);
return ( return (
@@ -55,20 +45,21 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
data-has-icon={children !== undefined} data-has-icon={children !== undefined}
data-disabled={disabled} data-disabled={disabled}
data-variant={variant} data-variant={variant}
data-size={size}
data-has-suffix={childrenAsSuffix}
> >
{children} {!childrenAsSuffix && children}
<input <input
data-auto-width={autoWidth}
data-align={align === "left" ? undefined : align} data-align={align === "left" ? undefined : align}
disabled={disabled} disabled={disabled}
ref={input} ref={input}
onChange={handleChange}
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
style={{
width: `${value?.toString().length || placeholder?.length || 0}ch`,
}}
/> />
{childrenAsSuffix && children}
</div> </div>
); );
}, },
@@ -1,6 +1,7 @@
import { literals } from "@blitzkit/i18n/src/literals"; import { literals } from "@blitzkit/i18n/src/literals";
import { EyeClosedIcon, EyeOpenIcon } from "@radix-ui/react-icons"; import { EyeClosedIcon, EyeOpenIcon } from "@radix-ui/react-icons";
import { useCallback, type ReactNode } from "react"; import { useCallback, type ReactNode } from "react";
import { useAbsoluteDisplay } from "../../hooks/useAbsoluteDisplay";
import { useStrings } from "../../hooks/useStrings"; import { useStrings } from "../../hooks/useStrings";
import { Tankopedia } from "../../stores/tankopedia"; import { Tankopedia } from "../../stores/tankopedia";
import { TankopediaPersistent } from "../../stores/tankopediaPersistent"; import { TankopediaPersistent } from "../../stores/tankopediaPersistent";
@@ -61,7 +62,9 @@ export interface ThicknessesProps {
export function Thicknesses({ thicknessRange }: ThicknessesProps) { export function Thicknesses({ thicknessRange }: ThicknessesProps) {
const strings = useStrings(); const strings = useStrings();
const display = Tankopedia.use((state) => state.loaders.display.loaded); const isStaticArmorDisplay = useAbsoluteDisplay(
TankopediaDisplay.StaticArmor,
);
const showControls = Tankopedia.use((state) => state.showControls); const showControls = Tankopedia.use((state) => state.showControls);
const showPrimaryArmor = TankopediaPersistent.use( const showPrimaryArmor = TankopediaPersistent.use(
(state) => state.showPrimaryArmor, (state) => state.showPrimaryArmor,
@@ -87,7 +90,7 @@ export function Thicknesses({ thicknessRange }: ThicknessesProps) {
return ( return (
<div <div
className={styles.thicknesses} className={styles.thicknesses}
data-visible={showControls && display === TankopediaDisplay.StaticArmor} data-visible={showControls && isStaticArmorDisplay}
> >
<LayerToggle <LayerToggle
visible={showPrimaryArmor} visible={showPrimaryArmor}
@@ -0,0 +1,11 @@
import { Tankopedia } from "../stores/tankopedia";
import type { TankopediaDisplay } from "../stores/tankopediaPersistent/constants";
/** True iff both the loaded and the requested display are `display` */
export function useAbsoluteDisplay(display: TankopediaDisplay) {
return Tankopedia.use(
(state) =>
state.loaders.display.loaded === display &&
state.loaders.display.requested === display,
);
}