mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-11 03:36:11 +01:00
antagonist controls
This commit is contained in:
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user