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 { ShellType } from "@blitzkit/protos";
|
||||
import { CaretDownIcon, DotFilledIcon } from "@radix-ui/react-icons";
|
||||
import { throttle } from "lodash-es";
|
||||
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { DuelModule, useDuel } from "../../hooks/useDuel";
|
||||
@@ -58,15 +61,24 @@ function NumberField({ value, suffix, onValueChange }: NumberFieldProps) {
|
||||
onValueChange(parsed);
|
||||
}
|
||||
}}
|
||||
childrenAsSuffix
|
||||
>
|
||||
<Text lowContrast>{suffix}</Text>
|
||||
</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() {
|
||||
const customShell = Tankopedia.use((state) => state.customShell);
|
||||
const gun = useDuel("protagonist", DuelModule.Gun);
|
||||
const antagonistGun = useDuel("antagonist", DuelModule.Gun);
|
||||
const antagonistShell = useDuel("antagonist", DuelModule.Shell);
|
||||
const strings = useStrings();
|
||||
|
||||
@@ -100,7 +112,10 @@ export function CustomShellButton() {
|
||||
highContrast={customShell !== undefined}
|
||||
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>
|
||||
</Popover.Trigger>
|
||||
</Tooltip>
|
||||
@@ -126,18 +141,21 @@ export function CustomShellButton() {
|
||||
modified.explosion_radius = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
case ShellType.SHELL_TYPE_APCR: {
|
||||
modified.normalization = 2;
|
||||
modified.ricochet = 70;
|
||||
modified.explosion_radius = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
case ShellType.SHELL_TYPE_HEAT: {
|
||||
modified.normalization = undefined;
|
||||
modified.ricochet = undefined;
|
||||
modified.explosion_radius = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
case ShellType.SHELL_TYPE_HE: {
|
||||
modified.normalization = undefined;
|
||||
modified.ricochet = undefined;
|
||||
@@ -156,27 +174,36 @@ export function CustomShellButton() {
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
|
||||
<Select.Icon>
|
||||
<CaretDownIcon />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Portal>
|
||||
<Select.Content position="popper" sideOffset={8}>
|
||||
<Select.Content>
|
||||
<Select.Viewport>
|
||||
{[
|
||||
[ShellType.SHELL_TYPE_AP, strings.common.shells.ap],
|
||||
[
|
||||
ShellType.SHELL_TYPE_APCR,
|
||||
strings.common.shells.ap_cr,
|
||||
],
|
||||
[
|
||||
ShellType.SHELL_TYPE_HEAT,
|
||||
strings.common.shells.hc,
|
||||
],
|
||||
[ShellType.SHELL_TYPE_HE, strings.common.shells.he],
|
||||
].map(([type, label]) => (
|
||||
<Select.Item key={type} value={`${type}`}>
|
||||
<Select.ItemText>{label}</Select.ItemText>
|
||||
</Select.Item>
|
||||
))}
|
||||
{COMMON_SHELLS.map(([type, label]) => {
|
||||
return (
|
||||
<Select.Item key={type} value={`${type}`}>
|
||||
<Select.ItemIndicator>
|
||||
<DotFilledIcon />
|
||||
</Select.ItemIndicator>
|
||||
|
||||
<Select.ItemText className="TEST_CLASS">
|
||||
{strings.common.shells[label]}
|
||||
|
||||
<img
|
||||
alt={strings.common.shells[label]}
|
||||
src={alias(
|
||||
"api",
|
||||
`/icons/shells/${label}.webp`,
|
||||
)}
|
||||
/>
|
||||
</Select.ItemText>
|
||||
</Select.Item>
|
||||
);
|
||||
})}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
right: var(--space-3);
|
||||
|
||||
font-size: var(--font-size-minor);
|
||||
line-height: 1.2;
|
||||
text-align: right;
|
||||
text-shadow: 0 0 4px black;
|
||||
color: var(--white-a12);
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
|
||||
@@ -75,6 +75,14 @@
|
||||
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,
|
||||
svg {
|
||||
width: 1em;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "../DropdownMenu/index.module.css";
|
||||
|
||||
.Trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -18,45 +20,18 @@
|
||||
outline: var(--outline-focus);
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.Content {
|
||||
/* above popovers */
|
||||
z-index: 2;
|
||||
|
||||
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);
|
||||
svg,
|
||||
img {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
&: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 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);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
|
||||
html:active & {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.Track {
|
||||
@@ -35,6 +39,12 @@
|
||||
background-color: var(--gray-12);
|
||||
box-shadow: var(--shadow-subtle);
|
||||
|
||||
cursor: grab;
|
||||
|
||||
html:active & {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--outline-focus);
|
||||
box-shadow: var(--shadow-focus);
|
||||
|
||||
@@ -41,7 +41,6 @@ export const TankCard = (props: MaybeSkeletonComponentProps<TankCardProps>) => {
|
||||
: props.tank.type === TankType.TANK_TYPE_PREMIUM
|
||||
? "amber"
|
||||
: "gray";
|
||||
const lowContrast = props.tank.type !== TankType.TANK_TYPE_RESEARCHABLE;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
@@ -68,7 +67,7 @@ export const TankCard = (props: MaybeSkeletonComponentProps<TankCardProps>) => {
|
||||
justifyContent: "flex-end",
|
||||
backgroundImage: `url(${alias(
|
||||
"api",
|
||||
`/flags/scratched/${props.tank.nation}.webp`
|
||||
`/flags/scratched/${props.tank.nation}.webp`,
|
||||
)})`,
|
||||
}}
|
||||
>
|
||||
@@ -78,7 +77,7 @@ export const TankCard = (props: MaybeSkeletonComponentProps<TankCardProps>) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text className={styles.name} color={color} lowContrast={lowContrast}>
|
||||
<Text className={styles.name} color={color}>
|
||||
<ClassIcon class={props.tank.class} className={styles.icon} />
|
||||
<span className={styles.text}>{name}</span>
|
||||
</Text>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { alias, romanize, uniqueGuns } from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
import { useMemo } from "react";
|
||||
import { useAbsoluteDisplay } from "../../hooks/useAbsoluteDisplay";
|
||||
import { DuelModule, useDuel } from "../../hooks/useDuel";
|
||||
import { useEquipment } from "../../hooks/useEquipment";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
@@ -28,7 +29,9 @@ export function TankopediaAntagonistControl({
|
||||
const unwrap = useUnwrapper();
|
||||
|
||||
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(
|
||||
(state) => state.customShell !== undefined,
|
||||
);
|
||||
@@ -46,7 +49,7 @@ export function TankopediaAntagonistControl({
|
||||
[tank.turrets],
|
||||
);
|
||||
|
||||
const isVisible = showControls && display === TankopediaDisplay.DynamicArmor;
|
||||
const isVisible = showControls && isDynamicArmorDisplay;
|
||||
|
||||
return (
|
||||
<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>
|
||||
</IconButton>
|
||||
@@ -127,7 +133,10 @@ export function TankopediaAntagonistControl({
|
||||
</div>
|
||||
|
||||
<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
|
||||
parentListChild="vertical"
|
||||
color={hasCalibratedShells ? undefined : "gray"}
|
||||
@@ -153,7 +162,10 @@ export function TankopediaAntagonistControl({
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip tooltip={strings.tanks.antagonist_control.enhanced_armor} side="left">
|
||||
<Tooltip
|
||||
tooltip={strings.tanks.antagonist_control.enhanced_armor}
|
||||
side="left"
|
||||
>
|
||||
<IconButton
|
||||
parentListChild="vertical"
|
||||
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 { TankopediaAntagonistControl } from "../TankopediaAntagonistControl";
|
||||
import { TankopediaOptions } from "../TankopediaOptions";
|
||||
import { TankopediaQuickInputs } from "../TankopediaQuickInputs";
|
||||
import { TankopediaSkinSwitcher } from "../TankopediaSkinSwitcher";
|
||||
import { TankSandboxLoader } from "../TankSandboxLoader";
|
||||
import { TankStaticArmor, type ThicknessRange } from "../TankStaticArmor";
|
||||
@@ -177,6 +178,7 @@ export function TankopediaSandbox({
|
||||
|
||||
<TankSandboxLoader />
|
||||
<TankopediaSkinSwitcher />
|
||||
<TankopediaQuickInputs />
|
||||
<Thicknesses thicknessRange={thicknessRange} />
|
||||
<TankopediaAntagonistControl
|
||||
antagonistCharacteristics={antagonistCharacteristics}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PauseIcon, PlayIcon } from "@radix-ui/react-icons";
|
||||
import { uniqBy } from "lodash-es";
|
||||
import { api } from "../../api/dynamic";
|
||||
import { SKIN_NUMBER } from "../../constants/skinNumber";
|
||||
import { useAbsoluteDisplay } from "../../hooks/useAbsoluteDisplay";
|
||||
import { DuelModule, useDuel } from "../../hooks/useDuel";
|
||||
import { hasSkinAnimations } from "../../hooks/useSkin";
|
||||
import { useUnwrapper } from "../../hooks/useUnwrapper";
|
||||
@@ -21,7 +22,7 @@ export function TankopediaSkinSwitcher() {
|
||||
|
||||
const skin = Tankopedia.use((state) => state.loaders.skin.requested);
|
||||
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 loadedAttachment = Tankopedia.use(
|
||||
(state) => state.loaders.attachment.loaded,
|
||||
@@ -42,7 +43,7 @@ export function TankopediaSkinSwitcher() {
|
||||
skins.filter((camouflage) => camouflage.attachment),
|
||||
].filter((group) => group.length > 0);
|
||||
|
||||
const isVisible = showControls && display === TankopediaDisplay.Model;
|
||||
const isVisible = showControls && isModelDisplay;
|
||||
const hasAnimations =
|
||||
hasSkinAnimations(tank.id, loadedSkin) ||
|
||||
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"] {
|
||||
padding-left: var(--space-3);
|
||||
|
||||
@@ -37,6 +55,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&[data-has-suffix="true"] {
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline: var(--outline-focus);
|
||||
box-shadow: var(--shadow-focus);
|
||||
@@ -60,6 +82,7 @@
|
||||
|
||||
input {
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
flex: 1;
|
||||
|
||||
background: none;
|
||||
@@ -84,5 +107,9 @@
|
||||
&[data-align="right"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&[data-auto-width="true"] {
|
||||
field-sizing: content;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
type ChangeEvent,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { classNames } from "../../ui/classNames";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export interface TextFieldProps extends ComponentProps<"input"> {
|
||||
export interface TextFieldProps extends Omit<ComponentProps<"input">, "size"> {
|
||||
children?: ReactNode;
|
||||
align?: "left" | "center" | "right";
|
||||
autoWidth?: boolean;
|
||||
variant?: "bubble" | "form";
|
||||
size?: "regular" | "minor";
|
||||
childrenAsSuffix?: boolean;
|
||||
}
|
||||
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
@@ -27,25 +27,15 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
align,
|
||||
disabled,
|
||||
value,
|
||||
onChange,
|
||||
variant = "bubble",
|
||||
size = "regular",
|
||||
childrenAsSuffix,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
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!, []);
|
||||
|
||||
return (
|
||||
@@ -55,20 +45,21 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
data-has-icon={children !== undefined}
|
||||
data-disabled={disabled}
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-has-suffix={childrenAsSuffix}
|
||||
>
|
||||
{children}
|
||||
{!childrenAsSuffix && children}
|
||||
|
||||
<input
|
||||
data-auto-width={autoWidth}
|
||||
data-align={align === "left" ? undefined : align}
|
||||
disabled={disabled}
|
||||
ref={input}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
style={{
|
||||
width: `${value?.toString().length || placeholder?.length || 0}ch`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{childrenAsSuffix && children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { literals } from "@blitzkit/i18n/src/literals";
|
||||
import { EyeClosedIcon, EyeOpenIcon } from "@radix-ui/react-icons";
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { useAbsoluteDisplay } from "../../hooks/useAbsoluteDisplay";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { Tankopedia } from "../../stores/tankopedia";
|
||||
import { TankopediaPersistent } from "../../stores/tankopediaPersistent";
|
||||
@@ -61,7 +62,9 @@ export interface ThicknessesProps {
|
||||
|
||||
export function Thicknesses({ thicknessRange }: ThicknessesProps) {
|
||||
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 showPrimaryArmor = TankopediaPersistent.use(
|
||||
(state) => state.showPrimaryArmor,
|
||||
@@ -87,7 +90,7 @@ export function Thicknesses({ thicknessRange }: ThicknessesProps) {
|
||||
return (
|
||||
<div
|
||||
className={styles.thicknesses}
|
||||
data-visible={showControls && display === TankopediaDisplay.StaticArmor}
|
||||
data-visible={showControls && isStaticArmorDisplay}
|
||||
>
|
||||
<LayerToggle
|
||||
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