mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-14 13:16:11 +01:00
migrate compare and tankopedia loadout to new data-driven components, splitting the old monolithic loadout and compare UI into smaller pieces and adding shared Table, Dialog and Scroller primitives
This commit is contained in:
111 files changed
+4009
-3917
No files matched your search
@@ -18,9 +18,6 @@ import "@fontsource-variable/roboto-flex";
|
||||
|
||||
--page-width: 64rem;
|
||||
|
||||
/* idk how to actually get or apply this without uglifying the scrollbar */
|
||||
--scrollbar-width: 15px;
|
||||
|
||||
--max-size: max(100vw, 100vh);
|
||||
--radius-max: var(--max-size);
|
||||
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
border-radius: var(--max-size);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
|
||||
border: none;
|
||||
|
||||
font-family: var(--font-family-regular);
|
||||
font-size: var(--font-size-regular);
|
||||
color: var(--gray-1);
|
||||
font-weight: var(--weight-medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&[data-even-padding] {
|
||||
padding: var(--space-3);
|
||||
&[data-size="minor"] {
|
||||
--padding: var(--space-2);
|
||||
--content-size: calc(0.85 * var(--font-size-regular));
|
||||
}
|
||||
|
||||
&[data-size="minor"] {
|
||||
font-size: var(--font-size-minor);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
|
||||
&[data-even-padding] {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
&[data-size="regular"] {
|
||||
--padding: var(--space-3);
|
||||
--content-size: calc(1 * var(--font-size-regular));
|
||||
}
|
||||
|
||||
&[data-size="major"] {
|
||||
font-size: var(--font-size-major);
|
||||
--padding: var(--space-3);
|
||||
--content-size: calc(1.5 * var(--font-size-regular));
|
||||
}
|
||||
|
||||
padding: var(--padding) calc(2 * var(--padding));
|
||||
font-size: var(--content-size);
|
||||
|
||||
&[data-even-padding="true"] {
|
||||
padding: var(--padding);
|
||||
}
|
||||
|
||||
&[data-variant="ghost"] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&[data-list-child="horizontal"]:not(:first-child),
|
||||
@@ -65,12 +73,10 @@
|
||||
border-bottom-width: var(--space-0);
|
||||
}
|
||||
|
||||
svg,
|
||||
img {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
|
||||
object-fit: contain;
|
||||
&[data-variant="surface"],
|
||||
&[data-variant="soft"] {
|
||||
background-color: var(--accent-a3);
|
||||
color: var(--gray-12);
|
||||
}
|
||||
|
||||
&[data-light-text="true"] {
|
||||
@@ -100,11 +106,11 @@
|
||||
&[data-variant="ghost"],
|
||||
&[data-variant="outline"] {
|
||||
background: none;
|
||||
color: var(--accent-12);
|
||||
}
|
||||
color: var(--accent-11);
|
||||
|
||||
&[data-variant="ghost"] {
|
||||
padding: 0;
|
||||
&[data-high-contrast="true"] {
|
||||
color: var(--accent-12);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-variant="surface"],
|
||||
|
||||
@@ -37,7 +37,7 @@ export function Button({
|
||||
style,
|
||||
radius = "max",
|
||||
size = "regular",
|
||||
evenPadding,
|
||||
evenPadding = false,
|
||||
|
||||
listChild,
|
||||
parentListChild,
|
||||
|
||||
@@ -2,14 +2,14 @@ import { I_HAT, J_HAT } from "@blitzkit/core";
|
||||
import { OrbitControls } from "@react-three/drei";
|
||||
import { invalidate, useFrame, useThree } from "@react-three/fiber";
|
||||
import type { QuicklimeEvent } from "quicklime";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { PerspectiveCamera, Vector3 } from "three";
|
||||
import { OrbitControls as OrbitControlsClass } from "three-stdlib";
|
||||
import { api } from "../api/dynamic";
|
||||
import { applyPitchYawLimits } from "../core/blitz/applyPitchYawLimits";
|
||||
import { Pose, poseEvent } from "../core/blitzkit/pose";
|
||||
import { controlsEnabledEvent } from "../core/controlsEnabled";
|
||||
import { DuelModule, getDuel, useDuel } from "../hooks/useDuel";
|
||||
import { useDuelModels } from "../hooks/useDuelModels";
|
||||
import { getDuelModules, useDuelModules } from "../hooks/useDuelModules";
|
||||
import { hasEquipment } from "../hooks/useEquipment";
|
||||
import { Tankopedia } from "../stores/tankopedia";
|
||||
|
||||
@@ -21,8 +21,6 @@ const poseDistances: Record<Pose, number> = {
|
||||
|
||||
const inspectModeInitialPosition = new Vector3(-8, 2, -13);
|
||||
|
||||
const modelDefinitions = await api.models();
|
||||
|
||||
interface ControlsProps {
|
||||
autoRotate?: boolean;
|
||||
zoomable?: boolean;
|
||||
@@ -37,42 +35,46 @@ export function CanvasControl({
|
||||
const camera = useThree((state) => state.camera as PerspectiveCamera);
|
||||
const canvas = useThree((state) => state.gl.domElement);
|
||||
const orbitControls = useRef<OrbitControlsClass>(null);
|
||||
const protagonistTurret = useDuel("protagonist", DuelModule.Turret);
|
||||
const antagonistTurret = useDuel("antagonist", DuelModule.Turret);
|
||||
const protagonistTrack = useDuel("protagonist", DuelModule.Track);
|
||||
const protagonistTank = useDuel("protagonist", DuelModule.Tank);
|
||||
const antagonistTank = useDuel("antagonist", DuelModule.Tank);
|
||||
const protagonistGun = useDuel("protagonist", DuelModule.Gun);
|
||||
const protagonistModelDefinition =
|
||||
modelDefinitions.models[protagonistTank.id];
|
||||
const protagonistTrackModelDefinition =
|
||||
modelDefinitions.models[protagonistTank.id].tracks[protagonistTrack.id];
|
||||
const antagonistModelDefinition = modelDefinitions.models[antagonistTank.id];
|
||||
const protagonistTurretModelDefinition =
|
||||
protagonistModelDefinition.turrets[protagonistTurret.id];
|
||||
const antagonistTurretModelDefinition =
|
||||
antagonistModelDefinition.turrets[antagonistTurret.id];
|
||||
const protagonistGunModelDefinition =
|
||||
protagonistTurretModelDefinition.guns[protagonistGun.id];
|
||||
const protagonistHullOrigin = new Vector3(
|
||||
protagonistTrackModelDefinition.origin!.x,
|
||||
protagonistTrackModelDefinition.origin!.y,
|
||||
-protagonistTrackModelDefinition.origin!.z,
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
const antagonist = useDuelModules("antagonist");
|
||||
const protagonistModels = useDuelModels("protagonist");
|
||||
const antagonistModels = useDuelModels("antagonist");
|
||||
|
||||
const protagonistHullOrigin = useMemo(
|
||||
() =>
|
||||
new Vector3(
|
||||
protagonistModels.track.origin!.x,
|
||||
protagonistModels.track.origin!.y,
|
||||
-protagonistModels.track.origin!.z,
|
||||
),
|
||||
[protagonistModels.track],
|
||||
);
|
||||
const protagonistTurretOrigin = new Vector3(
|
||||
protagonistModelDefinition.turret_origin!.x,
|
||||
protagonistModelDefinition.turret_origin!.y,
|
||||
-protagonistModelDefinition.turret_origin!.z,
|
||||
const protagonistTurretOrigin = useMemo(
|
||||
() =>
|
||||
new Vector3(
|
||||
protagonistModels.tank.turret_origin!.x,
|
||||
protagonistModels.tank.turret_origin!.y,
|
||||
-protagonistModels.tank.turret_origin!.z,
|
||||
),
|
||||
[protagonistModels.tank],
|
||||
);
|
||||
const protagonistGunOrigin = new Vector3(
|
||||
protagonistTurretModelDefinition.gun_origin!.x,
|
||||
protagonistTurretModelDefinition.gun_origin!.y,
|
||||
-protagonistTurretModelDefinition.gun_origin!.z,
|
||||
const protagonistGunOrigin = useMemo(
|
||||
() =>
|
||||
new Vector3(
|
||||
protagonistModels.turret.gun_origin!.x,
|
||||
protagonistModels.turret.gun_origin!.y,
|
||||
-protagonistModels.turret.gun_origin!.z,
|
||||
),
|
||||
[protagonistModels.turret],
|
||||
);
|
||||
const antagonistGunHeight =
|
||||
protagonistTrackModelDefinition.origin!.y +
|
||||
antagonistModelDefinition.turret_origin!.y +
|
||||
antagonistTurretModelDefinition.gun_origin!.y;
|
||||
const antagonistGunHeight = useMemo(
|
||||
() =>
|
||||
protagonistModels.track.origin!.y +
|
||||
antagonistModels.tank.turret_origin!.y +
|
||||
antagonistModels.turret.gun_origin!.y,
|
||||
[protagonistModels.track, antagonistModels.tank, antagonistModels.turret],
|
||||
);
|
||||
|
||||
const disturbed = Tankopedia.use((state) => state.disturbed);
|
||||
const doAutoRotate = autoRotate && !disturbed;
|
||||
const gunHeight =
|
||||
@@ -89,16 +91,16 @@ export function CanvasControl({
|
||||
controlsEnabledEvent.on(handleControlsEnabled);
|
||||
|
||||
function handlePoseEvent(event: Pose) {
|
||||
const protagonist = getDuel("protagonist", DuelModule.Tank);
|
||||
const { tank } = getDuelModules("protagonist");
|
||||
|
||||
const hasImprovedVerticalStabilizer = hasEquipment(
|
||||
122,
|
||||
protagonist.equipment_preset,
|
||||
tank.equipment_preset,
|
||||
Tankopedia.state.protagonist.equipment,
|
||||
);
|
||||
const hasDownImprovedVerticalStabilizer = hasEquipment(
|
||||
124,
|
||||
protagonist.equipment_preset,
|
||||
tank.equipment_preset,
|
||||
Tankopedia.state.protagonist.equipment,
|
||||
);
|
||||
|
||||
@@ -107,8 +109,8 @@ export function CanvasControl({
|
||||
const [pitch] = applyPitchYawLimits(
|
||||
-Infinity,
|
||||
0,
|
||||
protagonistGunModelDefinition.pitch!,
|
||||
protagonistTurretModelDefinition.yaw,
|
||||
protagonistModels.gun.pitch!,
|
||||
protagonistModels.turret.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
hasDownImprovedVerticalStabilizer,
|
||||
);
|
||||
@@ -140,8 +142,8 @@ export function CanvasControl({
|
||||
const [pitch] = applyPitchYawLimits(
|
||||
0,
|
||||
0,
|
||||
protagonistGunModelDefinition.pitch!,
|
||||
protagonistTurretModelDefinition.yaw,
|
||||
protagonistModels.gun.pitch!,
|
||||
protagonistModels.turret.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
hasDownImprovedVerticalStabilizer,
|
||||
);
|
||||
@@ -190,7 +192,7 @@ export function CanvasControl({
|
||||
controlsEnabledEvent.off(handleControlsEnabled);
|
||||
poseEvent.off(handlePoseEvent);
|
||||
};
|
||||
}, [camera, protagonistTank.id, antagonistTank.id]);
|
||||
}, [camera, protagonist.tank, antagonist.tank]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleWheel(event: WheelEvent) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.value {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCharacteristicRenderer } from "../../hooks/useCharacteristicRenderer";
|
||||
import type {
|
||||
CharacteristicRenderConfig,
|
||||
CharacteristicReturnType,
|
||||
} from "../../types/characteristics";
|
||||
import { Text } from "../Text";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
interface CharacteristicValueProps {
|
||||
config: CharacteristicRenderConfig;
|
||||
value: CharacteristicReturnType;
|
||||
units?: boolean;
|
||||
}
|
||||
|
||||
export function CharacteristicValue({
|
||||
config,
|
||||
value,
|
||||
units = true,
|
||||
}: CharacteristicValueProps) {
|
||||
const renderCharacteristic = useCharacteristicRenderer(units);
|
||||
const hasUnits = units && config.units !== undefined;
|
||||
|
||||
return (
|
||||
<Text
|
||||
className={styles.value}
|
||||
lowContrast={hasUnits}
|
||||
size={hasUnits ? "minor" : undefined}
|
||||
>
|
||||
{renderCharacteristic(value, config)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,43 @@
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
.control {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
.Root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
box-sizing: border-box;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
accent-color: var(--accent-9);
|
||||
border: none;
|
||||
border-radius: var(--space-2);
|
||||
background-color: var(--gray-a3);
|
||||
outline: var(--space-1) solid var(--gray-a5);
|
||||
color: var(--white-a12);
|
||||
|
||||
&[data-state="checked"] {
|
||||
background-color: var(--accent-8);
|
||||
outline-color: var(--accent-8);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--outline-focus);
|
||||
}
|
||||
|
||||
.Indicator {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { CheckIcon } from "@radix-ui/react-icons";
|
||||
import { Checkbox as _Checkbox } from "radix-ui";
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { Text } from "../Text";
|
||||
import { composeRadixComponents } from "../../ui/compose";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
const _ = composeRadixComponents(_Checkbox, styles);
|
||||
|
||||
export interface CheckboxProps {
|
||||
checked: boolean;
|
||||
onCheckedChange(checked: boolean): void;
|
||||
@@ -17,13 +22,15 @@ export function Checkbox({
|
||||
|
||||
return (
|
||||
<div className={styles.checkbox}>
|
||||
<input
|
||||
className={styles.control}
|
||||
type="checkbox"
|
||||
<_.Root
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={(event) => onCheckedChange(event.target.checked)}
|
||||
/>
|
||||
onCheckedChange={(value) => onCheckedChange(value === true)}
|
||||
>
|
||||
<_.Indicator>
|
||||
<CheckIcon />
|
||||
</_.Indicator>
|
||||
</_.Root>
|
||||
|
||||
{children && (
|
||||
<label htmlFor={id}>
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
import { alias } from "@blitzkit/core";
|
||||
import { Flex, Heading, IconButton, Table } from "@radix-ui/themes";
|
||||
import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
|
||||
import { useLocale } from "../../hooks/useLocale";
|
||||
import { CompareEphemeral, syncShellSlot } from "../../stores/compareEphemeral";
|
||||
import { CompareCellDirection } from "../CompareCell";
|
||||
import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
|
||||
import { CompareRow } from "./CompareRow";
|
||||
import { CompareSectionTitle } from "./CompareSectionTitle";
|
||||
|
||||
interface BodyProps {
|
||||
stats: TankCharacteristics[];
|
||||
}
|
||||
|
||||
export function Body({ stats }: BodyProps) {
|
||||
const members = CompareEphemeral.use((state) => state.members);
|
||||
const hasNonRegularGun = members.some(
|
||||
({ gun }) => gun.gun_type!.$case !== "regular",
|
||||
);
|
||||
const { unwrap, strings } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<StickyColumnHeaderCell top={137} style={{ left: 0, zIndex: 2 }}>
|
||||
<Flex height="100%" align="center">
|
||||
<Heading size="4">Firepower</Heading>
|
||||
</Flex>
|
||||
</StickyColumnHeaderCell>
|
||||
|
||||
{members.map(({ gun, shell, key }, index) => (
|
||||
<StickyColumnHeaderCell key={key} top={137}>
|
||||
<Flex justify="center">
|
||||
<Flex>
|
||||
{gun.shells.map((thisShell, shellIndex) => (
|
||||
<IconButton
|
||||
color={thisShell.id === shell.id ? undefined : "gray"}
|
||||
variant="soft"
|
||||
key={thisShell.id}
|
||||
style={{
|
||||
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
|
||||
borderBottomLeftRadius:
|
||||
shellIndex === 0 ? undefined : 0,
|
||||
borderTopRightRadius:
|
||||
shellIndex === gun.shells.length - 1 ? undefined : 0,
|
||||
borderBottomRightRadius:
|
||||
shellIndex === gun.shells.length - 1 ? undefined : 0,
|
||||
marginLeft: shellIndex === 0 ? 0 : -1,
|
||||
}}
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
if (draft.syncShells) {
|
||||
syncShellSlot(draft.members, shellIndex);
|
||||
} else {
|
||||
draft.members[index].shell = thisShell;
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={unwrap(thisShell.name!)}
|
||||
width={16}
|
||||
height={16}
|
||||
src={alias(
|
||||
"api",
|
||||
`/icons/shells/${thisShell.icon}.webp`,
|
||||
)}
|
||||
/>
|
||||
</IconButton>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</StickyColumnHeaderCell>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
<CompareRow stats={stats} value="dpm" decimals={0} />
|
||||
<CompareRow stats={stats} value="damage" decimals={0} />
|
||||
{hasNonRegularGun && (
|
||||
<CompareRow stats={stats} indent value="clipDamage" decimals={0} />
|
||||
)}
|
||||
<CompareRow stats={stats} value="moduleDamage" decimals={0} />
|
||||
<CompareRow stats={stats} value="penetration" decimals={0} />
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name={strings.website.tools.tankopedia.characteristics.values.reload}
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
value={(stats) =>
|
||||
stats.shellReload ??
|
||||
stats.shellReloads!.reduce((a, b) => a + b, 0) /
|
||||
stats.shellReloads!.length
|
||||
}
|
||||
display={(stats) =>
|
||||
stats.shellReload?.toFixed(2) ??
|
||||
stats.shellReloads!.map((reload) => reload.toFixed(2)).join(", ")
|
||||
}
|
||||
deltaNominalDisplay={(delta) => delta.toFixed(2)}
|
||||
/>
|
||||
{hasNonRegularGun && (
|
||||
<>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
value="intraClip"
|
||||
decimals={2}
|
||||
/>
|
||||
<CompareRow stats={stats} value="shells" decimals={0} />
|
||||
</>
|
||||
)}
|
||||
<CompareRow stats={stats} value="caliber" decimals={0} />
|
||||
<CompareRow stats={stats} value="shellNormalization" decimals={0} />
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
value="shellRicochet"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow stats={stats} value="shellVelocity" decimals={0} />
|
||||
<CompareRow stats={stats} value="shellRange" decimals={0} />
|
||||
<CompareRow stats={stats} value="shellCapacity" decimals={0} />
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
value="aimTime"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={2}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
value="dispersion"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={3}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="dispersionMoving"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={3}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="dispersionHullTraversing"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={3}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="dispersionTurretTraversing"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={3}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="dispersionShooting"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={3}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="dispersionGunDamaged"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={3}
|
||||
/>
|
||||
<CompareRow stats={stats} value="gunDepression" decimals={1} />
|
||||
<CompareRow stats={stats} indent value="gunElevation" decimals={1} />
|
||||
</Table.Body>
|
||||
|
||||
<CompareSectionTitle>Maneuverability</CompareSectionTitle>
|
||||
<Table.Body>
|
||||
<CompareRow stats={stats} value="speedForwards" decimals={0} />
|
||||
<CompareRow stats={stats} indent value="speedBackwards" decimals={0} />
|
||||
<CompareRow stats={stats} value="enginePower" decimals={0} />
|
||||
<CompareRow stats={stats} value="weight" decimals={1} />
|
||||
<CompareRow stats={stats} value="hardTerrainCoefficient" decimals={0} />
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="mediumTerrainCoefficient"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="softTerrainCoefficient"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
value="hardTerrainCoefficientRaw"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={2}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="mediumTerrainCoefficientRaw"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={2}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="softTerrainCoefficientRaw"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={2}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
value="powerToWeightRatioHardTerrain"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="powerToWeightRatioMediumTerrain"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="powerToWeightRatioSoftTerrain"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow stats={stats} value="turretTraverseSpeed" decimals={1} />
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
value="hullTraverseHardTerrain"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="hullTraverseMediumTerrain"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="hullTraverseSoftTerrain"
|
||||
decimals={1}
|
||||
/>
|
||||
</Table.Body>
|
||||
|
||||
<CompareSectionTitle>Survivability</CompareSectionTitle>
|
||||
<Table.Body>
|
||||
<CompareRow stats={stats} value="health" decimals={0} />
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
value="fireChance"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
display={(stats) => (stats.fireChance * 100).toFixed(0)}
|
||||
deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
|
||||
/>
|
||||
<CompareRow stats={stats} value="viewRange" decimals={0} />
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
value="camouflageStill"
|
||||
display={(stats) => (stats.camouflageStill * 100).toFixed(2)}
|
||||
deltaNominalDisplay={(delta) => (delta * 100).toFixed(2)}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="camouflageMoving"
|
||||
display={(stats) => (stats.camouflageMoving * 100).toFixed(2)}
|
||||
deltaNominalDisplay={(delta) => (delta * 100).toFixed(2)}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="camouflageShootingStill"
|
||||
display={(stats) => (stats.camouflageShootingStill * 100).toFixed(2)}
|
||||
deltaNominalDisplay={(delta) => (delta * 100).toFixed(2)}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="camouflageShootingMoving"
|
||||
display={(stats) => (stats.camouflageShootingMoving * 100).toFixed(2)}
|
||||
deltaNominalDisplay={(delta) => (delta * 100).toFixed(2)}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="camouflageCaughtOnFire"
|
||||
display={(stats) => (stats.camouflageCaughtOnFire * 100).toFixed(2)}
|
||||
deltaNominalDisplay={(delta) => (delta * 100).toFixed(2)}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
value="volume"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="width"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="height"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
indent
|
||||
value="length"
|
||||
deltaType={CompareCellDirection.LOWER_IS_BETTER}
|
||||
decimals={1}
|
||||
/>
|
||||
</Table.Body>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import {
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
CaretSortIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { Flex, IconButton, Table, Text } from "@radix-ui/themes";
|
||||
import { useRef } from "react";
|
||||
import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
|
||||
import { useLocale } from "../../hooks/useLocale";
|
||||
import { CompareEphemeral } from "../../stores/compareEphemeral";
|
||||
import { ComparePersistent } from "../../stores/comparePersistent";
|
||||
import { CompareCell, CompareCellDirection } from "../CompareCell";
|
||||
import { StickyRowHeaderCell } from "../StickyRowHeaderCell";
|
||||
|
||||
type CompareRowProps = {
|
||||
display?: (
|
||||
member: Awaited<TankCharacteristics>,
|
||||
) => number | string | undefined;
|
||||
deltaType?: CompareCellDirection;
|
||||
decimals?: number;
|
||||
deltaNominalDisplay?: (delta: number) => string | number;
|
||||
indent?: boolean;
|
||||
stats: TankCharacteristics[];
|
||||
} & (
|
||||
| {
|
||||
name: string;
|
||||
value: (member: TankCharacteristics) => number | undefined;
|
||||
}
|
||||
| {
|
||||
value: keyof TankCharacteristics;
|
||||
}
|
||||
);
|
||||
|
||||
export function CompareRow({
|
||||
display,
|
||||
deltaType = CompareCellDirection.HIGHER_IS_BETTER,
|
||||
decimals,
|
||||
deltaNominalDisplay,
|
||||
indent,
|
||||
stats,
|
||||
...props
|
||||
}: CompareRowProps) {
|
||||
const { strings } = useLocale();
|
||||
const sorting = CompareEphemeral.use((state) => state.sorting);
|
||||
const deltaMode = ComparePersistent.use((state) => state.deltaMode);
|
||||
const values = stats.map((stat) =>
|
||||
typeof props.value === "function"
|
||||
? props.value(stat)!
|
||||
: (stat[props.value] as number),
|
||||
);
|
||||
const id = useRef(Math.random());
|
||||
const name =
|
||||
typeof props.value === "string"
|
||||
? strings.website.tools.tankopedia.characteristics.values[props.value]
|
||||
: props.name;
|
||||
|
||||
return (
|
||||
<Table.Row>
|
||||
<StickyRowHeaderCell left="-1.25rem">
|
||||
<Flex
|
||||
align="center"
|
||||
maxWidth={{ initial: "6rem", sm: "unset" }}
|
||||
gap={indent ? { initial: "4", sm: "6" } : { initial: "1", sm: "2" }}
|
||||
>
|
||||
<IconButton
|
||||
color={sorting?.by === id.current ? undefined : "gray"}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members.sort((memberA, memberB) => {
|
||||
const indexA = draft.members.indexOf(memberA);
|
||||
const indexB = draft.members.indexOf(memberB);
|
||||
const valueA = values[indexA];
|
||||
const valueB = values[indexB];
|
||||
|
||||
return draft.sorting?.direction === "ascending" &&
|
||||
draft.sorting.by === id.current
|
||||
? valueA - valueB
|
||||
: valueB - valueA;
|
||||
});
|
||||
|
||||
draft.sorting = {
|
||||
by: id.current,
|
||||
direction:
|
||||
draft.sorting?.direction === "ascending" &&
|
||||
draft.sorting.by === id.current
|
||||
? "descending"
|
||||
: "ascending",
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(sorting === undefined || sorting.by !== id.current) && (
|
||||
<CaretSortIcon style={{ transform: "rotate(90deg)" }} />
|
||||
)}
|
||||
{sorting?.by === id.current && (
|
||||
<>
|
||||
{sorting.direction === "ascending" && <CaretRightIcon />}
|
||||
{sorting.direction === "descending" && <CaretLeftIcon />}
|
||||
</>
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
<Text size={{ initial: "1", sm: "2" }}>{name}</Text>
|
||||
</Flex>
|
||||
</StickyRowHeaderCell>
|
||||
|
||||
{values.map((value, index) => {
|
||||
const delta = value - values[0];
|
||||
const deltaPercentage = value / values[0] - 1;
|
||||
const resolvedDisplayValue = display
|
||||
? display(stats[index])
|
||||
: decimals === undefined
|
||||
? value
|
||||
: value?.toFixed(decimals);
|
||||
|
||||
return (
|
||||
<CompareCell
|
||||
key={index}
|
||||
truth={values[0]}
|
||||
value={value}
|
||||
direction={deltaType}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="2"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<Text style={{ textAlign: "center" }} wrap="nowrap">
|
||||
{resolvedDisplayValue}
|
||||
</Text>
|
||||
|
||||
{delta !== 0 &&
|
||||
resolvedDisplayValue !== undefined &&
|
||||
values[0] !== undefined && (
|
||||
<>
|
||||
{deltaMode === "absolute" && (
|
||||
<Text color="gray">
|
||||
(
|
||||
{`${delta > 0 ? "+" : ""}${
|
||||
deltaNominalDisplay
|
||||
? deltaNominalDisplay(delta)
|
||||
: decimals === undefined
|
||||
? delta
|
||||
: delta.toFixed(decimals)
|
||||
}`}
|
||||
)
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{deltaMode === "percentage" && (
|
||||
<Text color="gray">
|
||||
(
|
||||
{`${deltaPercentage > 0 ? "+" : ""}${Math.round(
|
||||
deltaPercentage * 100,
|
||||
)}%`}
|
||||
)
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</CompareCell>
|
||||
);
|
||||
})}
|
||||
</Table.Row>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Heading, Table } from "@radix-ui/themes";
|
||||
import { times } from "lodash-es";
|
||||
import { CompareEphemeral } from "../../stores/compareEphemeral";
|
||||
import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
|
||||
|
||||
export function CompareSectionTitle({ children }: { children: string }) {
|
||||
const memberCount = CompareEphemeral.use((state) => state.members.length);
|
||||
|
||||
return (
|
||||
<Table.Header style={{ height: 48 }}>
|
||||
<Table.Row align="center">
|
||||
<StickyColumnHeaderCell top={137} style={{ left: 0, zIndex: 2 }}>
|
||||
<Heading size="4">{children}</Heading>
|
||||
</StickyColumnHeaderCell>
|
||||
|
||||
{times(memberCount, (index) => (
|
||||
<StickyColumnHeaderCell key={index} top={137} />
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
);
|
||||
}
|
||||
@@ -1,654 +0,0 @@
|
||||
import {
|
||||
alias,
|
||||
availableProvisions,
|
||||
MAX_SKILL_LEVEL,
|
||||
permanentSkills,
|
||||
} from "@blitzkit/core";
|
||||
import { checkConsumableProvisionInclusivity } from "@blitzkit/core/src/blitzkit/checkConsumableProvisionInclusivity";
|
||||
import { ComponentPlaceholderIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
IconButton,
|
||||
Popover,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
} from "@radix-ui/themes";
|
||||
import { times } from "lodash-es";
|
||||
import { api } from "../../api/dynamic";
|
||||
import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
|
||||
import { Var } from "../../core/radix/var";
|
||||
import { useLocale } from "../../hooks/useLocale";
|
||||
import { CompareEphemeral } from "../../stores/compareEphemeral";
|
||||
import type { EquipmentMatrix } from "../../stores/duel";
|
||||
import { BlitzkitButtonGrayIcon } from "../BlitzkitButtonGrayIcon";
|
||||
import { CompareTankCard } from "../CompareTankCard";
|
||||
import { ConsumablesManager } from "../ConsumablesManager";
|
||||
import { CrewSkillManager } from "../CrewSkillManager";
|
||||
import { EquipmentManager } from "../EquipmentManager";
|
||||
import { ModuleManager } from "../ModuleManager";
|
||||
import { ProvisionsManager } from "../ProvisionsManager";
|
||||
import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
|
||||
import { StickyTableRoot } from "../StickyTableRoot";
|
||||
import { Body } from "./Body";
|
||||
import { InsertionMarker } from "./IntersectionMarker";
|
||||
|
||||
interface CompareTableProps {
|
||||
stats: TankCharacteristics[];
|
||||
}
|
||||
|
||||
const [
|
||||
skillDefinitions,
|
||||
equipmentDefinitions,
|
||||
consumableDefinitions,
|
||||
provisionDefinitions,
|
||||
] = await Promise.all([
|
||||
api.skills(),
|
||||
api.equipment(),
|
||||
api.consumables(),
|
||||
api.provisions(),
|
||||
]);
|
||||
|
||||
export function CompareTable({ stats }: CompareTableProps) {
|
||||
const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
|
||||
const members = CompareEphemeral.use((state) => state.members);
|
||||
const { unwrap, strings } = useLocale();
|
||||
const equalize = CompareEphemeral.use((state) => state.equalize);
|
||||
|
||||
return (
|
||||
<StickyTableRoot
|
||||
size="1"
|
||||
variant="surface"
|
||||
style={{
|
||||
position: "absolute",
|
||||
maxWidth: "100%",
|
||||
height: "100%",
|
||||
borderBottomRightRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
}}
|
||||
>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<StickyColumnHeaderCell width="0" style={{ height: "100%" }}>
|
||||
<Flex
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<BlitzkitButtonGrayIcon opacity="0.25" width={64} height={64} />
|
||||
<InsertionMarker index={0} />
|
||||
</Flex>
|
||||
</StickyColumnHeaderCell>
|
||||
|
||||
{members.map(({ tank, key }, index) => {
|
||||
return <CompareTankCard index={index} key={key} tank={tank} />;
|
||||
})}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
<Table.Row>
|
||||
<Table.Cell style={{ height: "100%" }}>
|
||||
<Flex
|
||||
gap="2"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="ghost" radius="large">
|
||||
<Flex direction="column" style={{ gap: 2 }}>
|
||||
{Object.entries(skillDefinitions.classes).map(
|
||||
([tankClass, skills]) => (
|
||||
<Flex key={tankClass} style={{ gap: 2 }}>
|
||||
{skills.skills.map((skill) => (
|
||||
<div
|
||||
key={skill}
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 2,
|
||||
backgroundColor:
|
||||
crewSkills[skill] === 0
|
||||
? Var("gray-11")
|
||||
: permanentSkills.includes(skill)
|
||||
? Var("crimson-11")
|
||||
: Var("amber-11"),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
),
|
||||
)}
|
||||
</Flex>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<CrewSkillManager
|
||||
skillLevels={crewSkills}
|
||||
onChange={(skills) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.crewSkills = skills;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex justify="end" mt="4" gap="4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
Object.keys(draft.crewSkills).forEach((skill) => {
|
||||
draft.crewSkills[skill] = 0;
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.website.tools.compare.table.skills.clear}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
Object.keys(draft.crewSkills).forEach((skill) => {
|
||||
draft.crewSkills[skill] = MAX_SKILL_LEVEL;
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.website.tools.compare.table.skills.maximize}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Flex align="center" gap="1">
|
||||
<Text>
|
||||
{strings.website.tools.compare.table.skills.equalize}
|
||||
</Text>
|
||||
<Switch
|
||||
size="1"
|
||||
checked={equalize}
|
||||
onCheckedChange={(checked) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.equalize = checked;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
|
||||
{members.map(
|
||||
(
|
||||
{
|
||||
equipmentMatrix,
|
||||
tank,
|
||||
key,
|
||||
provisions,
|
||||
gun,
|
||||
shell,
|
||||
turret,
|
||||
engine,
|
||||
track,
|
||||
consumables,
|
||||
camouflage,
|
||||
},
|
||||
index,
|
||||
) => {
|
||||
const equipmentPreset =
|
||||
equipmentDefinitions.presets[tank.equipment_preset];
|
||||
const provisionsList = availableProvisions(
|
||||
tank,
|
||||
gun,
|
||||
provisionDefinitions,
|
||||
);
|
||||
const consumablesList = Object.values(
|
||||
consumableDefinitions.consumables,
|
||||
).filter((consumable) =>
|
||||
checkConsumableProvisionInclusivity(consumable, tank, gun),
|
||||
);
|
||||
|
||||
return (
|
||||
<Table.Cell key={key}>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
direction="column"
|
||||
gap="2"
|
||||
>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="ghost">
|
||||
<Flex align="center">
|
||||
{(
|
||||
["turret", "gun", "engine", "chassis"] as const
|
||||
).map((module, index) => (
|
||||
<img
|
||||
key={module}
|
||||
alt={module}
|
||||
width={24}
|
||||
height={24}
|
||||
src={alias(
|
||||
"api",
|
||||
`/icons/modules/${module}.webp`,
|
||||
)}
|
||||
style={{
|
||||
marginLeft: index > 0 ? -8 : 0,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<ModuleManager
|
||||
tank={tank}
|
||||
turret={turret}
|
||||
gun={gun}
|
||||
shell={shell}
|
||||
engine={engine}
|
||||
track={track}
|
||||
onChange={(modules) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index] = {
|
||||
...draft.members[index],
|
||||
...modules,
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex justify="end" mt="4" gap="4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
const member = draft.members[index];
|
||||
|
||||
member.turret = member.tank.turrets[0];
|
||||
member.gun = member.turret.guns[0];
|
||||
member.shell = member.gun.shells[0];
|
||||
member.engine = member.tank.engines[0];
|
||||
member.track = member.tank.tracks[0];
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.website.tools.compare.table.modules.stock}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
const member = draft.members[index];
|
||||
|
||||
member.turret = member.tank.turrets.at(-1)!;
|
||||
member.gun = member.turret.guns.at(-1)!;
|
||||
member.shell = member.gun.shells[0];
|
||||
member.engine = member.tank.engines.at(-1)!;
|
||||
member.track = member.tank.tracks.at(-1)!;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{
|
||||
strings.website.tools.compare.table.modules
|
||||
.upgrade
|
||||
}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Flex justify="center" gap="3">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
radius="large"
|
||||
style={{
|
||||
height: "2rem",
|
||||
width: 12,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" justify="center">
|
||||
{provisions.map((provision, index) => (
|
||||
<img
|
||||
alt={unwrap(
|
||||
provisionDefinitions.provisions[provision]
|
||||
.name!,
|
||||
)}
|
||||
key={provision}
|
||||
src={alias(
|
||||
"api",
|
||||
`//icons/provisions/${provision}.webp`,
|
||||
)}
|
||||
style={{
|
||||
left: "50%",
|
||||
/**
|
||||
* max = 100% - 16px - 4px
|
||||
* min = 4px
|
||||
* diff = max - min
|
||||
* = 100% - 16px - 4px - 4px
|
||||
* = 100% - 24px
|
||||
* coefficient = index / length - 1
|
||||
* position = min + coefficient * diff
|
||||
*/
|
||||
top: `calc(4px + ${
|
||||
provisions.length === 1
|
||||
? 0.5
|
||||
: index / (provisions.length - 1)
|
||||
} * (100% - 24px))`,
|
||||
transform: "translateX(-50%)",
|
||||
position: "absolute",
|
||||
width: 16,
|
||||
height: 16,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{provisions.length === 0 && (
|
||||
<ComponentPlaceholderIcon />
|
||||
)}
|
||||
</Flex>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<ProvisionsManager
|
||||
provisions={provisionsList.map(
|
||||
(provision) => provision.id,
|
||||
)}
|
||||
selected={provisions}
|
||||
disabled={tank.max_provisions === provisions.length}
|
||||
onChange={(provisions) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index].provisions = provisions;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex justify="end" mt="4" gap="3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members.map((member) => {
|
||||
member.provisions = [];
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{
|
||||
strings.website.tools.compare.table.provisions
|
||||
.clear_all
|
||||
}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index].provisions = [];
|
||||
});
|
||||
}}
|
||||
>
|
||||
{
|
||||
strings.website.tools.compare.table.provisions
|
||||
.clear
|
||||
}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="ghost" radius="large">
|
||||
<Flex gap="1" direction="column">
|
||||
{times(3, (y) => (
|
||||
<Flex
|
||||
key={y}
|
||||
style={{
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{times(3, (x) => {
|
||||
const equipment = equipmentMatrix[y][x];
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={x}
|
||||
style={{
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 4,
|
||||
height: 8,
|
||||
backgroundColor:
|
||||
equipment === -1
|
||||
? Var("crimson-11")
|
||||
: Var("gray-11"),
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 4,
|
||||
height: 8,
|
||||
backgroundColor:
|
||||
equipment === 1
|
||||
? Var("crimson-11")
|
||||
: Var("gray-11"),
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<EquipmentManager
|
||||
matrix={equipmentMatrix}
|
||||
preset={equipmentPreset}
|
||||
onChange={(matrix) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index].equipmentMatrix = matrix;
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex justify="end" gap="4" mt="4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index].equipmentMatrix = times(
|
||||
3,
|
||||
() => times(3, () => 0),
|
||||
) as EquipmentMatrix;
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{
|
||||
strings.website.tools.compare.table.equipment
|
||||
.clear
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members.forEach((member) => {
|
||||
member.equipmentMatrix =
|
||||
draft.members[index].equipmentMatrix;
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{
|
||||
strings.website.tools.compare.table.equipment
|
||||
.apply_to_all
|
||||
}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
radius="large"
|
||||
style={{
|
||||
height: "2rem",
|
||||
width: 12,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" justify="center">
|
||||
{consumables.map((consumable, index) => (
|
||||
<img
|
||||
alt={unwrap(
|
||||
consumableDefinitions.consumables[
|
||||
consumable
|
||||
].name!,
|
||||
)}
|
||||
key={consumable}
|
||||
src={alias(
|
||||
"api",
|
||||
`/icons/consumables/${consumable}.webp`,
|
||||
)}
|
||||
style={{
|
||||
left: "50%",
|
||||
/**
|
||||
* max = 100% - 16px - 4px
|
||||
* min = 4px
|
||||
* diff = max - min
|
||||
* = 100% - 16px - 4px - 4px
|
||||
* = 100% - 24px
|
||||
* coefficient = index / length - 1
|
||||
* position = min + coefficient * diff
|
||||
*/
|
||||
top: `calc(4px + ${
|
||||
consumables.length === 1
|
||||
? 0.5
|
||||
: index / (consumables.length - 1)
|
||||
} * (100% - 24px))`,
|
||||
transform: "translateX(-50%)",
|
||||
position: "absolute",
|
||||
width: 16,
|
||||
height: 16,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{consumables.length === 0 && (
|
||||
<ComponentPlaceholderIcon />
|
||||
)}
|
||||
</Flex>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<ConsumablesManager
|
||||
consumables={consumablesList}
|
||||
selected={consumables}
|
||||
onConsumablesChange={(consumables) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index].consumables = consumables;
|
||||
});
|
||||
}}
|
||||
disabled={
|
||||
tank.max_consumables === consumables.length
|
||||
}
|
||||
/>
|
||||
|
||||
<Flex justify="end" mt="4" gap="4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members.map((member) => {
|
||||
member.consumables = [];
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{
|
||||
strings.website.tools.compare.table.consumables
|
||||
.clear_all
|
||||
}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index].consumables = [];
|
||||
});
|
||||
}}
|
||||
>
|
||||
{
|
||||
strings.website.tools.compare.table.consumables
|
||||
.clear
|
||||
}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Flex>
|
||||
|
||||
{!tank.fixed_camouflage && (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index].camouflage = !camouflage;
|
||||
});
|
||||
}}
|
||||
variant={camouflage ? "solid" : "outline"}
|
||||
>
|
||||
<img
|
||||
src={alias("api", `/icons/camo.webp`)}
|
||||
style={{
|
||||
width: "1em",
|
||||
height: "1em",
|
||||
transform: "scale(2) translate(15%, 15%)",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
|
||||
<Body stats={stats} />
|
||||
</StickyTableRoot>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { PlusIcon, TrashIcon } from "@radix-ui/react-icons";
|
||||
import { Button, Dialog, Flex, SegmentedControl } from "@radix-ui/themes";
|
||||
import { api } from "../../api/dynamic";
|
||||
import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
|
||||
import { useLocale } from "../../hooks/useLocale";
|
||||
import { CompareEphemeral, syncShellSlot } from "../../stores/compareEphemeral";
|
||||
import {
|
||||
ComparePersistent,
|
||||
type DeltaMode,
|
||||
} from "../../stores/comparePersistent";
|
||||
import { TankSearch } from "../TankSearch";
|
||||
|
||||
interface ControlsProps {
|
||||
addTankDialogOpen: boolean;
|
||||
onAddTankDialogOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const provisionDefinitions = await api.provisions();
|
||||
|
||||
export function Controls({
|
||||
addTankDialogOpen,
|
||||
onAddTankDialogOpenChange,
|
||||
}: ControlsProps) {
|
||||
const deltaMode = ComparePersistent.use((state) => state.deltaMode);
|
||||
const syncShells = CompareEphemeral.use((state) => state.syncShells);
|
||||
const { strings } = useLocale();
|
||||
|
||||
return (
|
||||
<Flex gap="2" wrap="wrap" justify="center">
|
||||
<Dialog.Root
|
||||
open={addTankDialogOpen}
|
||||
onOpenChange={onAddTankDialogOpenChange}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="soft">
|
||||
<PlusIcon /> {strings.website.tools.compare.actions.add.button}
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content maxHeight={DIALOG_MAX_HEIGHT}>
|
||||
<Dialog.Title align="center">
|
||||
{strings.website.tools.compare.actions.add.title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description align="center" mb="4">
|
||||
{strings.website.tools.compare.actions.add.description}
|
||||
</Dialog.Description>
|
||||
|
||||
<Flex gap="4" direction="column">
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="4"
|
||||
style={{ flex: 1 }}
|
||||
justify="center"
|
||||
>
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members.push(
|
||||
tankToCompareMember(tank, provisionDefinitions),
|
||||
);
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
onAddTankDialogOpenChange(false);
|
||||
}}
|
||||
onSelectAll={(tanks) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members.push(
|
||||
...tanks.map((tank) => {
|
||||
return tankToCompareMember(tank, provisionDefinitions);
|
||||
}),
|
||||
);
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
onAddTankDialogOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Button
|
||||
variant="soft"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members = [];
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TrashIcon /> {strings.website.tools.compare.actions.clear}
|
||||
</Button>
|
||||
|
||||
<SegmentedControl.Root
|
||||
variant="classic"
|
||||
value={deltaMode}
|
||||
onValueChange={(value) => {
|
||||
ComparePersistent.mutate((draft) => {
|
||||
draft.deltaMode = value as DeltaMode;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SegmentedControl.Item value={"none" satisfies DeltaMode}>
|
||||
{strings.website.tools.compare.actions.deltas.none}
|
||||
</SegmentedControl.Item>
|
||||
<SegmentedControl.Item value={"percentage" satisfies DeltaMode}>
|
||||
{strings.website.tools.compare.actions.deltas.percentage}
|
||||
</SegmentedControl.Item>
|
||||
<SegmentedControl.Item value={"absolute" satisfies DeltaMode}>
|
||||
{strings.website.tools.compare.actions.deltas.absolute}
|
||||
</SegmentedControl.Item>
|
||||
</SegmentedControl.Root>
|
||||
|
||||
<Flex
|
||||
align="center"
|
||||
gap="2"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.syncShells = !draft.syncShells;
|
||||
|
||||
if (!draft.syncShells || draft.members.length === 0) return;
|
||||
|
||||
const [first] = draft.members;
|
||||
|
||||
syncShellSlot(
|
||||
draft.members,
|
||||
first.gun.shells.findIndex(({ id }) => id === first.shell.id),
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Checkbox variant="classic" checked={syncShells} />
|
||||
<Text size="2">
|
||||
{strings.website.tools.compare.actions.sync_shells}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const insertionMarkers: { element: HTMLDivElement; index: number }[] =
|
||||
[];
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Var } from '../../../core/radix/var';
|
||||
import { insertionMarkers } from './constants';
|
||||
|
||||
export function InsertionMarker({ index }: { index: number }) {
|
||||
const marker = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
insertionMarkers.push({
|
||||
element: marker.current!,
|
||||
index,
|
||||
});
|
||||
|
||||
return () => {
|
||||
insertionMarkers.splice(
|
||||
insertionMarkers.findIndex(
|
||||
(insertionMarker) => insertionMarker.element === marker.current!,
|
||||
),
|
||||
1,
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={marker}
|
||||
style={{
|
||||
height: '75%',
|
||||
width: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: Var('crimson-11'),
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { ExternalLinkIcon, LoopIcon, TrashIcon } from "@radix-ui/react-icons";
|
||||
import { Dialog, Flex, IconButton } from "@radix-ui/themes";
|
||||
import { useState } from "react";
|
||||
import { api } from "../../api/dynamic";
|
||||
import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
|
||||
import { useLocale } from "../../hooks/useLocale";
|
||||
import { CompareEphemeral } from "../../stores/compareEphemeral";
|
||||
import { LinkI18n } from "../LinkI18n";
|
||||
import { TankSearch } from "../TankSearch";
|
||||
|
||||
interface TankControlProps {
|
||||
index: number;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const provisionDefinitions = await api.provisions();
|
||||
|
||||
export function TankControl({ index, slug }: TankControlProps) {
|
||||
const { locale, strings } = useLocale();
|
||||
const [switchTankDialogOpen, setSwitchTankDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Flex gap="3" justify="center" style={{ width: "100%" }}>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members.splice(index, 1);
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
|
||||
<Dialog.Root
|
||||
open={switchTankDialogOpen}
|
||||
onOpenChange={setSwitchTankDialogOpen}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<IconButton variant="ghost">
|
||||
<LoopIcon />
|
||||
</IconButton>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content maxHeight={DIALOG_MAX_HEIGHT}>
|
||||
<Dialog.Title align="center">
|
||||
{strings.website.common.tank_search.swap_dialog_title}
|
||||
</Dialog.Title>
|
||||
|
||||
<Flex gap="4" direction="column">
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="4"
|
||||
style={{ flex: 1 }}
|
||||
justify="center"
|
||||
>
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index] = tankToCompareMember(
|
||||
tank,
|
||||
provisionDefinitions,
|
||||
);
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
setSwitchTankDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<LinkI18n locale={locale} href={`/tanks/${slug}`} target="_blank">
|
||||
<IconButton variant="ghost">
|
||||
<ExternalLinkIcon />
|
||||
</IconButton>
|
||||
</LinkI18n>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { PlusIcon, TrashIcon } from "@radix-ui/react-icons";
|
||||
import { useMemo, useState } from "react";
|
||||
import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { CompareEphemeral, syncShellSlot } from "../../stores/compareEphemeral";
|
||||
import {
|
||||
ComparePersistent,
|
||||
type DeltaMode,
|
||||
} from "../../stores/comparePersistent";
|
||||
import { resolveTankModules } from "../../tankopedia/resolveTankModules";
|
||||
import { Button } from "../Button";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { Dialog } from "../Dialog";
|
||||
import { SegmentedControl } from "../SegmentedControl";
|
||||
import { TankSearch } from "../TankSearch";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export function CompareControls() {
|
||||
const strings = useStrings();
|
||||
const deltaMode = ComparePersistent.use((state) => state.deltaMode);
|
||||
const syncShells = CompareEphemeral.use((state) => state.syncShells);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const deltaOptions = useMemo<{ value: DeltaMode; label: string }[]>(
|
||||
() => [
|
||||
{ value: "none", label: strings.compare.deltas.none },
|
||||
{ value: "percentage", label: strings.compare.deltas.percentage },
|
||||
{ value: "absolute", label: strings.compare.deltas.absolute },
|
||||
],
|
||||
[strings],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.group}>
|
||||
<Dialog.Root open={adding} onOpenChange={setAdding}>
|
||||
<Dialog.Trigger asChild>
|
||||
<Button variant="soft">
|
||||
<PlusIcon />
|
||||
{strings.compare.add.button}
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay />
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{strings.compare.add.title}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{strings.compare.add.description}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Body>
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members.push(tankToCompareMember(tank));
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
setAdding(false);
|
||||
}}
|
||||
onSelectAll={(tanks) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members.push(...tanks.map(tankToCompareMember));
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
setAdding(false);
|
||||
}}
|
||||
/>
|
||||
</Dialog.Body>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<Button
|
||||
variant="soft"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members = [];
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
{strings.compare.clear}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SegmentedControl
|
||||
options={deltaOptions}
|
||||
value={deltaMode}
|
||||
onValueChange={(value) => {
|
||||
ComparePersistent.mutate((draft) => {
|
||||
draft.deltaMode = value as DeltaMode;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
checked={syncShells}
|
||||
onCheckedChange={(checked) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.syncShells = checked;
|
||||
|
||||
if (!checked || draft.members.length === 0) return;
|
||||
|
||||
const [first] = draft.members;
|
||||
const { gun, shell } = resolveTankModules(first.state);
|
||||
|
||||
syncShellSlot(
|
||||
draft.members,
|
||||
gun.shells.findIndex(({ id }) => id === shell.id),
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.compare.sync_shells}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
|
||||
padding-bottom: var(--space-3);
|
||||
|
||||
.dots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
.dot-row {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: var(--space-3);
|
||||
height: var(--space-3);
|
||||
border-radius: var(--space-1);
|
||||
background-color: var(--gray-9);
|
||||
|
||||
&[data-state="permanent"] {
|
||||
background-color: var(--accent-11);
|
||||
}
|
||||
|
||||
&[data-state="temporary"] {
|
||||
background-color: var(--amber-11);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { MAX_SKILL_LEVEL, permanentSkills } from "@blitzkit/core";
|
||||
import { api } from "../../api/dynamic";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { CompareEphemeral } from "../../stores/compareEphemeral";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { LoadoutSkills } from "../LoadoutSkills";
|
||||
import { Popover } from "../Popover";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
const skills = await api.skills();
|
||||
|
||||
export function CompareEnvironment() {
|
||||
const strings = useStrings();
|
||||
const selected = CompareEphemeral.use((state) => state.environment.skills);
|
||||
const equalize = CompareEphemeral.use((state) => state.environment.equalize);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<IconButton variant="ghost" className={styles.trigger}>
|
||||
<div className={styles.dots}>
|
||||
{Object.entries(skills.classes).map(([tankClass, group]) => (
|
||||
<div key={tankClass} className={styles["dot-row"]}>
|
||||
{group.skills.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className={styles.dot}
|
||||
data-state={
|
||||
selected[skill] === 0
|
||||
? "none"
|
||||
: permanentSkills.includes(skill)
|
||||
? "permanent"
|
||||
: "temporary"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content>
|
||||
<LoadoutSkills
|
||||
selected={selected}
|
||||
onChange={(skill, level) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.environment.skills[skill] = level;
|
||||
});
|
||||
}}
|
||||
onClear={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
for (const skill in draft.environment.skills) {
|
||||
draft.environment.skills[skill] = 0;
|
||||
}
|
||||
});
|
||||
}}
|
||||
onMaximize={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
for (const skill in draft.environment.skills) {
|
||||
draft.environment.skills[skill] = MAX_SKILL_LEVEL;
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
|
||||
<Checkbox
|
||||
checked={equalize}
|
||||
onCheckedChange={(checked) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.environment.equalize = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.compare.equalize}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const insertionMarkers = new Set<{
|
||||
element: HTMLDivElement;
|
||||
index: number;
|
||||
}>();
|
||||
@@ -0,0 +1,14 @@
|
||||
.marker {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
height: calc(100% - 2 * var(--space-4));
|
||||
width: var(--space-1);
|
||||
|
||||
border-radius: var(--radius-max);
|
||||
background-color: var(--accent-12);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { insertionMarkers } from "./constants";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
interface CompareInsertionMarkerProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function CompareInsertionMarker({ index }: CompareInsertionMarkerProps) {
|
||||
const marker = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = marker.current!;
|
||||
const insertionMarker = { element, index };
|
||||
|
||||
insertionMarkers.add(insertionMarker);
|
||||
|
||||
return () => {
|
||||
insertionMarkers.delete(insertionMarker);
|
||||
};
|
||||
}, [index]);
|
||||
|
||||
return <div ref={marker} className={styles.marker} />;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
.loadout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
padding: var(--space-3) 0;
|
||||
|
||||
.triggers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.modules-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
font-size: inherit;
|
||||
|
||||
.summary-module {
|
||||
object-fit: contain;
|
||||
|
||||
& + & {
|
||||
margin-left: -0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.items-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.summary-item {
|
||||
object-fit: contain;
|
||||
|
||||
& + & {
|
||||
margin-top: -0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.equipment-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.summary-slot {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: var(--space-2);
|
||||
height: var(--space-3);
|
||||
border-radius: var(--radius-max);
|
||||
|
||||
background: var(--gray-11);
|
||||
|
||||
&[data-active="true"] {
|
||||
background: var(--accent-11);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { alias } from "@blitzkit/core";
|
||||
import { CheckIcon, ComponentPlaceholderIcon } from "@radix-ui/react-icons";
|
||||
import { chunk } from "lodash-es";
|
||||
import { api } from "../../api/dynamic";
|
||||
import { equipmentColumns } from "../../config/equipment";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { useUnwrapper } from "../../hooks/useUnwrapper";
|
||||
import { CompareEphemeral } from "../../stores/compareEphemeral";
|
||||
import { resolveTankModules } from "../../tankopedia/resolveTankModules";
|
||||
import { selectModule } from "../../tankopedia/selectModule";
|
||||
import { Button, type ButtonProps } from "../Button";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { LoadoutConsumables } from "../LoadoutConsumables";
|
||||
import { LoadoutEquipment } from "../LoadoutEquipment";
|
||||
import { LoadoutModules } from "../LoadoutModules";
|
||||
import { LoadoutProvisions } from "../LoadoutProvisions";
|
||||
import { Popover } from "../Popover";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
const equipment = await api.equipment();
|
||||
const consumables = await api.consumables();
|
||||
const provisions = await api.provisions();
|
||||
|
||||
interface CompareLoadoutProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function CompareLoadout({ index }: CompareLoadoutProps) {
|
||||
return (
|
||||
<div className={styles.loadout}>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<ModulesSummary />
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content>
|
||||
<Modules index={index} />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
|
||||
<div className={styles.triggers}>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<ItemsSummary index={index} kind="provisions" />
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content>
|
||||
<Provisions index={index} />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<EquipmentSummary index={index} />
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content>
|
||||
<Equipment index={index} />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<ItemsSummary index={index} kind="consumables" />
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content>
|
||||
<Consumables index={index} />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
|
||||
<Camouflage index={index} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
function useMember(index: number) {
|
||||
const state = CompareEphemeral.use((state) => state.members[index].state);
|
||||
|
||||
return { state, ...resolveTankModules(state) };
|
||||
}
|
||||
|
||||
function Camouflage({ index }: SectionProps) {
|
||||
const strings = useStrings();
|
||||
const { tank, state } = useMember(index);
|
||||
|
||||
if (tank.fixed_camouflage) return null;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
color={state.camouflage ? undefined : "gray"}
|
||||
variant={state.camouflage ? "solid" : "outline"}
|
||||
aria-pressed={state.camouflage}
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index].state.camouflage = !state.camouflage;
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={strings.tanks.loadout.camouflage}
|
||||
src={alias("api", "/icons/camo.webp")}
|
||||
/>
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
type SummaryProps = Omit<ButtonProps, "children">;
|
||||
|
||||
function ModulesSummary(props: SummaryProps) {
|
||||
const strings = useStrings();
|
||||
|
||||
return (
|
||||
<IconButton variant="ghost" size="major" {...props}>
|
||||
<div className={styles["modules-summary"]}>
|
||||
{(["turret", "gun", "engine", "chassis"] as const).map((module) => (
|
||||
<img
|
||||
key={module}
|
||||
alt={strings.tanks.loadout.module_types[module]}
|
||||
className={styles["summary-module"]}
|
||||
src={alias("api", `/icons/modules/${module}.webp`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemsSummaryProps extends SummaryProps, SectionProps {
|
||||
kind: "provisions" | "consumables";
|
||||
}
|
||||
|
||||
function ItemsSummary({ index, kind, ...props }: ItemsSummaryProps) {
|
||||
const unwrap = useUnwrapper();
|
||||
const { state } = useMember(index);
|
||||
const selected = state[kind];
|
||||
|
||||
return (
|
||||
<IconButton variant="ghost" {...props}>
|
||||
<div className={styles["items-summary"]}>
|
||||
{selected.map((id) => (
|
||||
<img
|
||||
key={id}
|
||||
alt={unwrap(
|
||||
kind === "provisions"
|
||||
? provisions.provisions[id].name
|
||||
: consumables.consumables[id].name,
|
||||
)}
|
||||
className={styles["summary-item"]}
|
||||
src={alias("api", `/icons/${kind}/${id}.webp`)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{selected.length === 0 && <ComponentPlaceholderIcon />}
|
||||
</div>
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface EquipmentSummaryProps extends SummaryProps, SectionProps {}
|
||||
|
||||
function EquipmentSummary({ index, ...props }: EquipmentSummaryProps) {
|
||||
const { tank, state } = useMember(index);
|
||||
const preset = equipment.presets[tank.equipment_preset];
|
||||
|
||||
return (
|
||||
<IconButton variant="ghost" size="major" {...props}>
|
||||
<div className={styles["equipment-summary"]}>
|
||||
{chunk(preset.slots, equipmentColumns).map((slots, rowIndex) => (
|
||||
<div key={rowIndex} className={styles["summary-row"]}>
|
||||
{slots.map((slot, columnIndex) => {
|
||||
const slotIndex = rowIndex * equipmentColumns + columnIndex;
|
||||
|
||||
return (
|
||||
<div key={columnIndex} className={styles["summary-slot"]}>
|
||||
{slot.options.map((_, optionIndex) => (
|
||||
<div
|
||||
key={optionIndex}
|
||||
className={styles.bar}
|
||||
data-active={state.equipment[slotIndex] === optionIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
function Modules({ index }: SectionProps) {
|
||||
const { tank, state } = useMember(index);
|
||||
|
||||
function setAll(position: "first" | "last") {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
const member = draft.members[index];
|
||||
const pick = <Item,>(list: Item[]) =>
|
||||
position === "first" ? list[0] : list.at(-1)!;
|
||||
const nextTurret = pick(tank.turrets);
|
||||
const nextGun = pick(nextTurret.guns);
|
||||
|
||||
member.state.turret = nextTurret.id;
|
||||
member.state.gun = nextGun.id;
|
||||
member.state.shell = nextGun.shells[0].id;
|
||||
member.state.engine = pick(tank.engines).id;
|
||||
member.state.track = pick(tank.tracks).id;
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadoutModules
|
||||
tank={tank}
|
||||
selected={state}
|
||||
onSelect={(unlock) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
selectModule(draft.members[index].state, tank, unlock);
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
}}
|
||||
onStock={() => setAll("first")}
|
||||
onMaximize={() => setAll("last")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Equipment({ index }: SectionProps) {
|
||||
const strings = useStrings();
|
||||
const { tank, state } = useMember(index);
|
||||
|
||||
return (
|
||||
<LoadoutEquipment
|
||||
tank={tank}
|
||||
selected={state.equipment}
|
||||
onToggle={(slotIndex, optionIndex) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
const current = draft.members[index].state.equipment;
|
||||
|
||||
if (current[slotIndex] === optionIndex) delete current[slotIndex];
|
||||
else current[slotIndex] = optionIndex;
|
||||
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
}}
|
||||
onClear={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index].state.equipment = {};
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
}}
|
||||
actions={
|
||||
<Button
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
size="minor"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
for (const member of draft.members) {
|
||||
member.state.equipment = {
|
||||
...draft.members[index].state.equipment,
|
||||
};
|
||||
}
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
{strings.compare.apply_to_all}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Provisions({ index }: SectionProps) {
|
||||
const { tank, gun, state } = useMember(index);
|
||||
|
||||
return (
|
||||
<LoadoutProvisions
|
||||
implicitWidth
|
||||
tank={tank}
|
||||
gun={gun}
|
||||
selected={state.provisions}
|
||||
onToggle={(id) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
const list = draft.members[index].state.provisions;
|
||||
const position = list.indexOf(id);
|
||||
|
||||
if (position === -1) {
|
||||
if (list.length >= tank.max_provisions) return;
|
||||
|
||||
list.push(id);
|
||||
} else {
|
||||
list.splice(position, 1);
|
||||
}
|
||||
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
}}
|
||||
onClear={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index].state.provisions = [];
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Consumables({ index }: SectionProps) {
|
||||
const { tank, gun, state } = useMember(index);
|
||||
|
||||
return (
|
||||
<LoadoutConsumables
|
||||
tank={tank}
|
||||
gun={gun}
|
||||
selected={state.consumables}
|
||||
onToggle={(id) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
const list = draft.members[index].state.consumables;
|
||||
const position = list.indexOf(id);
|
||||
|
||||
if (position === -1) {
|
||||
if (list.length >= tank.max_consumables) return;
|
||||
|
||||
list.push(id);
|
||||
} else {
|
||||
list.splice(position, 1);
|
||||
}
|
||||
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
}}
|
||||
onClear={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index].state.consumables = [];
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
.logo {
|
||||
margin: auto;
|
||||
color: var(--gray-a5);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
&[data-indent="true"] {
|
||||
padding-left: var(--space-4);
|
||||
}
|
||||
|
||||
.sort {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loadout {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.shell-cell {
|
||||
vertical-align: middle;
|
||||
|
||||
.shells {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { alias } from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n/src/literals";
|
||||
import {
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
CaretSortIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useMemo } from "react";
|
||||
import { characteristicsGroups } from "../../config/characteristicsGroups";
|
||||
import { computeCharacteristics } from "../../hooks/useCharacteristics";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { useUnwrapper } from "../../hooks/useUnwrapper";
|
||||
import { Logo } from "../../icons/Logo";
|
||||
import {
|
||||
CompareEphemeral,
|
||||
syncShellSlot,
|
||||
type CompareMember,
|
||||
} from "../../stores/compareEphemeral";
|
||||
import { ComparePersistent } from "../../stores/comparePersistent";
|
||||
import { TankopediaSortDirection } from "../../stores/tankopediaPersistent";
|
||||
import { rankingValue } from "../../tankopedia/calculateRankings";
|
||||
import { resolveTankModules } from "../../tankopedia/resolveTankModules";
|
||||
import {
|
||||
CharacteristicRanking,
|
||||
type CharacteristicName,
|
||||
type CharacteristicRenderConfig,
|
||||
type ComputedCharacteristics,
|
||||
} from "../../types/characteristics";
|
||||
import { CharacteristicValue } from "../CharacteristicValue";
|
||||
import { CompareEnvironment } from "../CompareEnvironment";
|
||||
import { CompareInsertionMarker } from "../CompareInsertionMarker";
|
||||
import { CompareLoadout } from "../CompareLoadout";
|
||||
import { CompareTankCard } from "../CompareTankCard";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { Table } from "../Table";
|
||||
import { Text } from "../Text";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
// Removes one occurrence of each excluded id, keeping other tanks' duplicates
|
||||
function without(ids: number[], excluded: number[]) {
|
||||
const remaining = [...ids];
|
||||
|
||||
for (const id of excluded) {
|
||||
remaining.splice(remaining.indexOf(id), 1);
|
||||
}
|
||||
|
||||
return remaining;
|
||||
}
|
||||
|
||||
export function CompareTable() {
|
||||
const members = CompareEphemeral.use((state) => state.members);
|
||||
const environment = CompareEphemeral.use((state) => state.environment);
|
||||
|
||||
// Other tanks' consumables and provisions can reduce a tank's damage, so each
|
||||
// tank is computed against a synthetic opponent carrying all of theirs
|
||||
const characteristics = useMemo(() => {
|
||||
const allConsumables = members.flatMap(({ state }) => state.consumables);
|
||||
const allProvisions = members.flatMap(({ state }) => state.provisions);
|
||||
|
||||
return members.map((member) => {
|
||||
const antagonist = {
|
||||
...member.state,
|
||||
consumables: without(allConsumables, member.state.consumables),
|
||||
provisions: without(allProvisions, member.state.provisions),
|
||||
};
|
||||
|
||||
return computeCharacteristics(
|
||||
{
|
||||
protagonist: member.state,
|
||||
antagonist: members.length > 1 ? antagonist : member.state,
|
||||
},
|
||||
environment,
|
||||
);
|
||||
});
|
||||
}, [members, environment]);
|
||||
|
||||
return (
|
||||
<Table.Root scrollable>
|
||||
<Table.Head>
|
||||
<Table.Row>
|
||||
<Table.Corner>
|
||||
<Logo className={styles.logo} width={64} height={64} />
|
||||
<CompareInsertionMarker index={0} />
|
||||
</Table.Corner>
|
||||
|
||||
{members.map(({ key, state }, index) => (
|
||||
<CompareTankCard
|
||||
key={key}
|
||||
index={index}
|
||||
tank={resolveTankModules(state).tank}
|
||||
/>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Head>
|
||||
|
||||
<Table.Body>
|
||||
<Table.Row>
|
||||
<Table.RowHeader>
|
||||
<CompareEnvironment />
|
||||
</Table.RowHeader>
|
||||
|
||||
{members.map((member, index) => (
|
||||
<LoadoutCell key={member.key} index={index} />
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
|
||||
{characteristicsGroups.map((group) => (
|
||||
<Group
|
||||
key={group.name}
|
||||
group={group}
|
||||
characteristics={characteristics}
|
||||
/>
|
||||
))}
|
||||
</Table.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadoutCellProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
function LoadoutCell({ index }: LoadoutCellProps) {
|
||||
return (
|
||||
<Table.Cell className={styles.loadout}>
|
||||
<CompareLoadout index={index} />
|
||||
</Table.Cell>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShellSwitcherProps {
|
||||
member: CompareMember;
|
||||
index: number;
|
||||
}
|
||||
|
||||
function ShellSwitcher({ member, index }: ShellSwitcherProps) {
|
||||
const unwrap = useUnwrapper();
|
||||
const { gun, shell } = resolveTankModules(member.state);
|
||||
|
||||
return (
|
||||
<div className={styles.shells}>
|
||||
{gun.shells.map((candidate, shellIndex) => {
|
||||
const isSelected = candidate.id === shell.id;
|
||||
const name = unwrap(candidate.name!);
|
||||
|
||||
return (
|
||||
<Tooltip key={candidate.id} tooltip={name}>
|
||||
<IconButton
|
||||
size="minor"
|
||||
color={isSelected ? undefined : "gray"}
|
||||
variant={isSelected ? "surface" : "soft"}
|
||||
parentListChild="horizontal"
|
||||
onClick={() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
if (draft.syncShells) {
|
||||
syncShellSlot(draft.members, shellIndex);
|
||||
} else {
|
||||
draft.members[index].state.shell = candidate.id;
|
||||
}
|
||||
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={name}
|
||||
src={alias("api", `/icons/shells/${candidate.icon}.webp`)}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupProps {
|
||||
group: (typeof characteristicsGroups)[number];
|
||||
characteristics: ComputedCharacteristics[];
|
||||
}
|
||||
|
||||
function Group({ group, characteristics }: GroupProps) {
|
||||
const strings = useStrings();
|
||||
const members = CompareEphemeral.use((state) => state.members);
|
||||
const items = group.order.filter(
|
||||
(item): item is CharacteristicRenderConfig =>
|
||||
"name" in item &&
|
||||
characteristics.some((computed) => computed.has(item.name)),
|
||||
);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const isShellGroup = group.name === "shell";
|
||||
const title = (
|
||||
strings.tanks.characteristics.titles as Record<string, string>
|
||||
)[group.name];
|
||||
|
||||
return (
|
||||
<Table.Body>
|
||||
<Table.Row>
|
||||
<Table.GroupTitle
|
||||
colSpan={isShellGroup ? undefined : characteristics.length + 1}
|
||||
>
|
||||
{title}
|
||||
</Table.GroupTitle>
|
||||
|
||||
{isShellGroup &&
|
||||
members.map((member, index) => (
|
||||
<Table.GroupCell key={member.key} className={styles["shell-cell"]}>
|
||||
<ShellSwitcher member={member} index={index} />
|
||||
</Table.GroupCell>
|
||||
))}
|
||||
</Table.Row>
|
||||
|
||||
{items.map((config) => (
|
||||
<Row
|
||||
key={config.name}
|
||||
config={config}
|
||||
characteristics={characteristics}
|
||||
/>
|
||||
))}
|
||||
</Table.Body>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
config: CharacteristicRenderConfig;
|
||||
characteristics: ComputedCharacteristics[];
|
||||
}
|
||||
|
||||
function Row({ config, characteristics }: RowProps) {
|
||||
const strings = useStrings();
|
||||
const sorting = CompareEphemeral.use((state) => state.sorting);
|
||||
const deltaMode = ComparePersistent.use((state) => state.deltaMode);
|
||||
const members = CompareEphemeral.use((state) => state.members);
|
||||
|
||||
const values = characteristics.map((computed) =>
|
||||
rankingValue(config.name, computed.get(config.name)),
|
||||
);
|
||||
const truth = values[0];
|
||||
const isSorted = sorting.by === config.name;
|
||||
const label = (strings.tanks.characteristics.items as Record<string, string>)[
|
||||
config.name
|
||||
];
|
||||
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.RowHeader>
|
||||
<div className={styles.label} data-indent={config.indent}>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="minor"
|
||||
color={isSorted ? undefined : "gray"}
|
||||
onClick={() => sort(config.name, values, members)}
|
||||
>
|
||||
{!isSorted && <CaretSortIcon className={styles.sort} />}
|
||||
{isSorted &&
|
||||
sorting.direction === TankopediaSortDirection.Ascending && (
|
||||
<CaretRightIcon />
|
||||
)}
|
||||
{isSorted &&
|
||||
sorting.direction === TankopediaSortDirection.Descending && (
|
||||
<CaretLeftIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
<Text>{label}</Text>
|
||||
|
||||
{config.units !== undefined && (
|
||||
<Text lowContrast size="minor">
|
||||
{literals(strings.units[config.units], { value: "" })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Table.RowHeader>
|
||||
|
||||
{characteristics.map((computed, index) => {
|
||||
const value = values[index];
|
||||
const output = computed.get(config.name);
|
||||
const delta =
|
||||
value !== undefined && truth !== undefined ? value - truth : 0;
|
||||
|
||||
return (
|
||||
<Table.Cell
|
||||
key={members[index].key}
|
||||
align="end"
|
||||
style={{ backgroundColor: cellColor(config, value, truth) }}
|
||||
>
|
||||
<div className={styles.value}>
|
||||
{output && (
|
||||
<CharacteristicValue
|
||||
config={config}
|
||||
value={output}
|
||||
units={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{delta !== 0 && Number.isFinite(delta) && (
|
||||
<>
|
||||
{deltaMode === "absolute" && (
|
||||
<Text lowContrast size="minor">
|
||||
({delta > 0 ? "+" : ""}
|
||||
{Number(delta.toFixed(config.decimals ?? 2))})
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{deltaMode === "percentage" && truth !== 0 && (
|
||||
<Text lowContrast size="minor">
|
||||
({delta > 0 ? "+" : ""}
|
||||
{Math.round((delta / Math.abs(truth!)) * 100)}%)
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
);
|
||||
})}
|
||||
</Table.Row>
|
||||
);
|
||||
}
|
||||
|
||||
function sort(
|
||||
name: CharacteristicName,
|
||||
values: (number | undefined)[],
|
||||
members: CompareMember[],
|
||||
) {
|
||||
const byKey = new Map(members.map(({ key }, index) => [key, values[index]]));
|
||||
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
const ascending =
|
||||
draft.sorting?.by === name &&
|
||||
draft.sorting.direction === TankopediaSortDirection.Descending;
|
||||
|
||||
// Missing values always sink to the end
|
||||
draft.members.sort((a, b) => {
|
||||
const valueA = byKey.get(a.key);
|
||||
const valueB = byKey.get(b.key);
|
||||
|
||||
if (valueA === undefined && valueB === undefined) return 0;
|
||||
if (valueA === undefined) return 1;
|
||||
if (valueB === undefined) return -1;
|
||||
|
||||
return ascending ? valueA - valueB : valueB - valueA;
|
||||
});
|
||||
|
||||
draft.sorting = {
|
||||
by: name,
|
||||
direction: ascending
|
||||
? TankopediaSortDirection.Ascending
|
||||
: TankopediaSortDirection.Descending,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function cellColor(
|
||||
config: CharacteristicRenderConfig,
|
||||
value: number | undefined,
|
||||
truth: number | undefined,
|
||||
) {
|
||||
if (
|
||||
config.ranking === CharacteristicRanking.None ||
|
||||
value === undefined ||
|
||||
truth === undefined ||
|
||||
value === truth ||
|
||||
!Number.isFinite(value) ||
|
||||
!Number.isFinite(truth)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const higherIsBetter = config.ranking !== CharacteristicRanking.LowerIsBetter;
|
||||
const better = higherIsBetter === value > truth;
|
||||
const change = truth === 0 ? 1 : Math.abs(value / truth - 1);
|
||||
const intensity = Math.round(Math.min(100, change * 2 * 100 + 25));
|
||||
const color = better ? "jade" : "red";
|
||||
|
||||
return `color-mix(in srgb, var(--${color}-7) ${intensity}%, var(--${color}-3))`;
|
||||
}
|
||||
@@ -1,3 +1,47 @@
|
||||
.name {
|
||||
max-width: 8rem;
|
||||
.card {
|
||||
position: relative;
|
||||
|
||||
padding: var(--space-3);
|
||||
vertical-align: top;
|
||||
|
||||
/* The header is its own stacking context, so it rises above its siblings while dragging */
|
||||
&[data-dragging="true"] {
|
||||
z-index: 2;
|
||||
|
||||
.draggable {
|
||||
position: fixed;
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.draggable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-1);
|
||||
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.icon {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.name {
|
||||
max-width: 6rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { tankIcon } from "@blitzkit/core";
|
||||
import type { TankDefinition } from "@blitzkit/protos";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Vector2 } from "three";
|
||||
import { useLocale } from "../../hooks/useLocale";
|
||||
import { useUnwrapper } from "../../hooks/useUnwrapper";
|
||||
import { CompareEphemeral } from "../../stores/compareEphemeral";
|
||||
import { InsertionMarker } from "../Compare/IntersectionMarker";
|
||||
import { insertionMarkers } from "../Compare/IntersectionMarker/constants";
|
||||
import { TankControl } from "../Compare/TankControl";
|
||||
import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
|
||||
import { CompareInsertionMarker } from "../CompareInsertionMarker";
|
||||
import { insertionMarkers } from "../CompareInsertionMarker/constants";
|
||||
import { CompareTankControl } from "../CompareTankControl";
|
||||
import { Table } from "../Table";
|
||||
import { Text } from "../Text";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
@@ -17,8 +17,9 @@ interface TankCardProps {
|
||||
}
|
||||
|
||||
export function CompareTankCard({ index, tank }: TankCardProps) {
|
||||
const header = useRef<HTMLTableCellElement>(null);
|
||||
const draggable = useRef<HTMLDivElement>(null);
|
||||
const { unwrap } = useLocale();
|
||||
const unwrap = useUnwrapper();
|
||||
|
||||
useEffect(() => {
|
||||
const initial = new Vector2();
|
||||
@@ -47,7 +48,7 @@ export function CompareTankCard({ index, tank }: TankCardProps) {
|
||||
element.style.opacity = "0";
|
||||
});
|
||||
|
||||
const distances = insertionMarkers.map((insertionMarker) => {
|
||||
const distances = Array.from(insertionMarkers, (insertionMarker) => {
|
||||
const markerBounds = insertionMarker.element.getBoundingClientRect();
|
||||
marker.set(markerBounds.left, markerBounds.top);
|
||||
|
||||
@@ -57,23 +58,19 @@ export function CompareTankCard({ index, tank }: TankCardProps) {
|
||||
};
|
||||
});
|
||||
const closest = distances.reduce((a, b) =>
|
||||
a.distance < b.distance ? a : b
|
||||
a.distance < b.distance ? a : b,
|
||||
);
|
||||
|
||||
dropIndex = closest.index;
|
||||
closest.element.style.opacity = "1";
|
||||
draggable.current!.style.position = "fixed";
|
||||
draggable.current!.style.cursor = "grabbing";
|
||||
draggable.current!.style.zIndex = "1";
|
||||
header.current!.dataset.dragging = "true";
|
||||
draggable.current!.style.left = `${initialRect.left + delta.x}px`;
|
||||
draggable.current!.style.top = `${initialRect.top + delta.y}px`;
|
||||
}
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
draggable.current!.style.position = "static";
|
||||
draggable.current!.style.zIndex = "unset";
|
||||
draggable.current!.style.cursor = "grab";
|
||||
delete header.current!.dataset.dragging;
|
||||
|
||||
insertionMarkers.forEach(({ element }) => {
|
||||
element.style.opacity = "0";
|
||||
@@ -104,48 +101,25 @@ export function CompareTankCard({ index, tank }: TankCardProps) {
|
||||
}, [index]);
|
||||
|
||||
return (
|
||||
<StickyColumnHeaderCell width="0">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "var(--space-2)",
|
||||
}}
|
||||
>
|
||||
<TankControl index={index} key={tank.id} slug={tank.slug} />
|
||||
<Table.ColumnHeader ref={header} className={styles.card}>
|
||||
<div className={styles.content}>
|
||||
<CompareTankControl index={index} key={tank.id} slug={tank.slug} />
|
||||
|
||||
<div
|
||||
ref={draggable}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "var(--space-2)",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
cursor: "grab",
|
||||
}}
|
||||
>
|
||||
<div ref={draggable} className={styles.draggable}>
|
||||
<img
|
||||
alt={unwrap(tank.name!)}
|
||||
src={tankIcon(tank.id)}
|
||||
width={64}
|
||||
height={64}
|
||||
draggable={false}
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
}}
|
||||
className={styles.icon}
|
||||
/>
|
||||
|
||||
<Text className={styles.name} nowrap overflow="ellipsis">
|
||||
{unwrap(tank.name!)}
|
||||
</Text>
|
||||
<Text className={styles.name}>{unwrap(tank.name!)}</Text>
|
||||
</div>
|
||||
</div>
|
||||
<InsertionMarker index={index + 1} />
|
||||
</StickyColumnHeaderCell>
|
||||
|
||||
<CompareInsertionMarker index={index + 1} />
|
||||
</Table.ColumnHeader>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import locales from "@blitzkit/i18n/locales.json";
|
||||
import { ExternalLinkIcon, LoopIcon, TrashIcon } from "@radix-ui/react-icons";
|
||||
import { useCallback, useState } from "react";
|
||||
import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
|
||||
import { useLocale } from "../../hooks/useLocale";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { CompareEphemeral } from "../../stores/compareEphemeral";
|
||||
import { Dialog } from "../Dialog";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { TankSearch } from "../TankSearch";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
interface CompareTankControlProps {
|
||||
index: number;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function CompareTankControl({ index, slug }: CompareTankControlProps) {
|
||||
const locale = useLocale();
|
||||
const strings = useStrings();
|
||||
const [swapping, setSwapping] = useState(false);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members.splice(index, 1);
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
}, [index]);
|
||||
|
||||
return (
|
||||
<div className={styles.controls}>
|
||||
<IconButton variant="ghost" size="minor" onClick={handleRemove}>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
|
||||
<Dialog.Root open={swapping} onOpenChange={setSwapping}>
|
||||
<Dialog.Trigger asChild>
|
||||
<IconButton variant="ghost" size="minor">
|
||||
<LoopIcon />
|
||||
</IconButton>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay />
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{strings.compare.swap_dialog.title}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{strings.compare.swap_dialog.description}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Body>
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members[index] = tankToCompareMember(tank);
|
||||
draft.sorting.by = null;
|
||||
});
|
||||
setSwapping(false);
|
||||
}}
|
||||
/>
|
||||
</Dialog.Body>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="minor"
|
||||
onClick={() => {
|
||||
const prefix = locale === locales.default ? "" : `/${locale}`;
|
||||
window.open(`${prefix}/tanks/${slug}`, "_blank");
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { Consumable } from "@blitzkit/protos";
|
||||
import { Flex, type FlexProps } from "@radix-ui/themes";
|
||||
import { ConsumableButton } from "./ModuleButtons/ConsumableButton";
|
||||
|
||||
type ConsumablesManagerProps = FlexProps & {
|
||||
consumables: Consumable[];
|
||||
selected: number[];
|
||||
onConsumablesChange?: (consumables: number[]) => void;
|
||||
disabled?: boolean;
|
||||
timers?: boolean;
|
||||
hasHighEndConsumables?: boolean;
|
||||
hasConsumableDeliverySystem?: boolean;
|
||||
cooldownBooster?: number;
|
||||
};
|
||||
|
||||
export function ConsumablesManager({
|
||||
consumables,
|
||||
selected,
|
||||
onConsumablesChange,
|
||||
disabled,
|
||||
timers,
|
||||
hasConsumableDeliverySystem,
|
||||
cooldownBooster,
|
||||
hasHighEndConsumables,
|
||||
...props
|
||||
}: ConsumablesManagerProps) {
|
||||
return (
|
||||
<Flex wrap="wrap" gap="2" {...props}>
|
||||
{consumables.map((consumable) => {
|
||||
if (consumable.game_mode_exclusive) return null;
|
||||
|
||||
const isSelected = selected.some((id) => id === consumable.id);
|
||||
|
||||
return (
|
||||
<ConsumableButton
|
||||
special={consumable.duration === undefined}
|
||||
duration={
|
||||
timers
|
||||
? (consumable.duration ?? 0) *
|
||||
(hasHighEndConsumables ? 1.33 : 1) || undefined
|
||||
: undefined
|
||||
}
|
||||
cooldown={
|
||||
timers
|
||||
? consumable.cooldown *
|
||||
(hasConsumableDeliverySystem ? 1 - 0.17 : 1) *
|
||||
(1 - cooldownBooster! * 0.1)
|
||||
: undefined
|
||||
}
|
||||
key={consumable.id}
|
||||
disabled={disabled && !isSelected}
|
||||
consumable={consumable.id}
|
||||
selected={isSelected}
|
||||
onClick={() => {
|
||||
if (!onConsumablesChange) return;
|
||||
|
||||
const draft = [...selected];
|
||||
|
||||
if (draft.some((id) => id === consumable.id)) {
|
||||
draft.splice(draft.indexOf(consumable.id), 1);
|
||||
} else {
|
||||
draft.push(consumable.id);
|
||||
}
|
||||
|
||||
onConsumablesChange(draft);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import {
|
||||
alias,
|
||||
MAX_SKILL_LEVEL,
|
||||
permanentSkills,
|
||||
romanize,
|
||||
} from "@blitzkit/core";
|
||||
import { Flex } from "@radix-ui/themes";
|
||||
import { api } from "../api/dynamic";
|
||||
import { GenericTankComponentButton } from "./ModuleButtons/GenericTankComponentButton";
|
||||
|
||||
interface CrewSkillManagerProps {
|
||||
skillLevels: Record<string, number>;
|
||||
onChange?: (skillLevels: Record<string, number>) => void;
|
||||
}
|
||||
|
||||
const skillDefinitions = await api.skills();
|
||||
|
||||
export function CrewSkillManager({
|
||||
skillLevels,
|
||||
onChange,
|
||||
}: CrewSkillManagerProps) {
|
||||
return (
|
||||
<Flex direction="column" gap="2">
|
||||
{Object.entries(skillDefinitions.classes).map(([tankClass, skills]) => (
|
||||
<Flex gap="2" key={tankClass}>
|
||||
{skills.skills.map((skill) => {
|
||||
const level = skillLevels[skill];
|
||||
|
||||
return (
|
||||
<GenericTankComponentButton
|
||||
key={skill}
|
||||
special={!permanentSkills.includes(skill)}
|
||||
selected={level > 0}
|
||||
discriminator={level === 0 ? undefined : romanize(level)}
|
||||
icon={alias("api", `/icons/skills/${skill}.webp`)}
|
||||
onClick={(event) => {
|
||||
if (!onChange) return;
|
||||
|
||||
const draft = { ...skillLevels };
|
||||
|
||||
if (event.shiftKey) {
|
||||
draft[skill] = draft[skill] === 0 ? MAX_SKILL_LEVEL : 0;
|
||||
} else {
|
||||
draft[skill] = level - 1;
|
||||
if (draft[skill] < 0) draft[skill] = MAX_SKILL_LEVEL;
|
||||
}
|
||||
|
||||
onChange(draft);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ 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";
|
||||
import { useDuelModules } from "../../hooks/useDuelModules";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { Tankopedia } from "../../stores/tankopedia";
|
||||
import { Button } from "../Button";
|
||||
@@ -78,9 +78,8 @@ const COMMON_SHELLS: [ShellType, keyof Strings["common"]["shells"]][] = [
|
||||
|
||||
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 protagonist = useDuelModules("protagonist");
|
||||
const antagonist = useDuelModules("antagonist");
|
||||
const strings = useStrings();
|
||||
|
||||
const mm = literals(strings.common.units.mm, { value: "" });
|
||||
@@ -100,7 +99,7 @@ export function CustomShellButton() {
|
||||
if (!open || customShell) return;
|
||||
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.customShell = antagonistShell;
|
||||
draft.customShell = antagonist.shell;
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -161,7 +160,7 @@ export function CustomShellButton() {
|
||||
modified.normalization = undefined;
|
||||
modified.ricochet = undefined;
|
||||
modified.explosion_radius =
|
||||
gun.shells.find(
|
||||
protagonist.gun.shells.find(
|
||||
(shell) => shell.type === ShellType.SHELL_TYPE_HE,
|
||||
)?.explosion_radius ?? 1;
|
||||
break;
|
||||
@@ -321,7 +320,7 @@ export function CustomShellButton() {
|
||||
color="red"
|
||||
onClick={() => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.customShell = antagonistShell;
|
||||
draft.customShell = antagonist.shell;
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { InfoCircledIcon } from '@radix-ui/react-icons';
|
||||
import { Callout } from '@radix-ui/themes';
|
||||
import { CompareEphemeral } from '../stores/compareEphemeral';
|
||||
|
||||
export function DamageWarning() {
|
||||
const members = CompareEphemeral.use((state) => state.members);
|
||||
const hasReactive = members.some((member) => member.consumables.includes(33));
|
||||
const haDynamicArmor = members.some((member) =>
|
||||
member.consumables.includes(73),
|
||||
);
|
||||
const hasSpallLiner = members.some((member) =>
|
||||
member.provisions.includes(101),
|
||||
);
|
||||
|
||||
if (!hasReactive && !haDynamicArmor && !hasSpallLiner) return null;
|
||||
|
||||
{
|
||||
<Callout.Root>
|
||||
<Callout.Icon>
|
||||
<InfoCircledIcon />
|
||||
</Callout.Icon>
|
||||
<Callout.Text>
|
||||
Consumables like Reactive Armor and Dynamic Armor System and provisions
|
||||
like Spall Liner may reduce other tanks' damage.
|
||||
</Callout.Text>
|
||||
</Callout.Root>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.Overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
|
||||
background-color: var(--black-a9);
|
||||
}
|
||||
|
||||
.Content {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
box-sizing: border-box;
|
||||
width: calc(100dvw - 2 * var(--space-4));
|
||||
max-width: 40rem;
|
||||
max-height: calc(100dvh - 2 * var(--space-4));
|
||||
overflow: hidden;
|
||||
|
||||
background-color: var(--gray-2);
|
||||
box-shadow: var(--shadow-subtle);
|
||||
border-radius: var(--space-4);
|
||||
outline: var(--outline-interactive) !important;
|
||||
}
|
||||
|
||||
.Title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-heading-4);
|
||||
color: var(--gray-12);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.Description {
|
||||
margin: 0;
|
||||
color: var(--gray-11);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
padding: var(--space-4);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
--scroller-curtain-color: var(--gray-2);
|
||||
|
||||
flex: 1;
|
||||
|
||||
.content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Dialog as _ } from "radix-ui";
|
||||
import type { ComponentProps } from "react";
|
||||
import { classNames } from "../../ui/classNames";
|
||||
import { composeRadixComponents } from "../../ui/compose";
|
||||
import { Scroller, ScrollerOrientation } from "../Scroller";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
// Groups a title and description
|
||||
function Header({ className, ...props }: ComponentProps<"div">) {
|
||||
return <div className={classNames(styles.header, className)} {...props} />;
|
||||
}
|
||||
|
||||
// Scrolls the content that follows the header
|
||||
function Body({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof Scroller>) {
|
||||
return (
|
||||
<Scroller
|
||||
orientation={ScrollerOrientation.Vertical}
|
||||
className={classNames(styles.body, className)}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</Scroller>
|
||||
);
|
||||
}
|
||||
|
||||
export const Dialog = { ...composeRadixComponents(_, styles), Header, Body };
|
||||
@@ -1,5 +1,6 @@
|
||||
.Content,
|
||||
.SubContent {
|
||||
--drop-down-width: 16rem;
|
||||
--gutter: var(--space-5);
|
||||
|
||||
min-width: min(calc(100dvw - 2 * var(--space-4)), var(--drop-down-width));
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Heading } from "../Heading";
|
||||
import { Text } from "../Text";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, body }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={styles["empty-state"]}>
|
||||
<Heading size="4">{title}</Heading>
|
||||
<Text lowContrast>{body}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.equipment-manager .left {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.equipment-manager .right {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { EquipmentPreset } from "@blitzkit/protos";
|
||||
import { Flex } from "@radix-ui/themes";
|
||||
import { chunk, cloneDeep } from "lodash-es";
|
||||
import type { EquipmentMatrix } from "../../stores/duel";
|
||||
import { EquipmentButton } from "../ModuleButtons/EquipmentButton";
|
||||
import "./index.css";
|
||||
|
||||
interface EquipmentManagerProps {
|
||||
preset: EquipmentPreset;
|
||||
matrix: EquipmentMatrix;
|
||||
onChange: (matrix: EquipmentMatrix) => void;
|
||||
}
|
||||
|
||||
export function EquipmentManager({
|
||||
preset,
|
||||
matrix,
|
||||
onChange,
|
||||
}: EquipmentManagerProps) {
|
||||
return (
|
||||
<Flex direction="column" gap="2" className="equipment-manager">
|
||||
{chunk(preset.slots, 3).map((equipmentRow, rowIndex) => (
|
||||
<Flex gap="2" key={rowIndex}>
|
||||
{equipmentRow.map((equipment, columnIndex) => (
|
||||
<Flex key={columnIndex}>
|
||||
<EquipmentButton
|
||||
className="left"
|
||||
equipment={equipment.left}
|
||||
selected={matrix[rowIndex][columnIndex] === -1}
|
||||
onClick={() => {
|
||||
const draft = cloneDeep(matrix);
|
||||
|
||||
if (draft[rowIndex][columnIndex] === -1) {
|
||||
draft[rowIndex][columnIndex] = 0;
|
||||
} else {
|
||||
draft[rowIndex][columnIndex] = -1;
|
||||
}
|
||||
|
||||
onChange(draft);
|
||||
}}
|
||||
/>
|
||||
<EquipmentButton
|
||||
className="right"
|
||||
equipment={equipment.right}
|
||||
selected={matrix[rowIndex][columnIndex] === 1}
|
||||
onClick={() => {
|
||||
const draft = cloneDeep(matrix);
|
||||
|
||||
if (draft[rowIndex][columnIndex] === 1) {
|
||||
draft[rowIndex][columnIndex] = 0;
|
||||
} else {
|
||||
draft[rowIndex][columnIndex] = 1;
|
||||
}
|
||||
|
||||
onChange(draft);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,2 @@
|
||||
.icon-button {
|
||||
&[data-size="regular"] {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
&[data-size="major"] {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import styles from "./index.module.css";
|
||||
export function IconButton({ className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
evenPadding
|
||||
className={classNames(className, styles["icon-button"])}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
import { useEffect } from "react";
|
||||
import { degToRad } from "three/src/math/MathUtils.js";
|
||||
import { api } from "../api/dynamic";
|
||||
import { applyPitchYawLimits } from "../core/blitz/applyPitchYawLimits";
|
||||
import { modelTransformEvent } from "../core/blitzkit/modelTransform";
|
||||
import { DuelModule, useDuel } from "../hooks/useDuel";
|
||||
|
||||
const modelDefinitions = await api.models();
|
||||
import { useDuelModels } from "../hooks/useDuelModels";
|
||||
|
||||
export function InitialCameraAligner() {
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const turret = useDuel("protagonist", DuelModule.Turret);
|
||||
const gun = useDuel("protagonist", DuelModule.Gun);
|
||||
|
||||
const tankModel = modelDefinitions.models[tank.id];
|
||||
const turretModel = tankModel.turrets[turret.id];
|
||||
const gunModel = turretModel.guns[gun.id];
|
||||
const { turret, gun } = useDuelModels("protagonist");
|
||||
|
||||
useEffect(() => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
degToRad(8),
|
||||
degToRad(25),
|
||||
gunModel.pitch!,
|
||||
turretModel.yaw,
|
||||
gun.pitch!,
|
||||
turret.yaw,
|
||||
);
|
||||
|
||||
modelTransformEvent.dispatch({ pitch, yaw });
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.consumables {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, max-content);
|
||||
gap: var(--space-3);
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
|
||||
.tooltip {
|
||||
width: 100%;
|
||||
|
||||
.consumable {
|
||||
width: 100%;
|
||||
|
||||
font-size: var(--space-5);
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { alias } from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n/src/literals";
|
||||
import type { GunDefinition, TankDefinition } from "@blitzkit/protos";
|
||||
import { ClockIcon, ResetIcon, TrashIcon } from "@radix-ui/react-icons";
|
||||
import { api } from "../../api/dynamic";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { useTankCompatibility } from "../../hooks/useTankCompatibility";
|
||||
import { useUnwrapper } from "../../hooks/useUnwrapper";
|
||||
import { Button } from "../Button";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { LoadoutSection } from "../LoadoutSection";
|
||||
import { Text } from "../Text";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
const [consumables, scripts] = await Promise.all([
|
||||
api.consumables(),
|
||||
api.scripts(),
|
||||
]);
|
||||
|
||||
interface ContentProps {
|
||||
tank: TankDefinition;
|
||||
gun: GunDefinition;
|
||||
selected: number[];
|
||||
onToggle: (id: number) => void;
|
||||
}
|
||||
|
||||
function Content({ tank, gun, selected, onToggle }: ContentProps) {
|
||||
const strings = useStrings();
|
||||
const unwrap = useUnwrapper();
|
||||
const isCompatible = useTankCompatibility(tank, gun);
|
||||
|
||||
return (
|
||||
<div className={styles.consumables}>
|
||||
{Object.values(consumables.consumables).map((consumable) => {
|
||||
if (
|
||||
consumable.game_mode_exclusive ||
|
||||
!isCompatible(consumable.include, consumable.exclude)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSelected = selected.includes(consumable.id);
|
||||
const disabled = !isSelected && selected.length >= tank.max_consumables;
|
||||
const name = unwrap(consumable.name);
|
||||
const { duration = 0, cooldown = 0 } =
|
||||
scripts.consumables[consumable.id] ?? {};
|
||||
|
||||
return (
|
||||
<div key={consumable.id} className={styles.wrapper}>
|
||||
<Tooltip tooltip={name} className={styles.tooltip}>
|
||||
<Button
|
||||
radius="1"
|
||||
variant={isSelected ? "surface" : "soft"}
|
||||
color={isSelected ? undefined : "gray"}
|
||||
className={styles.consumable}
|
||||
disabled={disabled}
|
||||
onClick={() => onToggle(consumable.id)}
|
||||
>
|
||||
<img
|
||||
src={alias("api", `/icons/consumables/${consumable.id}.webp`)}
|
||||
/>
|
||||
<div className={styles.info}>
|
||||
{duration > 0 && (
|
||||
<Text lowContrast size="minor">
|
||||
<div className={styles.entry}>
|
||||
<ClockIcon />
|
||||
{literals(strings.units.s, {
|
||||
value: duration,
|
||||
})}
|
||||
</div>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{cooldown > 0 && (
|
||||
<Text lowContrast size="minor">
|
||||
<div className={styles.entry}>
|
||||
<ResetIcon />
|
||||
{literals(strings.units.s, {
|
||||
value: cooldown,
|
||||
})}
|
||||
</div>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadoutConsumablesProps {
|
||||
tank: TankDefinition;
|
||||
gun: GunDefinition;
|
||||
selected: number[];
|
||||
onToggle: (id: number) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function LoadoutConsumables({
|
||||
tank,
|
||||
gun,
|
||||
selected,
|
||||
onToggle,
|
||||
onClear,
|
||||
}: LoadoutConsumablesProps) {
|
||||
const strings = useStrings();
|
||||
|
||||
return (
|
||||
<LoadoutSection
|
||||
title={strings.tanks.loadout.consumables}
|
||||
actions={
|
||||
<IconButton color="red" variant="ghost" size="minor" onClick={onClear}>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<Content tank={tank} gun={gun} selected={selected} onToggle={onToggle} />
|
||||
</LoadoutSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.equipment {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
|
||||
.slots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { alias } from "@blitzkit/core";
|
||||
import type { EquipmentSlot, TankDefinition } from "@blitzkit/protos";
|
||||
import { TrashIcon } from "@radix-ui/react-icons";
|
||||
import { chunk } from "lodash-es";
|
||||
import type { ReactNode } from "react";
|
||||
import { api } from "../../api/dynamic";
|
||||
import { equipmentColumns } from "../../config/equipment";
|
||||
import { useEquipmentPreset } from "../../hooks/useEquipmentPreset";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { useUnwrapper } from "../../hooks/useUnwrapper";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { LoadoutSection } from "../LoadoutSection";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
const equipment = await api.equipment();
|
||||
|
||||
interface ContentProps {
|
||||
tank: TankDefinition;
|
||||
selected: Record<number, number>;
|
||||
onToggle: (slotIndex: number, optionIndex: number) => void;
|
||||
}
|
||||
|
||||
function Content({ tank, selected, onToggle }: ContentProps) {
|
||||
const preset = useEquipmentPreset(tank.equipment_preset);
|
||||
|
||||
return (
|
||||
<div className={styles.equipment}>
|
||||
{chunk(preset.slots, equipmentColumns).map((slots, rowIndex) => (
|
||||
<div key={rowIndex} className={styles.row}>
|
||||
{slots.map((slot, columnIndex) => (
|
||||
<Slot
|
||||
key={columnIndex}
|
||||
slot={slot}
|
||||
slotIndex={rowIndex * equipmentColumns + columnIndex}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SlotProps {
|
||||
slot: EquipmentSlot;
|
||||
slotIndex: number;
|
||||
selected: Record<number, number>;
|
||||
onToggle: (slotIndex: number, optionIndex: number) => void;
|
||||
}
|
||||
|
||||
function Slot({ slot, slotIndex, selected, onToggle }: SlotProps) {
|
||||
const unwrap = useUnwrapper();
|
||||
|
||||
return (
|
||||
<div className={styles.slots}>
|
||||
<div className={styles.options}>
|
||||
{slot.options.map((id, optionIndex) => {
|
||||
const isSelected = selected[slotIndex] === optionIndex;
|
||||
const name = unwrap(equipment.equipments[id].name);
|
||||
|
||||
return (
|
||||
<Tooltip key={id} tooltip={name}>
|
||||
<IconButton
|
||||
size="major"
|
||||
color={isSelected ? undefined : "gray"}
|
||||
variant={isSelected ? "surface" : "soft"}
|
||||
radius="1"
|
||||
parentListChild="horizontal"
|
||||
onClick={() => onToggle(slotIndex, optionIndex)}
|
||||
>
|
||||
<img
|
||||
alt={name}
|
||||
src={alias("api", `/icons/equipment/${id}.webp`)}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadoutEquipmentProps {
|
||||
tank: TankDefinition;
|
||||
selected: Record<number, number>;
|
||||
onToggle: (slotIndex: number, optionIndex: number) => void;
|
||||
onClear: () => void;
|
||||
// Extra controls beside the clear button
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function LoadoutEquipment({
|
||||
tank,
|
||||
selected,
|
||||
onToggle,
|
||||
onClear,
|
||||
actions,
|
||||
}: LoadoutEquipmentProps) {
|
||||
const strings = useStrings();
|
||||
|
||||
return (
|
||||
<LoadoutSection
|
||||
title={strings.tanks.loadout.equipment}
|
||||
actions={
|
||||
<>
|
||||
<IconButton
|
||||
color="red"
|
||||
variant="ghost"
|
||||
size="minor"
|
||||
onClick={onClear}
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
|
||||
{actions}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Content tank={tank} selected={selected} onToggle={onToggle} />
|
||||
</LoadoutSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
.tree {
|
||||
/* The game lays the tree out left to right; here depth runs top to bottom */
|
||||
--depth-gap: var(--space-5);
|
||||
--sibling-gap: var(--space-4);
|
||||
--line-thickness: var(--space-1);
|
||||
--line-border: var(--space-1) solid var(--gray-6);
|
||||
|
||||
&[data-has-bottom-cost="true"] {
|
||||
margin-bottom: calc(var(--space-2) + var(--font-size-minor));
|
||||
}
|
||||
|
||||
display: grid;
|
||||
gap: var(--depth-gap) var(--sibling-gap);
|
||||
|
||||
.cell {
|
||||
position: relative;
|
||||
|
||||
.trunk-left,
|
||||
.trunk-right {
|
||||
position: absolute;
|
||||
top: calc(var(--depth-gap) / -2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trunk-left,
|
||||
.trunk-right,
|
||||
.snake-left,
|
||||
.snake-right {
|
||||
border-top: var(--line-border);
|
||||
border-bottom: var(--line-border);
|
||||
}
|
||||
|
||||
.trunk-left {
|
||||
right: 50%;
|
||||
}
|
||||
|
||||
.trunk-right {
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.top,
|
||||
.bottom {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
height: calc(var(--depth-gap) / 2);
|
||||
|
||||
border-left: var(--line-border);
|
||||
border-right: var(--line-border);
|
||||
}
|
||||
|
||||
.top {
|
||||
bottom: 100%;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.snake-left,
|
||||
.snake-right {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: var(--sibling-gap);
|
||||
}
|
||||
|
||||
.snake-left {
|
||||
right: 100%;
|
||||
}
|
||||
|
||||
.snake-right {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.module {
|
||||
position: relative;
|
||||
|
||||
.icon {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
|
||||
.module[data-major="true"] & {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
|
||||
font-size: var(--font-size-minor);
|
||||
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
|
||||
transform: translateX(-50%);
|
||||
|
||||
margin-top: var(--space-2);
|
||||
|
||||
background-color: var(--gray-2);
|
||||
|
||||
svg,
|
||||
img {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { alias, formatCompact, romanize, tankIcon } from "@blitzkit/core";
|
||||
import {
|
||||
ModuleType,
|
||||
type EngineDefinition,
|
||||
type GunDefinition,
|
||||
type TankDefinition,
|
||||
type TrackDefinition,
|
||||
type TurretDefinition,
|
||||
type Unlock,
|
||||
} from "@blitzkit/protos";
|
||||
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { api } from "../../api/dynamic";
|
||||
import { useLocale } from "../../hooks/useLocale";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { useUnwrapper } from "../../hooks/useUnwrapper";
|
||||
import { layoutModuleTree } from "../../tankopedia/moduleTreeLayout";
|
||||
import type { TankState } from "../../tankopedia/tankState";
|
||||
import { Discriminator } from "../Discriminator";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { LinkI18n } from "../LinkI18n";
|
||||
import { LoadoutSection } from "../LoadoutSection";
|
||||
import { Text } from "../Text";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
const tanks = await api.tanks();
|
||||
|
||||
interface ContentProps {
|
||||
tank: TankDefinition;
|
||||
selected: Pick<TankState, "turret" | "gun" | "engine" | "track">;
|
||||
onSelect: (unlock: Unlock) => void;
|
||||
}
|
||||
|
||||
const moduleIconNames: Record<ModuleType, string> = {
|
||||
[ModuleType.MODULE_TYPE_VEHICLE]: "",
|
||||
[ModuleType.MODULE_TYPE_ENGINE]: "engine",
|
||||
[ModuleType.MODULE_TYPE_TRACKS]: "chassis",
|
||||
[ModuleType.MODULE_TYPE_TURRET]: "turret",
|
||||
[ModuleType.MODULE_TYPE_GUN]: "gun",
|
||||
};
|
||||
|
||||
type AnyModule =
|
||||
| TurretDefinition
|
||||
| GunDefinition
|
||||
| TrackDefinition
|
||||
| EngineDefinition;
|
||||
|
||||
function stock(type: ModuleType, id: number) {
|
||||
return { id, type, cost: { type: "xp", value: 0 } } satisfies Unlock;
|
||||
}
|
||||
|
||||
function Content({ tank, selected, onSelect }: ContentProps) {
|
||||
const unwrap = useUnwrapper();
|
||||
|
||||
// The same gun can sit on several turrets; reversing keeps the first one,
|
||||
// which is what a linear find would return
|
||||
const modules = useMemo(
|
||||
() =>
|
||||
new Map<ModuleType, Map<number, AnyModule>>([
|
||||
[
|
||||
ModuleType.MODULE_TYPE_TRACKS,
|
||||
new Map(tank.tracks.map((track) => [track.id, track])),
|
||||
],
|
||||
[
|
||||
ModuleType.MODULE_TYPE_ENGINE,
|
||||
new Map(tank.engines.map((engine) => [engine.id, engine])),
|
||||
],
|
||||
[
|
||||
ModuleType.MODULE_TYPE_TURRET,
|
||||
new Map(tank.turrets.map((turret) => [turret.id, turret])),
|
||||
],
|
||||
[
|
||||
ModuleType.MODULE_TYPE_GUN,
|
||||
new Map(
|
||||
tank.turrets
|
||||
.flatMap((turret) => turret.guns)
|
||||
.toReversed()
|
||||
.map((gun) => [gun.id, gun])
|
||||
),
|
||||
],
|
||||
]),
|
||||
[tank]
|
||||
);
|
||||
|
||||
const resolve = useCallback(
|
||||
(unlock: Unlock) =>
|
||||
unlock.type === ModuleType.MODULE_TYPE_VEHICLE
|
||||
? (tanks.tanks[unlock.id] as TankDefinition | undefined)
|
||||
: modules.get(unlock.type)?.get(unlock.id),
|
||||
[modules]
|
||||
);
|
||||
|
||||
const { cells, columns, rows } = useMemo(
|
||||
() =>
|
||||
layoutModuleTree(
|
||||
[
|
||||
stock(ModuleType.MODULE_TYPE_GUN, tank.turrets[0].guns[0].id),
|
||||
stock(ModuleType.MODULE_TYPE_TURRET, tank.turrets[0].id),
|
||||
stock(ModuleType.MODULE_TYPE_ENGINE, tank.engines[0].id),
|
||||
stock(ModuleType.MODULE_TYPE_TRACKS, tank.tracks[0].id),
|
||||
].filter((unlock) => resolve(unlock) !== undefined),
|
||||
(unlock) => {
|
||||
const module = resolve(unlock);
|
||||
const children =
|
||||
module !== undefined && "unlocks" in module ? module.unlocks : [];
|
||||
|
||||
return children.filter((child) => resolve(child) !== undefined);
|
||||
},
|
||||
tank.module_positions
|
||||
),
|
||||
[tank, resolve]
|
||||
);
|
||||
|
||||
// Prices hang below the modules, so room is only reserved if the bottom row has one
|
||||
const bottomRow = Math.max(...cells.map((cell) => cell.position[0]));
|
||||
const hasBottomCost = cells.some(
|
||||
(cell) =>
|
||||
cell.position[0] === bottomRow &&
|
||||
cell.unlock !== undefined &&
|
||||
cell.unlock.cost!.value > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.tree}
|
||||
data-has-bottom-cost={hasBottomCost}
|
||||
style={{
|
||||
// Transposed: the layout's columns (depth) become rows
|
||||
gridTemplateColumns: `repeat(${rows}, var(--module-size))`,
|
||||
gridTemplateRows: `repeat(${columns}, var(--module-size))`,
|
||||
}}
|
||||
>
|
||||
{cells.map((cell) => {
|
||||
const module = cell.unlock && resolve(cell.unlock);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cell.position.join(",")}
|
||||
className={styles.cell}
|
||||
style={{ gridColumn: cell.position[1], gridRow: cell.position[0] }}
|
||||
>
|
||||
{cell.trunkUp && <span className={styles["trunk-left"]} />}
|
||||
{cell.trunkDown && <span className={styles["trunk-right"]} />}
|
||||
{cell.snakeUp && <span className={styles["snake-left"]} />}
|
||||
{cell.snakeDown && <span className={styles["snake-right"]} />}
|
||||
{cell.right && <span className={styles.bottom} />}
|
||||
{cell.left && <span className={styles.top} />}
|
||||
|
||||
{cell.unlock && module && (
|
||||
<ModuleNode
|
||||
unlock={cell.unlock}
|
||||
module={module}
|
||||
tank={tank}
|
||||
selected={selected}
|
||||
tooltip={unwrap(module.name!)}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModuleNodeProps {
|
||||
unlock: Unlock;
|
||||
module: AnyModule | TankDefinition;
|
||||
tank: TankDefinition;
|
||||
selected: ContentProps["selected"];
|
||||
tooltip: string;
|
||||
onSelect: ContentProps["onSelect"];
|
||||
}
|
||||
|
||||
function ModuleNode({
|
||||
unlock,
|
||||
module,
|
||||
tank,
|
||||
selected,
|
||||
tooltip,
|
||||
onSelect,
|
||||
}: ModuleNodeProps) {
|
||||
const locale = useLocale();
|
||||
|
||||
const isTank = unlock.type === ModuleType.MODULE_TYPE_VEHICLE;
|
||||
const topTurret = tank.turrets.at(-1)!;
|
||||
|
||||
const isSelected =
|
||||
(unlock.type === ModuleType.MODULE_TYPE_TURRET &&
|
||||
selected.turret === unlock.id) ||
|
||||
(unlock.type === ModuleType.MODULE_TYPE_GUN &&
|
||||
selected.gun === unlock.id) ||
|
||||
(unlock.type === ModuleType.MODULE_TYPE_ENGINE &&
|
||||
selected.engine === unlock.id) ||
|
||||
(unlock.type === ModuleType.MODULE_TYPE_TRACKS &&
|
||||
selected.track === unlock.id);
|
||||
|
||||
const isTop =
|
||||
(unlock.type === ModuleType.MODULE_TYPE_TURRET &&
|
||||
unlock.id === topTurret.id) ||
|
||||
(unlock.type === ModuleType.MODULE_TYPE_GUN &&
|
||||
unlock.id === topTurret.guns.at(-1)!.id) ||
|
||||
(unlock.type === ModuleType.MODULE_TYPE_ENGINE &&
|
||||
unlock.id === tank.engines.at(-1)!.id) ||
|
||||
(unlock.type === ModuleType.MODULE_TYPE_TRACKS &&
|
||||
unlock.id === tank.tracks.at(-1)!.id);
|
||||
|
||||
const button = (
|
||||
<IconButton
|
||||
radius="1"
|
||||
variant={isSelected ? "surface" : "soft"}
|
||||
color={isSelected ? (isTop ? "amber" : undefined) : "gray"}
|
||||
className={styles.module}
|
||||
data-major={unlock.type === ModuleType.MODULE_TYPE_VEHICLE}
|
||||
onClick={() => {
|
||||
if (!isTank) onSelect(unlock);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={tooltip}
|
||||
src={
|
||||
isTank
|
||||
? tankIcon(unlock.id)
|
||||
: alias(
|
||||
"api",
|
||||
`/icons/modules/${moduleIconNames[unlock.type]}.webp`
|
||||
)
|
||||
}
|
||||
className={styles.icon}
|
||||
/>
|
||||
|
||||
<Discriminator corner="top-left">{romanize(module.tier)}</Discriminator>
|
||||
|
||||
{unlock.cost!.value > 0 && (
|
||||
<div className={styles.price}>
|
||||
<Text size="minor">{formatCompact(locale, unlock.cost!.value)}</Text>
|
||||
<img
|
||||
alt={unlock.cost!.type}
|
||||
src={alias("api", `/icons/currencies/${unlock.cost!.type}.webp`)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip tooltip={tooltip}>
|
||||
{isTank ? (
|
||||
<LinkI18n
|
||||
locale={locale}
|
||||
href={`/tanks/${tanks.tanks[unlock.id].slug}`}
|
||||
>
|
||||
{button}
|
||||
</LinkI18n>
|
||||
) : (
|
||||
button
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadoutModulesProps {
|
||||
tank: TankDefinition;
|
||||
selected: Pick<TankState, "turret" | "gun" | "engine" | "track">;
|
||||
onSelect: (unlock: Unlock) => void;
|
||||
onStock: () => void;
|
||||
onMaximize: () => void;
|
||||
}
|
||||
|
||||
export function LoadoutModules({
|
||||
tank,
|
||||
selected,
|
||||
onSelect,
|
||||
onStock,
|
||||
onMaximize,
|
||||
}: LoadoutModulesProps) {
|
||||
const strings = useStrings();
|
||||
const hasUpgrades =
|
||||
tank.turrets.length > 1 ||
|
||||
tank.turrets[0].guns.length > 1 ||
|
||||
tank.engines.length > 1 ||
|
||||
tank.tracks.length > 1;
|
||||
|
||||
return (
|
||||
<LoadoutSection
|
||||
title={strings.tanks.loadout.modules}
|
||||
actions={
|
||||
hasUpgrades && (
|
||||
<>
|
||||
<IconButton
|
||||
highContrast
|
||||
color="gray"
|
||||
size="minor"
|
||||
variant="ghost"
|
||||
onClick={onStock}
|
||||
>
|
||||
<DoubleArrowUpIcon />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
highContrast
|
||||
color="gray"
|
||||
size="minor"
|
||||
variant="ghost"
|
||||
onClick={onMaximize}
|
||||
>
|
||||
<DoubleArrowDownIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Content tank={tank} selected={selected} onSelect={onSelect} />
|
||||
</LoadoutSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.provisions {
|
||||
width: 0;
|
||||
min-width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
|
||||
&[data-implicit-width="true"] {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { alias } from "@blitzkit/core";
|
||||
import type { GunDefinition, TankDefinition } from "@blitzkit/protos";
|
||||
import { TrashIcon } from "@radix-ui/react-icons";
|
||||
import { api } from "../../api/dynamic";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { useTankCompatibility } from "../../hooks/useTankCompatibility";
|
||||
import { useUnwrapper } from "../../hooks/useUnwrapper";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { LoadoutSection } from "../LoadoutSection";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
const provisions = await api.provisions();
|
||||
|
||||
interface ContentProps {
|
||||
tank: TankDefinition;
|
||||
gun: GunDefinition;
|
||||
selected: number[];
|
||||
onToggle: (id: number) => void;
|
||||
// Sizes to its content instead of filling the parent
|
||||
implicitWidth?: boolean;
|
||||
}
|
||||
|
||||
function Content({
|
||||
tank,
|
||||
gun,
|
||||
selected,
|
||||
onToggle,
|
||||
implicitWidth,
|
||||
}: ContentProps) {
|
||||
const unwrap = useUnwrapper();
|
||||
const isCompatible = useTankCompatibility(tank, gun);
|
||||
|
||||
return (
|
||||
<div className={styles.provisions} data-implicit-width={implicitWidth}>
|
||||
{Object.values(provisions.provisions).map((provision) => {
|
||||
if (
|
||||
provision.game_mode_exclusive ||
|
||||
!isCompatible(provision.include, provision.exclude)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSelected = selected.includes(provision.id);
|
||||
const disabled = !isSelected && selected.length >= tank.max_provisions;
|
||||
const name = unwrap(provision.name);
|
||||
|
||||
return (
|
||||
<Tooltip key={provision.id} tooltip={name}>
|
||||
<IconButton
|
||||
size="major"
|
||||
radius="1"
|
||||
variant={isSelected ? "surface" : "soft"}
|
||||
color={isSelected ? undefined : "gray"}
|
||||
disabled={disabled}
|
||||
onClick={() => onToggle(provision.id)}
|
||||
>
|
||||
<img
|
||||
src={alias("api", `/icons/provisions/${provision.id}.webp`)}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadoutProvisionsProps {
|
||||
tank: TankDefinition;
|
||||
gun: GunDefinition;
|
||||
selected: number[];
|
||||
onToggle: (id: number) => void;
|
||||
onClear: () => void;
|
||||
// Sizes to its content instead of filling the parent
|
||||
implicitWidth?: boolean;
|
||||
}
|
||||
|
||||
export function LoadoutProvisions({
|
||||
tank,
|
||||
gun,
|
||||
selected,
|
||||
onToggle,
|
||||
onClear,
|
||||
implicitWidth,
|
||||
}: LoadoutProvisionsProps) {
|
||||
const strings = useStrings();
|
||||
|
||||
return (
|
||||
<LoadoutSection
|
||||
title={strings.tanks.loadout.provisions}
|
||||
actions={
|
||||
<IconButton
|
||||
color="red"
|
||||
variant="ghost"
|
||||
size="minor"
|
||||
onClick={onClear}
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<Content
|
||||
tank={tank}
|
||||
gun={gun}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
implicitWidth={implicitWidth}
|
||||
/>
|
||||
</LoadoutSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Heading } from "../Heading";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
interface LoadoutSectionProps {
|
||||
title: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function LoadoutSection({
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
}: LoadoutSectionProps) {
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.header}>
|
||||
<Heading size="4">{title}</Heading>
|
||||
|
||||
{actions && <div className={styles.actions}>{actions}</div>}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.shells {
|
||||
display: flex;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { alias } from "@blitzkit/core";
|
||||
import type { GunDefinition } from "@blitzkit/protos";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { useUnwrapper } from "../../hooks/useUnwrapper";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { LoadoutSection } from "../LoadoutSection";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
interface LoadoutShellsProps {
|
||||
gun: GunDefinition;
|
||||
selected: number;
|
||||
onSelect: (id: number) => void;
|
||||
}
|
||||
|
||||
export function LoadoutShells({ gun, selected, onSelect }: LoadoutShellsProps) {
|
||||
const strings = useStrings();
|
||||
const unwrap = useUnwrapper();
|
||||
|
||||
return (
|
||||
<LoadoutSection title={strings.tanks.loadout.shells}>
|
||||
<div className={styles.shells}>
|
||||
{gun.shells.map((candidate) => {
|
||||
const isSelected = candidate.id === selected;
|
||||
const name = unwrap(candidate.name!);
|
||||
|
||||
return (
|
||||
<Tooltip key={candidate.id} tooltip={name}>
|
||||
<IconButton
|
||||
size="major"
|
||||
radius="1"
|
||||
color={isSelected ? undefined : "gray"}
|
||||
variant={isSelected ? "surface" : "soft"}
|
||||
parentListChild="horizontal"
|
||||
onClick={() => onSelect(candidate.id)}
|
||||
>
|
||||
<img
|
||||
alt={name}
|
||||
src={alias("api", `/icons/shells/${candidate.icon}.webp`)}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</LoadoutSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.skills {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: inherit;
|
||||
}
|
||||
|
||||
.skill {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
alias,
|
||||
MAX_SKILL_LEVEL,
|
||||
permanentSkills,
|
||||
romanize,
|
||||
} from "@blitzkit/core";
|
||||
import { DoubleArrowUpIcon, TrashIcon } from "@radix-ui/react-icons";
|
||||
import { api } from "../../api/dynamic";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { Discriminator } from "../Discriminator";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { LoadoutSection } from "../LoadoutSection";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
const skills = await api.skills();
|
||||
|
||||
interface ContentProps {
|
||||
selected: Record<string, number>;
|
||||
onChange: (skill: string, level: number) => void;
|
||||
}
|
||||
|
||||
function Content({ selected, onChange }: ContentProps) {
|
||||
return (
|
||||
<div className={styles.skills}>
|
||||
{Object.entries(skills.classes).map(([tankClass, group]) => (
|
||||
<div key={tankClass} className={styles.row}>
|
||||
{group.skills.map((skill) => {
|
||||
const level = selected[skill];
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
key={skill}
|
||||
size="major"
|
||||
radius="1"
|
||||
className={styles.skill}
|
||||
color={
|
||||
level === 0
|
||||
? "gray"
|
||||
: permanentSkills.includes(skill)
|
||||
? undefined
|
||||
: "amber"
|
||||
}
|
||||
variant={level === 0 ? "soft" : "surface"}
|
||||
onClick={(event) => {
|
||||
if (event.shiftKey) {
|
||||
onChange(skill, level === 0 ? MAX_SKILL_LEVEL : 0);
|
||||
} else {
|
||||
onChange(skill, level === 0 ? MAX_SKILL_LEVEL : level - 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img src={alias("api", `/icons/skills/${skill}.webp`)} />
|
||||
{level > 0 && (
|
||||
<Discriminator inset="small">{romanize(level)}</Discriminator>
|
||||
)}
|
||||
</IconButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadoutSkillsProps {
|
||||
selected: Record<string, number>;
|
||||
onChange: (skill: string, level: number) => void;
|
||||
onClear: () => void;
|
||||
onMaximize: () => void;
|
||||
}
|
||||
|
||||
export function LoadoutSkills({
|
||||
selected,
|
||||
onChange,
|
||||
onClear,
|
||||
onMaximize,
|
||||
}: LoadoutSkillsProps) {
|
||||
const strings = useStrings();
|
||||
|
||||
return (
|
||||
<LoadoutSection
|
||||
title={strings.tanks.loadout.skills}
|
||||
actions={
|
||||
<>
|
||||
<IconButton
|
||||
color="red"
|
||||
variant="ghost"
|
||||
size="minor"
|
||||
onClick={onClear}
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
highContrast
|
||||
color="gray"
|
||||
size="minor"
|
||||
variant="ghost"
|
||||
onClick={onMaximize}
|
||||
>
|
||||
<DoubleArrowUpIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Content selected={selected} onChange={onChange} />
|
||||
</LoadoutSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
|
||||
.state {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { TrashIcon } from "@radix-ui/react-icons";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import {
|
||||
vehicleStatusKeys,
|
||||
type VehicleStatusKey,
|
||||
} from "../../tankopedia/tankState";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { LoadoutSection } from "../LoadoutSection";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
interface LoadoutStatusProps {
|
||||
status: Record<VehicleStatusKey, boolean>;
|
||||
onToggle: (key: VehicleStatusKey) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function LoadoutStatus({
|
||||
status,
|
||||
onToggle,
|
||||
onClear,
|
||||
}: LoadoutStatusProps) {
|
||||
const strings = useStrings();
|
||||
|
||||
return (
|
||||
<LoadoutSection
|
||||
title={strings.tanks.loadout.status}
|
||||
actions={
|
||||
<IconButton
|
||||
color="red"
|
||||
variant="ghost"
|
||||
size="minor"
|
||||
onClick={onClear}
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<div className={styles.status}>
|
||||
{vehicleStatusKeys.map((group, index) => (
|
||||
<div key={index} className={styles.group}>
|
||||
{group.map((key) => (
|
||||
<IconButton
|
||||
key={key}
|
||||
aria-pressed={status[key]}
|
||||
radius="1"
|
||||
className={styles.state}
|
||||
color={status[key] ? "amber" : "gray"}
|
||||
variant={status[key] ? "surface" : "soft"}
|
||||
onClick={() => onToggle(key)}
|
||||
>
|
||||
<img src={`/media/status/${key}.png`} />
|
||||
</IconButton>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LoadoutSection>
|
||||
);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { romanize } from "@blitzkit/core";
|
||||
import type {
|
||||
EngineDefinition,
|
||||
GunDefinition,
|
||||
ShellDefinition,
|
||||
TankDefinition,
|
||||
TrackDefinition,
|
||||
TurretDefinition,
|
||||
} from "@blitzkit/protos";
|
||||
import { DropdownMenu, Flex } from "@radix-ui/themes";
|
||||
import { useState } from "react";
|
||||
import { useLocale } from "../hooks/useLocale";
|
||||
import { ModuleButton } from "./ModuleButtons/ModuleButton";
|
||||
|
||||
interface ModuleManagerProps {
|
||||
tank: TankDefinition;
|
||||
turret: TurretDefinition;
|
||||
gun: GunDefinition;
|
||||
shell: ShellDefinition;
|
||||
engine: EngineDefinition;
|
||||
track: TrackDefinition;
|
||||
onChange?: (modules: {
|
||||
turret: TurretDefinition;
|
||||
gun: GunDefinition;
|
||||
engine: EngineDefinition;
|
||||
track: TrackDefinition;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function ModuleManager({
|
||||
tank,
|
||||
onChange,
|
||||
...modules
|
||||
}: ModuleManagerProps) {
|
||||
const [turretMenuOpen, setTurretMenuOpen] = useState(false);
|
||||
const [gunMenuOpen, setGunMenuOpen] = useState(false);
|
||||
const [engineMenuOpen, setEngineMenuOpen] = useState(false);
|
||||
const [trackMenuOpen, setTrackMenuOpen] = useState(false);
|
||||
const { unwrap } = useLocale();
|
||||
|
||||
return (
|
||||
<Flex gap="1" wrap="wrap">
|
||||
<ModuleButton
|
||||
module="turret"
|
||||
discriminator={romanize(modules.turret.tier)}
|
||||
onClick={() => setTurretMenuOpen(true)}
|
||||
/>
|
||||
|
||||
<DropdownMenu.Root open={turretMenuOpen} onOpenChange={setTurretMenuOpen}>
|
||||
<DropdownMenu.Trigger>
|
||||
<div />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.RadioGroup value={`${modules.turret.id}`}>
|
||||
{[...tank.turrets].reverse().map((turret) => {
|
||||
return (
|
||||
<DropdownMenu.RadioItem
|
||||
key={turret.id}
|
||||
value={`${turret.id}`}
|
||||
onClick={() => {
|
||||
const draft = { ...modules };
|
||||
draft.turret = turret;
|
||||
draft.gun = draft.turret.guns.at(-1)!;
|
||||
draft.shell = draft.gun.shells[0];
|
||||
|
||||
onChange?.(draft);
|
||||
setTurretMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ModuleButton
|
||||
variant="ghost"
|
||||
module="turret"
|
||||
discriminator={romanize(turret.tier)}
|
||||
/>{" "}
|
||||
{unwrap(turret.name!)}
|
||||
</DropdownMenu.RadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<ModuleButton
|
||||
module="gun"
|
||||
discriminator={romanize(modules.gun.tier)}
|
||||
onClick={() => setGunMenuOpen(true)}
|
||||
/>
|
||||
|
||||
<DropdownMenu.Root open={gunMenuOpen} onOpenChange={setGunMenuOpen}>
|
||||
<DropdownMenu.Trigger>
|
||||
<div />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.RadioGroup value={`${modules.gun.id}`}>
|
||||
{[...modules.turret.guns].reverse().map((gun) => {
|
||||
return (
|
||||
<DropdownMenu.RadioItem
|
||||
key={gun.id}
|
||||
value={`${gun.id}`}
|
||||
onClick={() => {
|
||||
const draft = { ...modules };
|
||||
draft.gun = gun;
|
||||
draft.shell = draft.gun.shells[0];
|
||||
|
||||
onChange?.(draft);
|
||||
setGunMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ModuleButton
|
||||
variant="ghost"
|
||||
module="gun"
|
||||
discriminator={romanize(gun.tier)}
|
||||
/>{" "}
|
||||
{unwrap(gun.name!)}
|
||||
</DropdownMenu.RadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<ModuleButton
|
||||
module="engine"
|
||||
discriminator={romanize(modules.engine.tier)}
|
||||
onClick={() => setEngineMenuOpen(true)}
|
||||
/>
|
||||
|
||||
<DropdownMenu.Root open={engineMenuOpen} onOpenChange={setEngineMenuOpen}>
|
||||
<DropdownMenu.Trigger>
|
||||
<div />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.RadioGroup value={`${modules.engine.id}`}>
|
||||
{[...tank.engines].reverse().map((engine) => {
|
||||
return (
|
||||
<DropdownMenu.RadioItem
|
||||
key={engine.id}
|
||||
value={`${engine.id}`}
|
||||
onClick={() => {
|
||||
const draft = { ...modules };
|
||||
draft.engine = engine;
|
||||
|
||||
onChange?.(draft);
|
||||
setEngineMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ModuleButton
|
||||
variant="ghost"
|
||||
module="engine"
|
||||
discriminator={romanize(engine.tier)}
|
||||
/>{" "}
|
||||
{unwrap(engine.name!)}
|
||||
</DropdownMenu.RadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<ModuleButton
|
||||
module="chassis"
|
||||
discriminator={romanize(modules.track.tier)}
|
||||
onClick={() => setTrackMenuOpen(true)}
|
||||
/>
|
||||
|
||||
<DropdownMenu.Root open={trackMenuOpen} onOpenChange={setTrackMenuOpen}>
|
||||
<DropdownMenu.Trigger>
|
||||
<div />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.RadioGroup value={`${modules.track.id}`}>
|
||||
{[...tank.tracks].reverse().map((track) => {
|
||||
return (
|
||||
<DropdownMenu.RadioItem
|
||||
key={track.id}
|
||||
value={`${track.id}`}
|
||||
onClick={() => {
|
||||
const draft = { ...modules };
|
||||
draft.track = track;
|
||||
|
||||
onChange?.(draft);
|
||||
setTrackMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ModuleButton
|
||||
variant="ghost"
|
||||
module="chassis"
|
||||
discriminator={romanize(track.tier)}
|
||||
/>{" "}
|
||||
{unwrap(track.name!)}
|
||||
</DropdownMenu.RadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { nameToArmorId } from "../core/blitzkit/nameToArmorId";
|
||||
import { resolveArmor } from "../core/blitzkit/resolveThickness";
|
||||
import { useArmor } from "../hooks/useArmor";
|
||||
import { useConsumable } from "../hooks/useConsumable";
|
||||
import { DuelModule, useDuel } from "../hooks/useDuel";
|
||||
import { useDuelModel } from "../hooks/useDuelModel";
|
||||
import { useDuelModels } from "../hooks/useDuelModels";
|
||||
import { useDuelModules } from "../hooks/useDuelModules";
|
||||
import { useTankTransform } from "../hooks/useTankTransform";
|
||||
import { toThreeVector } from "../three/toThreeVector";
|
||||
import { PrimaryArmorSceneComponent } from "./PrimaryArmorSceneComponent";
|
||||
@@ -13,22 +13,21 @@ import { PrimaryArmorSceneComponent } from "./PrimaryArmorSceneComponent";
|
||||
export const PrimaryArmorScene = memo(() => {
|
||||
const turretContainer = useRef<Group>(null!);
|
||||
const gunContainer = useRef<Group>(null!);
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const track = useDuel("protagonist", DuelModule.Track);
|
||||
const turret = useDuel("protagonist", DuelModule.Turret);
|
||||
const gun = useDuel("protagonist", DuelModule.Gun);
|
||||
const armorGltf = useArmor(tank.id).gltf;
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
const armorGltf = useArmor(protagonist.tank.id).gltf;
|
||||
const armorNodes = Object.values(armorGltf.nodes);
|
||||
const tankModelDefinition = useDuelModel("protagonist");
|
||||
const trackModelDefinition = tankModelDefinition.tracks[track.id];
|
||||
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
||||
const hullOrigin = toThreeVector(trackModelDefinition.origin);
|
||||
const turretOrigin = toThreeVector(tankModelDefinition.turret_origin);
|
||||
const gunOrigin = toThreeVector(turretModelDefinition.gun_origin);
|
||||
const models = useDuelModels("protagonist");
|
||||
const hullOrigin = toThreeVector(models.track.origin);
|
||||
const turretOrigin = toThreeVector(models.tank.turret_origin);
|
||||
const gunOrigin = toThreeVector(models.turret.gun_origin);
|
||||
const isDynamicArmorActive = useConsumable("protagonist", 73);
|
||||
|
||||
useTankTransform(track, turret, turretContainer, gunContainer);
|
||||
useTankTransform(
|
||||
protagonist.track,
|
||||
protagonist.turret,
|
||||
turretContainer,
|
||||
gunContainer,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -38,7 +37,7 @@ export const PrimaryArmorScene = memo(() => {
|
||||
const isVisible = isHull;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
tankModelDefinition.armor!,
|
||||
models.tank.armor!,
|
||||
armorId,
|
||||
);
|
||||
|
||||
@@ -64,14 +63,12 @@ export const PrimaryArmorScene = memo(() => {
|
||||
<group ref={turretContainer}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentTurret = node.name.startsWith(
|
||||
`turret_${turretModelDefinition.model_id
|
||||
.toString()
|
||||
.padStart(2, "0")}`,
|
||||
`turret_${models.turret.model_id.toString().padStart(2, "0")}`,
|
||||
);
|
||||
const isVisible = isCurrentTurret;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
turretModelDefinition.armor!,
|
||||
models.turret.armor!,
|
||||
armorId,
|
||||
);
|
||||
|
||||
@@ -100,12 +97,12 @@ export const PrimaryArmorScene = memo(() => {
|
||||
<group ref={gunContainer}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentGun = node.name.startsWith(
|
||||
`gun_${gunModelDefinition.model_id.toString().padStart(2, "0")}`,
|
||||
`gun_${models.gun.model_id.toString().padStart(2, "0")}`,
|
||||
);
|
||||
const isVisible = isCurrentGun;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
gunModelDefinition.armor!,
|
||||
models.gun.armor!,
|
||||
armorId,
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "three";
|
||||
import { degToRad } from "three/src/math/MathUtils.js";
|
||||
import { jsxTree } from "../../core/blitzkit/jsxTree";
|
||||
import { DuelModule, getDuel } from "../../hooks/useDuel";
|
||||
import { getDuelModules } from "../../hooks/useDuelModules";
|
||||
import { hasEquipment } from "../../hooks/useEquipment";
|
||||
import { Tankopedia } from "../../stores/tankopedia";
|
||||
import { TankopediaPersistent } from "../../stores/tankopediaPersistent";
|
||||
@@ -82,7 +82,7 @@ export function PrimaryArmorSceneComponent({
|
||||
useEffect(() => {
|
||||
function handleShellChange() {
|
||||
const shell =
|
||||
Tankopedia.state.customShell ?? getDuel("antagonist", DuelModule.Shell);
|
||||
Tankopedia.state.customShell ?? getDuelModules("antagonist").shell;
|
||||
|
||||
material.current.uniforms.caliber.value = shell.caliber;
|
||||
material.current.uniforms.ricochet.value = degToRad(
|
||||
@@ -118,7 +118,7 @@ export function PrimaryArmorSceneComponent({
|
||||
}
|
||||
function handleProtagonistEquipmentChange(noInvalidate = false) {
|
||||
const equipment = Tankopedia.state.protagonist.equipment;
|
||||
const protagonist = getDuel("protagonist", DuelModule.Tank);
|
||||
const protagonist = getDuelModules("protagonist").tank;
|
||||
const hasEnhancedArmor = hasEquipment(
|
||||
110,
|
||||
protagonist.equipment_preset,
|
||||
@@ -135,13 +135,12 @@ export function PrimaryArmorSceneComponent({
|
||||
if (!noInvalidate) invalidate();
|
||||
}
|
||||
function handleAntagonistEquipmentChange(noInvalidate = false) {
|
||||
const antagonist = getDuel("antagonist", DuelModule.Tank);
|
||||
const antagonist = getDuelModules("antagonist").tank;
|
||||
|
||||
const equipment = Tankopedia.state.antagonist.equipment;
|
||||
const tankopediaEphemeral = Tankopedia.state;
|
||||
const shell =
|
||||
tankopediaEphemeral.customShell ??
|
||||
getDuel("antagonist", DuelModule.Shell);
|
||||
tankopediaEphemeral.customShell ?? getDuelModules("antagonist").shell;
|
||||
const penetration = shell.penetration!.near;
|
||||
const hasCalibratedShells = hasEquipment(
|
||||
103,
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Flex } from "@radix-ui/themes";
|
||||
import { api } from "../api/dynamic";
|
||||
import { ProvisionButton } from "./ModuleButtons/ProvisionButton";
|
||||
|
||||
interface ProvisionsManagerProps {
|
||||
provisions: number[];
|
||||
selected: number[];
|
||||
disabled?: boolean;
|
||||
onChange?: (provisions: number[]) => void;
|
||||
}
|
||||
|
||||
const provisionDefinitions = await api.provisions();
|
||||
|
||||
export function ProvisionsManager({
|
||||
provisions,
|
||||
selected,
|
||||
onChange,
|
||||
disabled,
|
||||
}: ProvisionsManagerProps) {
|
||||
return (
|
||||
<Flex wrap="wrap" gap="2" justify={{ initial: "center", sm: "start" }}>
|
||||
{provisions.map((id) => {
|
||||
const isSelected = selected.includes(id);
|
||||
const provision = provisionDefinitions.provisions[id];
|
||||
|
||||
if (provision.game_mode_exclusive) return null;
|
||||
|
||||
return (
|
||||
<ProvisionButton
|
||||
key={id}
|
||||
disabled={disabled && !isSelected}
|
||||
provision={id}
|
||||
selected={isSelected}
|
||||
onClick={() => {
|
||||
if (!onChange) return;
|
||||
|
||||
const draft = [...selected];
|
||||
|
||||
if (isSelected) {
|
||||
draft.splice(draft.indexOf(id), 1);
|
||||
} else {
|
||||
draft.push(id);
|
||||
}
|
||||
|
||||
onChange(draft);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
.scroller {
|
||||
--scroller-curtain-color: var(--gray-1);
|
||||
--scroller-curtain-size: min(25%, var(--space-6));
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -10,31 +11,56 @@
|
||||
.content {
|
||||
overflow: auto;
|
||||
|
||||
padding-bottom: var(--space-4);
|
||||
/* Contains children's stacking so the bars always sit above them */
|
||||
isolation: isolate;
|
||||
|
||||
transition: scrollbar-color var(--duration-brief);
|
||||
scrollbar-color: transparent transparent;
|
||||
/* The custom bars overlay the content instead of taking a gutter */
|
||||
scrollbar-width: none;
|
||||
|
||||
&:hover {
|
||||
scrollbar-color: var(--gray-7) var(--gray-3);
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
.bar-wrapper {
|
||||
&:focus-within,
|
||||
&:has([data-dragging="true"]) {
|
||||
.bar {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-orientation="horizontal"] {
|
||||
.curtain {
|
||||
width: min(25%, var(--space-7));
|
||||
height: calc(100% - var(--space-3) - 2 * var(--space-1));
|
||||
&[data-orientation="vertical"],
|
||||
&[data-orientation="both"] {
|
||||
min-height: 0;
|
||||
|
||||
position: absolute;
|
||||
.content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-orientation="vertical"] {
|
||||
.content {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.curtain {
|
||||
position: absolute;
|
||||
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&[data-axis="x"] {
|
||||
top: 0;
|
||||
|
||||
width: var(--scroller-curtain-size);
|
||||
height: 100%;
|
||||
|
||||
&[data-side="start"] {
|
||||
left: 0;
|
||||
background: linear-gradient(
|
||||
@@ -53,28 +79,13 @@
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-orientation="vertical"] {
|
||||
min-height: 0;
|
||||
|
||||
.content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.curtain {
|
||||
width: calc(100% - var(--space-3) - 2 * var(--space-1));
|
||||
height: min(25%, var(--space-7));
|
||||
|
||||
position: absolute;
|
||||
&[data-axis="y"] {
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
height: var(--scroller-curtain-size);
|
||||
|
||||
&[data-side="start"] {
|
||||
top: 0;
|
||||
background: linear-gradient(
|
||||
@@ -95,8 +106,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
.curtain {
|
||||
.bar {
|
||||
position: absolute;
|
||||
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--duration-brief);
|
||||
|
||||
&[data-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[data-axis="x"] {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: var(--space-4);
|
||||
}
|
||||
|
||||
&[data-axis="y"] {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: var(--space-4);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
|
||||
border-radius: var(--max-size);
|
||||
background-color: var(--gray-a8);
|
||||
touch-action: none;
|
||||
|
||||
&:hover,
|
||||
&[data-dragging="true"] {
|
||||
background-color: var(--gray-a10);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-axis="x"] .thumb {
|
||||
top: var(--space-2);
|
||||
height: var(--space-3);
|
||||
}
|
||||
|
||||
&[data-axis="y"] .thumb {
|
||||
left: var(--space-2);
|
||||
width: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Leaves the shared corner to neither bar */
|
||||
&[data-orientation="both"] {
|
||||
.bar[data-axis="x"] {
|
||||
right: var(--space-4);
|
||||
}
|
||||
|
||||
.bar[data-axis="y"] {
|
||||
bottom: var(--space-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,257 @@
|
||||
import { useEffect, useRef, type ComponentProps } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ComponentProps,
|
||||
type PointerEvent,
|
||||
} from "react";
|
||||
import { clamp } from "three/src/math/MathUtils.js";
|
||||
import { classNames } from "../../ui/classNames";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export enum ScrollerOrientation {
|
||||
Horizontal = "horizontal",
|
||||
Vertical = "vertical",
|
||||
Both = "both",
|
||||
}
|
||||
|
||||
enum Axis {
|
||||
X = "x",
|
||||
Y = "y",
|
||||
}
|
||||
|
||||
enum Side {
|
||||
Start = "start",
|
||||
End = "end",
|
||||
}
|
||||
|
||||
interface ScrollerProps extends ComponentProps<"div"> {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
orientation?: ScrollerOrientation;
|
||||
|
||||
// Whether the fade at each end of the scroll axis is rendered
|
||||
startCurtain?: boolean;
|
||||
endCurtain?: boolean;
|
||||
}
|
||||
|
||||
const MIN_THUMB_SIZE = 32;
|
||||
const MAX_FADE_DISTANCE = 60;
|
||||
const FADE_RATIO = 0.15;
|
||||
|
||||
const SIDES = [Side.Start, Side.End];
|
||||
|
||||
const ORIENTATION_AXES = {
|
||||
[ScrollerOrientation.Horizontal]: [Axis.X],
|
||||
[ScrollerOrientation.Vertical]: [Axis.Y],
|
||||
[ScrollerOrientation.Both]: [Axis.X, Axis.Y],
|
||||
} satisfies Record<ScrollerOrientation, Axis[]>;
|
||||
|
||||
// DOM property names per axis so the logic below stays axis-agnostic
|
||||
const DIMENSIONS = {
|
||||
[Axis.X]: {
|
||||
size: "width",
|
||||
scroll: "scrollLeft",
|
||||
scrollSize: "scrollWidth",
|
||||
clientSize: "clientWidth",
|
||||
offsetSize: "offsetWidth",
|
||||
pointer: "clientX",
|
||||
rectStart: "left",
|
||||
translate: "translateX",
|
||||
},
|
||||
[Axis.Y]: {
|
||||
size: "height",
|
||||
scroll: "scrollTop",
|
||||
scrollSize: "scrollHeight",
|
||||
clientSize: "clientHeight",
|
||||
offsetSize: "offsetHeight",
|
||||
pointer: "clientY",
|
||||
rectStart: "top",
|
||||
translate: "translateY",
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface Drag {
|
||||
pointer: number;
|
||||
scroll: number;
|
||||
// Content pixels scrolled per pixel of thumb travel
|
||||
ratio: number;
|
||||
axis: Axis;
|
||||
}
|
||||
|
||||
export function Scroller({
|
||||
children,
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
orientation = ScrollerOrientation.Horizontal,
|
||||
startCurtain = true,
|
||||
endCurtain = true,
|
||||
...props
|
||||
}: ScrollerProps) {
|
||||
const content = useRef<HTMLDivElement>(null!);
|
||||
const curtainStart = useRef<HTMLDivElement>(null!);
|
||||
const curtainEnd = useRef<HTMLDivElement>(null!);
|
||||
const curtains = useRef<Record<Axis, Record<Side, HTMLDivElement | null>>>({
|
||||
[Axis.X]: { [Side.Start]: null, [Side.End]: null },
|
||||
[Axis.Y]: { [Side.Start]: null, [Side.End]: null },
|
||||
});
|
||||
const bars = useRef<Record<Axis, HTMLDivElement | null>>({
|
||||
[Axis.X]: null,
|
||||
[Axis.Y]: null,
|
||||
});
|
||||
const thumbs = useRef<Record<Axis, HTMLDivElement | null>>({
|
||||
[Axis.X]: null,
|
||||
[Axis.Y]: null,
|
||||
});
|
||||
const drag = useRef<Drag | undefined>(undefined);
|
||||
|
||||
const isHorizontal = orientation === "horizontal";
|
||||
const axes = ORIENTATION_AXES[orientation];
|
||||
const curtainSides = SIDES.filter((side) =>
|
||||
side === Side.Start ? startCurtain : endCurtain,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function syncCurtains() {
|
||||
const maxScroll = isHorizontal
|
||||
? content.current.scrollWidth - content.current.clientWidth
|
||||
: content.current.scrollHeight - content.current.clientHeight;
|
||||
const scrollPosition = isHorizontal
|
||||
? content.current.scrollLeft
|
||||
: content.current.scrollTop;
|
||||
const sync = useCallback(() => {
|
||||
const element = content.current;
|
||||
|
||||
if (maxScroll <= 0) {
|
||||
curtainStart.current.style.opacity = "0";
|
||||
curtainEnd.current.style.opacity = "0";
|
||||
for (const axis of axes) {
|
||||
const dimension = DIMENSIONS[axis];
|
||||
const scrollSize = element[dimension.scrollSize];
|
||||
const clientSize = element[dimension.clientSize];
|
||||
const position = element[dimension.scroll];
|
||||
const overflow = scrollSize - clientSize;
|
||||
|
||||
return;
|
||||
// Curtains fade the edges the scrolling content slides under
|
||||
const fadeDistance = Math.min(MAX_FADE_DISTANCE, clientSize * FADE_RATIO);
|
||||
const curtain = curtains.current[axis];
|
||||
const start = curtain[Side.Start];
|
||||
const end = curtain[Side.End];
|
||||
|
||||
if (start) {
|
||||
start.style.opacity = `${
|
||||
overflow <= 0 ? 0 : clamp(position / fadeDistance, 0, 1)
|
||||
}`;
|
||||
}
|
||||
|
||||
const dimension = isHorizontal
|
||||
? content.current.clientWidth
|
||||
: content.current.clientHeight;
|
||||
const fadeDistance = Math.min(60, dimension * 0.15);
|
||||
const startOpacity = clamp(scrollPosition / fadeDistance, 0, 1);
|
||||
const endOpacity = clamp(
|
||||
(maxScroll - scrollPosition) / fadeDistance,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (end) {
|
||||
end.style.opacity = `${
|
||||
overflow <= 0 ? 0 : clamp((overflow - position) / fadeDistance, 0, 1)
|
||||
}`;
|
||||
}
|
||||
|
||||
curtainStart.current.style.opacity = `${startOpacity}`;
|
||||
curtainEnd.current.style.opacity = `${endOpacity}`;
|
||||
// Thumbs mirror the scroll position over the track
|
||||
const bar = bars.current[axis];
|
||||
const thumb = thumbs.current[axis];
|
||||
|
||||
if (!bar || !thumb) continue;
|
||||
|
||||
const hidden = overflow <= 1;
|
||||
bar.dataset.hidden = `${hidden}`;
|
||||
|
||||
if (hidden) continue;
|
||||
|
||||
const track = bar[dimension.clientSize];
|
||||
const size = Math.max(MIN_THUMB_SIZE, (track * clientSize) / scrollSize);
|
||||
const offset = (position / overflow) * (track - size);
|
||||
|
||||
thumb.style[dimension.size] = `${size}px`;
|
||||
thumb.style.transform = `${dimension.translate}(${offset}px)`;
|
||||
}
|
||||
}, [axes]);
|
||||
|
||||
syncCurtains();
|
||||
useEffect(() => {
|
||||
const element = content.current;
|
||||
const resizeObserver = new ResizeObserver(sync);
|
||||
|
||||
window.addEventListener("resize", syncCurtains);
|
||||
content.current.addEventListener("scroll", syncCurtains);
|
||||
// Tracks children as they mount and unmount instead of re-subscribing every render
|
||||
const mutationObserver = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
record.removedNodes.forEach((node) => {
|
||||
if (node instanceof Element) resizeObserver.unobserve(node);
|
||||
});
|
||||
record.addedNodes.forEach((node) => {
|
||||
if (node instanceof Element) resizeObserver.observe(node);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(element);
|
||||
Array.from(element.children).forEach((child) =>
|
||||
resizeObserver.observe(child),
|
||||
);
|
||||
mutationObserver.observe(element, { childList: true });
|
||||
element.addEventListener("scroll", sync, { passive: true });
|
||||
|
||||
sync();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", syncCurtains);
|
||||
content.current.removeEventListener("scroll", syncCurtains);
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
element.removeEventListener("scroll", sync);
|
||||
};
|
||||
}, [orientation]);
|
||||
}, [sync]);
|
||||
|
||||
const handleThumbPointerDown = useCallback(
|
||||
(axis: Axis, event: PointerEvent<HTMLDivElement>) => {
|
||||
const element = content.current;
|
||||
const bar = bars.current[axis]!;
|
||||
const thumb = event.currentTarget;
|
||||
const dimension = DIMENSIONS[axis];
|
||||
const trackSpace =
|
||||
bar[dimension.clientSize] - thumb[dimension.offsetSize];
|
||||
const overflow =
|
||||
element[dimension.scrollSize] - element[dimension.clientSize];
|
||||
|
||||
event.preventDefault();
|
||||
thumb.setPointerCapture(event.pointerId);
|
||||
thumb.dataset.dragging = "true";
|
||||
|
||||
drag.current = {
|
||||
axis,
|
||||
pointer: event[dimension.pointer],
|
||||
scroll: element[dimension.scroll],
|
||||
ratio: trackSpace > 0 ? overflow / trackSpace : 0,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Pointer capture routes moves to the dragged thumb, so the axis comes from the drag
|
||||
const handleThumbPointerMove = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!drag.current) return;
|
||||
|
||||
const { axis, pointer, scroll, ratio } = drag.current;
|
||||
const dimension = DIMENSIONS[axis];
|
||||
|
||||
content.current[dimension.scroll] =
|
||||
scroll + (event[dimension.pointer] - pointer) * ratio;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleThumbPointerUp = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
drag.current = undefined;
|
||||
delete event.currentTarget.dataset.dragging;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Clicking the empty track pages toward the click
|
||||
const handleTrackPointerDown = useCallback(
|
||||
(axis: Axis, event: PointerEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
|
||||
const element = content.current;
|
||||
const dimension = DIMENSIONS[axis];
|
||||
const thumbStart =
|
||||
thumbs.current[axis]!.getBoundingClientRect()[dimension.rectStart];
|
||||
const page = element[dimension.clientSize];
|
||||
|
||||
element.scrollBy({
|
||||
[dimension.rectStart]:
|
||||
event[dimension.pointer] < thumbStart ? -page : page,
|
||||
behavior: "smooth",
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -71,8 +263,46 @@ export function Scroller({
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div ref={curtainStart} data-side="start" className={styles.curtain} />
|
||||
<div ref={curtainEnd} data-side="end" className={styles.curtain} />
|
||||
{/* Curtains render before every bar so no curtain paints over a bar */}
|
||||
{axes.map((axis) => (
|
||||
<Fragment key={axis}>
|
||||
{curtainSides.map((side) => (
|
||||
<div
|
||||
key={side}
|
||||
ref={(element) => {
|
||||
curtains.current[axis][side] = element;
|
||||
}}
|
||||
className={styles.curtain}
|
||||
data-axis={axis}
|
||||
data-side={side}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{axes.map((axis) => (
|
||||
<div
|
||||
key={axis}
|
||||
ref={(element) => {
|
||||
bars.current[axis] = element;
|
||||
}}
|
||||
className={styles.bar}
|
||||
data-axis={axis}
|
||||
data-hidden="true"
|
||||
onPointerDown={(event) => handleTrackPointerDown(axis, event)}
|
||||
>
|
||||
<div
|
||||
ref={(element) => {
|
||||
thumbs.current[axis] = element;
|
||||
}}
|
||||
className={styles.thumb}
|
||||
onPointerDown={(event) => handleThumbPointerDown(axis, event)}
|
||||
onPointerMove={handleThumbPointerMove}
|
||||
onPointerUp={handleThumbPointerUp}
|
||||
onPointerCancel={handleThumbPointerUp}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { nameToArmorId } from "../core/blitzkit/nameToArmorId";
|
||||
import { resolveArmor } from "../core/blitzkit/resolveThickness";
|
||||
import { useArmor } from "../hooks/useArmor";
|
||||
import { useConsumable } from "../hooks/useConsumable";
|
||||
import { DuelModule, useDuel } from "../hooks/useDuel";
|
||||
import { useDuelModel } from "../hooks/useDuelModel";
|
||||
import { useDuelModels } from "../hooks/useDuelModels";
|
||||
import { useDuelModules } from "../hooks/useDuelModules";
|
||||
import { useModel } from "../hooks/useModel";
|
||||
import { useTankTransform } from "../hooks/useTankTransform";
|
||||
import { toThreeVector } from "../three/toThreeVector";
|
||||
@@ -24,28 +24,27 @@ interface SpacedArmorSceneProps {
|
||||
export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
||||
const turretContainer = useRef<Group>(null!);
|
||||
const gunContainer = useRef<Group>(null!);
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const track = useDuel("protagonist", DuelModule.Track);
|
||||
const turret = useDuel("protagonist", DuelModule.Turret);
|
||||
const gun = useDuel("protagonist", DuelModule.Gun);
|
||||
const { gltf: armorGltf } = useArmor(tank.id);
|
||||
const { gltf: modelGltf } = useModel(tank.id);
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
const { gltf: armorGltf } = useArmor(protagonist.tank.id);
|
||||
const { gltf: modelGltf } = useModel(protagonist.tank.id);
|
||||
const armorNodes = Object.values(armorGltf.nodes);
|
||||
const modelNodes = Object.values(modelGltf.nodes);
|
||||
const tankModelDefinition = useDuelModel("protagonist");
|
||||
const trackModelDefinition = tankModelDefinition.tracks[track.id];
|
||||
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
||||
const hullOrigin = toThreeVector(trackModelDefinition.origin);
|
||||
const turretOrigin = toThreeVector(tankModelDefinition.turret_origin);
|
||||
const gunOrigin = toThreeVector(turretModelDefinition.gun_origin);
|
||||
const models = useDuelModels("protagonist");
|
||||
const hullOrigin = toThreeVector(models.track.origin);
|
||||
const turretOrigin = toThreeVector(models.tank.turret_origin);
|
||||
const gunOrigin = toThreeVector(models.turret.gun_origin);
|
||||
const maskOrigin =
|
||||
gunModelDefinition.mask === undefined
|
||||
models.gun.mask === undefined
|
||||
? undefined
|
||||
: gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y;
|
||||
: models.gun.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y;
|
||||
const isDynamicArmorActive = useConsumable("protagonist", 73);
|
||||
|
||||
useTankTransform(track, turret, turretContainer, gunContainer);
|
||||
useTankTransform(
|
||||
protagonist.track,
|
||||
protagonist.turret,
|
||||
turretContainer,
|
||||
gunContainer,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -55,7 +54,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
||||
const isVisible = isHull;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
tankModelDefinition.armor!,
|
||||
models.tank.armor!,
|
||||
armorId,
|
||||
);
|
||||
|
||||
@@ -91,7 +90,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
||||
scene={scene}
|
||||
key={`${node.uuid}-track`}
|
||||
type={ArmorType.External}
|
||||
thickness={trackModelDefinition.thickness}
|
||||
thickness={models.track.thickness}
|
||||
variant="track"
|
||||
node={node}
|
||||
/>
|
||||
@@ -101,12 +100,12 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
||||
<group ref={turretContainer}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentTurret = node.name.startsWith(
|
||||
`turret_${turretModelDefinition.model_id.toString().padStart(2, "0")}`,
|
||||
`turret_${models.turret.model_id.toString().padStart(2, "0")}`,
|
||||
);
|
||||
const isVisible = isCurrentTurret;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
turretModelDefinition.armor!,
|
||||
models.turret.armor!,
|
||||
armorId,
|
||||
);
|
||||
|
||||
@@ -136,12 +135,12 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
||||
<group ref={gunContainer}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentGun = node.name.startsWith(
|
||||
`gun_${gunModelDefinition.model_id.toString().padStart(2, "0")}`,
|
||||
`gun_${models.gun.model_id.toString().padStart(2, "0")}`,
|
||||
);
|
||||
const isVisible = isCurrentGun;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
gunModelDefinition.armor!,
|
||||
models.gun.armor!,
|
||||
armorId,
|
||||
);
|
||||
|
||||
@@ -168,10 +167,10 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
||||
})}
|
||||
|
||||
{modelNodes.map((node) => {
|
||||
const gunString = `gun_${gunModelDefinition.model_id
|
||||
const gunString = `gun_${models.gun.model_id
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
const isCurrentGun = gunModelDefinition.mask
|
||||
const isCurrentGun = models.gun.mask
|
||||
? node.name.startsWith(gunString)
|
||||
: node.name === gunString;
|
||||
const isVisible = isCurrentGun;
|
||||
@@ -183,7 +182,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
||||
scene={scene}
|
||||
key={`${node.uuid}-gun-external`}
|
||||
type={ArmorType.External}
|
||||
thickness={gunModelDefinition.thickness}
|
||||
thickness={models.gun.thickness}
|
||||
variant="gun"
|
||||
node={node}
|
||||
clip={
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
SPALL_LINER_PROVISION_ID,
|
||||
} from "../core/blitzkit/spallLiner";
|
||||
import { discardClippingPlane } from "../core/three/discardClippingPlane";
|
||||
import { DuelModule, getDuel } from "../hooks/useDuel";
|
||||
import { getDuelModules } from "../hooks/useDuelModules";
|
||||
import { hasEquipment } from "../hooks/useEquipment";
|
||||
import {
|
||||
type Shot,
|
||||
@@ -118,11 +118,11 @@ export function SpacedArmorSceneComponent({
|
||||
remainingPenetrationInput?: number,
|
||||
penetrationScale = 1,
|
||||
) => {
|
||||
const protagonist = getDuel("protagonist", DuelModule.Tank);
|
||||
const antagonist = getDuel("antagonist", DuelModule.Tank);
|
||||
const protagonist = getDuelModules("protagonist").tank;
|
||||
const antagonist = getDuelModules("antagonist").tank;
|
||||
|
||||
const { customShell } = Tankopedia.state;
|
||||
const shell = customShell ?? getDuel("protagonist", DuelModule.Shell);
|
||||
const shell = customShell ?? getDuelModules("protagonist").shell;
|
||||
const { equalize } = Tankopedia.state.environment;
|
||||
const protagonistEqualizer =
|
||||
(equalize ? protagonist.equalizer : undefined) ?? defaultEqualizer;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ShaderMaterial,
|
||||
} from "three";
|
||||
import { jsxTree } from "../../core/blitzkit/jsxTree";
|
||||
import { DuelModule, getDuel } from "../../hooks/useDuel";
|
||||
import { getDuelModules } from "../../hooks/useDuelModules";
|
||||
import { hasEquipment } from "../../hooks/useEquipment";
|
||||
import { Tankopedia } from "../../stores/tankopedia";
|
||||
import { ArmorType } from "../SpacedArmorScene";
|
||||
@@ -52,12 +52,11 @@ export function SpacedArmorSubExternal({
|
||||
function handleShellChange() {
|
||||
const tankopediaEphemeral = Tankopedia.state;
|
||||
const shell =
|
||||
tankopediaEphemeral.customShell ??
|
||||
getDuel("antagonist", DuelModule.Shell);
|
||||
tankopediaEphemeral.customShell ?? getDuelModules("antagonist").shell;
|
||||
material.uniforms.penetration.value = shell.penetration!.near;
|
||||
}
|
||||
function handleProtagonistEquipmentChange() {
|
||||
const protagonist = getDuel("protagonist", DuelModule.Tank);
|
||||
const protagonist = getDuelModules("protagonist").tank;
|
||||
|
||||
const hasEnhancedArmor = hasEquipment(
|
||||
110,
|
||||
@@ -73,12 +72,11 @@ export function SpacedArmorSubExternal({
|
||||
thickness * (hasEnhancedArmor ? 1.04 : 1) * equalizer;
|
||||
}
|
||||
function handleAntagonistEquipmentChange() {
|
||||
const antagonist = getDuel("antagonist", DuelModule.Tank);
|
||||
const antagonist = getDuelModules("antagonist").tank;
|
||||
|
||||
const tankopediaEphemeral = Tankopedia.state;
|
||||
const shell =
|
||||
tankopediaEphemeral.customShell ??
|
||||
getDuel("antagonist", DuelModule.Shell);
|
||||
tankopediaEphemeral.customShell ?? getDuelModules("antagonist").shell;
|
||||
const penetration = shell.penetration!.near;
|
||||
const hasCalibratedShells = hasEquipment(
|
||||
103,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "three";
|
||||
import { degToRad } from "three/src/math/MathUtils.js";
|
||||
import { jsxTree } from "../../core/blitzkit/jsxTree";
|
||||
import { DuelModule, getDuel } from "../../hooks/useDuel";
|
||||
import { getDuelModules } from "../../hooks/useDuelModules";
|
||||
import { hasEquipment } from "../../hooks/useEquipment";
|
||||
import { Tankopedia } from "../../stores/tankopedia";
|
||||
import { ArmorType } from "../SpacedArmorScene";
|
||||
@@ -51,8 +51,7 @@ export function SpacedArmorSubSpaced({
|
||||
function handleShellChange() {
|
||||
const tankopediaEphemeral = Tankopedia.state;
|
||||
const shell =
|
||||
tankopediaEphemeral.customShell ??
|
||||
getDuel("antagonist", DuelModule.Shell);
|
||||
tankopediaEphemeral.customShell ?? getDuelModules("antagonist").shell;
|
||||
|
||||
material.uniforms.penetration.value = shell.penetration!.near;
|
||||
material.uniforms.caliber.value = shell.caliber;
|
||||
@@ -64,7 +63,7 @@ export function SpacedArmorSubSpaced({
|
||||
);
|
||||
}
|
||||
function handleProtagonistEquipmentChange() {
|
||||
const protagonist = getDuel("protagonist", DuelModule.Tank);
|
||||
const protagonist = getDuelModules("protagonist").tank;
|
||||
|
||||
const hasEnhancedArmor = hasEquipment(
|
||||
110,
|
||||
@@ -80,12 +79,11 @@ export function SpacedArmorSubSpaced({
|
||||
thickness * (hasEnhancedArmor ? 1.04 : 1) * equalizer;
|
||||
}
|
||||
function handleAntagonistEquipmentChange() {
|
||||
const antagonist = getDuel("antagonist", DuelModule.Tank);
|
||||
const antagonist = getDuelModules("antagonist").tank;
|
||||
|
||||
const tankopediaEphemeral = Tankopedia.state;
|
||||
const shell =
|
||||
tankopediaEphemeral.customShell ??
|
||||
getDuel("antagonist", DuelModule.Shell);
|
||||
tankopediaEphemeral.customShell ?? getDuelModules("antagonist").shell;
|
||||
const penetration = shell.penetration!.near;
|
||||
const hasCalibratedShells = hasEquipment(
|
||||
103,
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
type ModelTransformEventData,
|
||||
} from "../core/blitzkit/modelTransform";
|
||||
import { discardClippingPlane } from "../core/three/discardClippingPlane";
|
||||
import { DuelModule, useDuel } from "../hooks/useDuel";
|
||||
import { useDuelModules } from "../hooks/useDuelModules";
|
||||
import { useEquipment } from "../hooks/useEquipment";
|
||||
import { Tankopedia } from "../stores/tankopedia";
|
||||
import { transitionEvent } from "./Lighting";
|
||||
@@ -75,9 +75,10 @@ export function StaticArmorSceneComponent({
|
||||
}: StaticArmorSceneComponentProps) {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const hasEnhancedArmor = useEquipment("protagonist", 110);
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const equalize = Tankopedia.use((state) => state.equalize);
|
||||
const equalizer = (equalize ? tank.equalizer : undefined) ?? defaultEqualizer;
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
const equalize = Tankopedia.use((state) => state.environment.equalize);
|
||||
const equalizer =
|
||||
(equalize ? protagonist.tank.equalizer : undefined) ?? defaultEqualizer;
|
||||
const thicknessCoefficient = (hasEnhancedArmor ? 1.04 : 1) * equalizer.armor;
|
||||
const thickness = thicknessRaw * thicknessCoefficient;
|
||||
const x = thickness / thicknessRange.value;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
.scroller {
|
||||
/* Fits the table, centered by the parent, never wider than the page */
|
||||
max-width: 100%;
|
||||
|
||||
border: var(--space-0) solid var(--gray-a5);
|
||||
border-radius: var(--space-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-3);
|
||||
text-align: center;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/* Row dividers sit inside the cells, so they take no space. Borders on a row itself don't paint with separate borders, so rows apply them to their cells here */
|
||||
tbody tr > * {
|
||||
box-shadow: inset 0 var(--space-0) var(--gray-a4);
|
||||
}
|
||||
|
||||
.column-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
|
||||
padding-top: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
|
||||
background-color: var(--gray-3);
|
||||
}
|
||||
|
||||
.corner {
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.row-header {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
|
||||
background-color: var(--gray-2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.group-title,
|
||||
.group-cell {
|
||||
position: sticky;
|
||||
top: var(--head-height, 0);
|
||||
z-index: 1;
|
||||
|
||||
padding-top: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
|
||||
background-color: var(--gray-3);
|
||||
}
|
||||
|
||||
.group-title {
|
||||
left: 0;
|
||||
color: var(--gray-11);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* A cell spanning the whole row has no room to slide, so its content pins instead */
|
||||
.pinned {
|
||||
position: sticky;
|
||||
left: var(--space-4);
|
||||
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.cell {
|
||||
&[data-align="start"] {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&[data-align="end"] {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useRef, type ComponentProps } from "react";
|
||||
import { classNames } from "../../ui/classNames";
|
||||
import { Scroller, ScrollerOrientation } from "../Scroller";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
interface RootProps
|
||||
extends Omit<ComponentProps<typeof Scroller>, "orientation"> {
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
// When scrollable, scrolls both ways, keeping the head, the row headers and the group titles in view
|
||||
function Root({ children, className, scrollable, ...props }: RootProps) {
|
||||
const table = useRef<HTMLTableElement>(null);
|
||||
|
||||
// Group titles stick right below the head, whatever its height
|
||||
useEffect(() => {
|
||||
const head = table.current!.tHead;
|
||||
|
||||
if (!head) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
table.current!.style.setProperty(
|
||||
"--head-height",
|
||||
`${head.offsetHeight}px`
|
||||
);
|
||||
});
|
||||
|
||||
observer.observe(head);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const content = (
|
||||
<table ref={table} className={styles.table}>
|
||||
{children}
|
||||
</table>
|
||||
);
|
||||
|
||||
if (!scrollable) {
|
||||
return (
|
||||
<div className={classNames(styles.scroller, className)} {...props}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Scroller
|
||||
orientation={ScrollerOrientation.Both}
|
||||
startCurtain={false}
|
||||
className={classNames(styles.scroller, className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</Scroller>
|
||||
);
|
||||
}
|
||||
|
||||
function Head(props: ComponentProps<"thead">) {
|
||||
return <thead {...props} />;
|
||||
}
|
||||
|
||||
function Body(props: ComponentProps<"tbody">) {
|
||||
return <tbody {...props} />;
|
||||
}
|
||||
|
||||
function Row(props: ComponentProps<"tr">) {
|
||||
return <tr {...props} />;
|
||||
}
|
||||
|
||||
// Sits where the head and the row headers meet
|
||||
function Corner({ className, ...props }: ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
className={classNames(styles["column-header"], styles.corner, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnHeader({ className, ...props }: ComponentProps<"th">) {
|
||||
return (
|
||||
<th className={classNames(styles["column-header"], className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function RowHeader({ className, ...props }: ComponentProps<"th">) {
|
||||
return (
|
||||
<th className={classNames(styles["row-header"], className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
// A title that spans columns pins its content instead of sliding away
|
||||
function GroupTitle({ className, children, ...props }: ComponentProps<"th">) {
|
||||
return (
|
||||
<th className={classNames(styles["group-title"], className)} {...props}>
|
||||
{props.colSpan === undefined ? (
|
||||
children
|
||||
) : (
|
||||
<div className={styles.pinned}>{children}</div>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
// Shares the group title's row
|
||||
function GroupCell({ className, ...props }: ComponentProps<"td">) {
|
||||
return (
|
||||
<td className={classNames(styles["group-cell"], className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
interface CellProps extends Omit<ComponentProps<"td">, "align"> {
|
||||
align?: "start" | "center" | "end";
|
||||
}
|
||||
|
||||
function Cell({ className, align, ...props }: CellProps) {
|
||||
return (
|
||||
<td
|
||||
className={classNames(styles.cell, className)}
|
||||
data-align={align}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Table = {
|
||||
Root,
|
||||
Head,
|
||||
Body,
|
||||
Row,
|
||||
Corner,
|
||||
ColumnHeader,
|
||||
RowHeader,
|
||||
GroupTitle,
|
||||
GroupCell,
|
||||
Cell,
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { applyPitchYawLimits } from "../core/blitz/applyPitchYawLimits";
|
||||
import { modelTransformEvent } from "../core/blitzkit/modelTransform";
|
||||
import { skinTree } from "../core/blitzkit/skinTree";
|
||||
import { controlsEnabledEvent } from "../core/controlsEnabled";
|
||||
import { DuelModule, getDuel, useDuel } from "../hooks/useDuel";
|
||||
import { getDuelModules, useDuelModules } from "../hooks/useDuelModules";
|
||||
import { useDuelModel } from "../hooks/useDuelModel";
|
||||
import { hasEquipment } from "../hooks/useEquipment";
|
||||
import { useModel } from "../hooks/useModel";
|
||||
@@ -22,26 +22,31 @@ interface TankModelProps {
|
||||
}
|
||||
|
||||
export function TankModel({ silhouette = false }: TankModelProps) {
|
||||
const protagonist = Tankopedia.use((state) => state.protagonist);
|
||||
const track = useDuel("protagonist", DuelModule.Track);
|
||||
const turret = useDuel("protagonist", DuelModule.Turret);
|
||||
const protagonistState = Tankopedia.use((state) => state.protagonist);
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
|
||||
const canvas = useThree((state) => state.gl.domElement);
|
||||
const turretContainer = useRef<Group>(null!);
|
||||
const gunContainer = useRef<Group>(null!);
|
||||
const tankModelDefinition = useDuelModel("protagonist");
|
||||
const turretModelDefinition = tankModelDefinition.turrets[protagonist.turret];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun];
|
||||
const { gltf } = useModel(protagonist.tank);
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonistState.turret];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonistState.gun];
|
||||
const { gltf } = useModel(protagonistState.tank);
|
||||
const nodes = Object.values(gltf.nodes);
|
||||
const skinGltf = useSkin(protagonist.tank);
|
||||
const attachmentGltf = useSkin(protagonist.tank, "attachment");
|
||||
const skinGltf = useSkin(protagonistState.tank);
|
||||
const attachmentGltf = useSkin(protagonistState.tank, "attachment");
|
||||
const skins = useMemo(
|
||||
() => [skinGltf, attachmentGltf].filter((skin) => skin !== undefined),
|
||||
[skinGltf, attachmentGltf],
|
||||
);
|
||||
|
||||
useTankTransform(track, turret, turretContainer, gunContainer);
|
||||
useTankTransform(
|
||||
protagonist.track,
|
||||
protagonist.turret,
|
||||
turretContainer,
|
||||
gunContainer,
|
||||
);
|
||||
useSkinAnimation(skins);
|
||||
|
||||
const materials = useMemo(() => {
|
||||
@@ -187,7 +192,7 @@ export function TankModel({ silhouette = false }: TankModelProps) {
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
const protagonist = getDuel("protagonist", DuelModule.Tank);
|
||||
const protagonist = getDuelModules("protagonist").tank;
|
||||
|
||||
const hasImprovedVerticalStabilizer = hasEquipment(
|
||||
122,
|
||||
@@ -281,7 +286,7 @@ export function TankModel({ silhouette = false }: TankModelProps) {
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
const protagonist = getDuel("protagonist", DuelModule.Tank);
|
||||
const protagonist = getDuelModules("protagonist").tank;
|
||||
|
||||
const hasImprovedVerticalStabilizer = hasEquipment(
|
||||
122,
|
||||
|
||||
@@ -8,8 +8,8 @@ import { resolveArmor } from "../core/blitzkit/resolveThickness";
|
||||
import { controlsEnabledEvent } from "../core/controlsEnabled";
|
||||
import { useArmor } from "../hooks/useArmor";
|
||||
import { useConsumable } from "../hooks/useConsumable";
|
||||
import { DuelModule, getDuel, useDuel } from "../hooks/useDuel";
|
||||
import { useDuelModel } from "../hooks/useDuelModel";
|
||||
import { useDuelModels } from "../hooks/useDuelModels";
|
||||
import { getDuelModules, useDuelModules } from "../hooks/useDuelModules";
|
||||
import { hasEquipment } from "../hooks/useEquipment";
|
||||
import { useModel } from "../hooks/useModel";
|
||||
import { useTankTransform } from "../hooks/useTankTransform";
|
||||
@@ -31,28 +31,22 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
const turretContainer = useRef<Group>(null!);
|
||||
const gunContainer = useRef<Group>(null!);
|
||||
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const track = useDuel("protagonist", DuelModule.Track);
|
||||
const turret = useDuel("protagonist", DuelModule.Turret);
|
||||
const gun = useDuel("protagonist", DuelModule.Gun);
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
|
||||
const { gltf: armorGltf } = useArmor(tank.id);
|
||||
const { gltf: modelGltf } = useModel(tank.id);
|
||||
const { gltf: armorGltf } = useArmor(protagonist.tank.id);
|
||||
const { gltf: modelGltf } = useModel(protagonist.tank.id);
|
||||
const armorNodes = Object.values(armorGltf.nodes);
|
||||
const modelNodes = Object.values(modelGltf.nodes);
|
||||
const tankModelDefinition = useDuelModel("protagonist");
|
||||
const trackModelDefinition = tankModelDefinition.tracks[track.id];
|
||||
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
||||
const hullOrigin = toThreeVector(trackModelDefinition.origin);
|
||||
const turretOrigin = toThreeVector(tankModelDefinition.turret_origin);
|
||||
const gunOrigin = toThreeVector(turretModelDefinition.gun_origin);
|
||||
const models = useDuelModels("protagonist");
|
||||
const hullOrigin = toThreeVector(models.track.origin);
|
||||
const turretOrigin = toThreeVector(models.tank.turret_origin);
|
||||
const gunOrigin = toThreeVector(models.turret.gun_origin);
|
||||
const canvas = useThree((state) => state.gl.domElement);
|
||||
|
||||
const maskOrigin =
|
||||
gunModelDefinition.mask === undefined
|
||||
models.gun.mask === undefined
|
||||
? undefined
|
||||
: gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y;
|
||||
: models.gun.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y;
|
||||
const showPrimaryArmor = TankopediaPersistent.use(
|
||||
(state) => state.showPrimaryArmor,
|
||||
);
|
||||
@@ -64,7 +58,12 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
);
|
||||
const isDynamicArmorActive = useConsumable("protagonist", 73);
|
||||
|
||||
useTankTransform(track, turret, turretContainer, gunContainer);
|
||||
useTankTransform(
|
||||
protagonist.track,
|
||||
protagonist.turret,
|
||||
turretContainer,
|
||||
gunContainer,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -74,7 +73,7 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
const isVisible = isHull;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
tankModelDefinition.armor!,
|
||||
models.tank.armor!,
|
||||
armorId,
|
||||
);
|
||||
|
||||
@@ -115,7 +114,7 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
thicknessRange={thicknessRange}
|
||||
key={node.uuid}
|
||||
type={ArmorType.External}
|
||||
thickness={trackModelDefinition.thickness}
|
||||
thickness={models.track.thickness}
|
||||
variant="track"
|
||||
node={node}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
@@ -126,12 +125,12 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
<group ref={turretContainer}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentTurret = node.name.startsWith(
|
||||
`turret_${turretModelDefinition.model_id.toString().padStart(2, "0")}`,
|
||||
`turret_${models.turret.model_id.toString().padStart(2, "0")}`,
|
||||
);
|
||||
const isVisible = isCurrentTurret;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
turretModelDefinition.armor!,
|
||||
models.turret.armor!,
|
||||
armorId,
|
||||
);
|
||||
|
||||
@@ -164,7 +163,7 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
const protagonist = getDuel("protagonist", DuelModule.Tank);
|
||||
const protagonist = getDuelModules("protagonist").tank;
|
||||
|
||||
const hasImprovedVerticalStabilizer = hasEquipment(
|
||||
122,
|
||||
@@ -185,8 +184,8 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
modelTransformEvent.last!.pitch,
|
||||
modelTransformEvent.last!.yaw +
|
||||
delta.x * (Math.PI / boundingRect.width),
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
models.gun.pitch!,
|
||||
models.turret.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
hasDownImprovedVerticalStabilizer,
|
||||
);
|
||||
@@ -217,12 +216,12 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
<group ref={gunContainer}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentGun = node.name.startsWith(
|
||||
`gun_${gunModelDefinition.model_id.toString().padStart(2, "0")}`,
|
||||
`gun_${models.gun.model_id.toString().padStart(2, "0")}`,
|
||||
);
|
||||
const isVisible = isCurrentGun;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
gunModelDefinition.armor!,
|
||||
models.gun.armor!,
|
||||
armorId,
|
||||
);
|
||||
|
||||
@@ -254,7 +253,7 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
const protagonist = getDuel("protagonist", DuelModule.Tank);
|
||||
const protagonist = getDuelModules("protagonist").tank;
|
||||
|
||||
const hasImprovedVerticalStabilizer = hasEquipment(
|
||||
122,
|
||||
@@ -275,8 +274,8 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
delta.y * (Math.PI / boundingRect.height),
|
||||
modelTransformEvent.last!.yaw +
|
||||
delta.x * (Math.PI / boundingRect.width),
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
models.gun.pitch!,
|
||||
models.turret.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
hasDownImprovedVerticalStabilizer,
|
||||
);
|
||||
@@ -305,10 +304,10 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
})}
|
||||
|
||||
{modelNodes.map((node) => {
|
||||
const gunString = `gun_${gunModelDefinition.model_id
|
||||
const gunString = `gun_${models.gun.model_id
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
const isCurrentGun = gunModelDefinition.mask
|
||||
const isCurrentGun = models.gun.mask
|
||||
? node.name.startsWith(gunString)
|
||||
: node.name === gunString;
|
||||
const isVisible = isCurrentGun;
|
||||
@@ -332,7 +331,7 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
const protagonist = getDuel("protagonist", DuelModule.Tank);
|
||||
const protagonist = getDuelModules("protagonist").tank;
|
||||
|
||||
const hasImprovedVerticalStabilizer = hasEquipment(
|
||||
122,
|
||||
@@ -353,8 +352,8 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
delta.y * (Math.PI / boundingRect.height),
|
||||
modelTransformEvent.last!.yaw +
|
||||
delta.x * (Math.PI / boundingRect.width),
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
models.gun.pitch!,
|
||||
models.turret.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
hasDownImprovedVerticalStabilizer,
|
||||
);
|
||||
@@ -373,7 +372,7 @@ export const TankStaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
thicknessRange={thicknessRange}
|
||||
key={node.uuid}
|
||||
type={ArmorType.External}
|
||||
thickness={gunModelDefinition.thickness}
|
||||
thickness={models.gun.thickness}
|
||||
variant="gun"
|
||||
node={node}
|
||||
clip={
|
||||
|
||||
@@ -2,7 +2,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 { useDuelModules } from "../../hooks/useDuelModules";
|
||||
import { useEquipment } from "../../hooks/useEquipment";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { useUnwrapper } from "../../hooks/useUnwrapper";
|
||||
@@ -40,13 +40,11 @@ export function TankopediaAntagonistControl({
|
||||
const hasCalibratedShells = useEquipment("antagonist", 103);
|
||||
const hasEnhancedArmor = useEquipment("protagonist", 110);
|
||||
|
||||
const tank = useDuel("antagonist", DuelModule.Tank);
|
||||
const gun = useDuel("antagonist", DuelModule.Gun);
|
||||
const shell = useDuel("antagonist", DuelModule.Shell);
|
||||
const protagonist = useDuelModules("antagonist");
|
||||
|
||||
const antagonistUniqueGuns = useMemo(
|
||||
() => uniqueGuns(tank.turrets),
|
||||
[tank.turrets],
|
||||
() => uniqueGuns(protagonist.tank.turrets),
|
||||
[protagonist.tank.turrets],
|
||||
);
|
||||
|
||||
const isVisible = showControls && isDynamicArmorDisplay;
|
||||
@@ -64,7 +62,7 @@ export function TankopediaAntagonistControl({
|
||||
{antagonistUniqueGuns.length > 1 && (
|
||||
<div className={styles.group}>
|
||||
{[...antagonistUniqueGuns].reverse().map((entry) => {
|
||||
const selected = entry.gun.id === gun.id;
|
||||
const selected = entry.gun.id === protagonist.gun.id;
|
||||
const name = unwrap(entry.gun.name!);
|
||||
|
||||
return (
|
||||
@@ -100,8 +98,9 @@ export function TankopediaAntagonistControl({
|
||||
)}
|
||||
|
||||
<div className={styles.group}>
|
||||
{gun.shells.map((thisShell) => {
|
||||
const selected = thisShell.id === shell.id && !hasCustomShell;
|
||||
{protagonist.gun.shells.map((thisShell) => {
|
||||
const selected =
|
||||
thisShell.id === protagonist.shell.id && !hasCustomShell;
|
||||
const name = unwrap(thisShell.name!);
|
||||
|
||||
return (
|
||||
|
||||
@@ -81,12 +81,6 @@
|
||||
.value {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.units {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.ranking {
|
||||
|
||||
@@ -3,8 +3,7 @@ import { literals } from "@blitzkit/i18n/src/literals";
|
||||
import { useRef } from "react";
|
||||
import { characteristicsGroups } from "../../config/characteristicsGroups";
|
||||
import { toys } from "../../config/toys";
|
||||
import { useCharacteristicRenderer } from "../../hooks/useCharacteristicRenderer";
|
||||
import { DuelModule, useDuel } from "../../hooks/useDuel";
|
||||
import { useDuelModules } from "../../hooks/useDuelModules";
|
||||
import { useRankings } from "../../hooks/useRankings";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { Tankopedia, TankopediaRelativeAgainst } from "../../stores/tankopedia";
|
||||
@@ -19,6 +18,7 @@ import type {
|
||||
} from "../../types/characteristics";
|
||||
import { Heading } from "../Heading";
|
||||
import { SegmentedControl } from "../SegmentedControl";
|
||||
import { CharacteristicValue } from "../CharacteristicValue";
|
||||
import { Text } from "../Text";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
@@ -150,8 +150,6 @@ interface ItemProps {
|
||||
|
||||
function Item({ config, value, ranking }: ItemProps) {
|
||||
const strings = useStrings();
|
||||
const renderCharacteristic = useCharacteristicRenderer();
|
||||
const hasUnits = config.units !== undefined;
|
||||
|
||||
return (
|
||||
<span className={styles.item} data-indent={config.indent}>
|
||||
@@ -164,11 +162,7 @@ function Item({ config, value, ranking }: ItemProps) {
|
||||
}
|
||||
</Text>
|
||||
|
||||
<Text lowContrast={hasUnits} size={hasUnits ? "minor" : undefined}>
|
||||
<div className={styles.units}>
|
||||
{renderCharacteristic(value, config)}
|
||||
</div>
|
||||
</Text>
|
||||
<CharacteristicValue config={config} value={value} />
|
||||
</div>
|
||||
|
||||
{ranking && (
|
||||
@@ -188,7 +182,7 @@ function Item({ config, value, ranking }: ItemProps) {
|
||||
}
|
||||
|
||||
function CompareOptions() {
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
const relativeAgainst = Tankopedia.use((state) => state.relative_against);
|
||||
|
||||
const strings = useStrings();
|
||||
@@ -198,15 +192,18 @@ function CompareOptions() {
|
||||
value: `${TankopediaRelativeAgainst.ClassAndTier}`,
|
||||
relativeAgainst: TankopediaRelativeAgainst.ClassAndTier,
|
||||
label: literals(strings.tanks.characteristics.compare.class, {
|
||||
tier: romanize(tank.tier),
|
||||
class: strings.common.tank_class_short_plural_lowercase[tank.class],
|
||||
tier: romanize(protagonist.tank.tier),
|
||||
class:
|
||||
strings.common.tank_class_short_plural_lowercase[
|
||||
protagonist.tank.class
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: `${TankopediaRelativeAgainst.Tier}`,
|
||||
relativeAgainst: TankopediaRelativeAgainst.Tier,
|
||||
label: literals(strings.tanks.characteristics.compare.tier, {
|
||||
tier: romanize(tank.tier),
|
||||
tier: romanize(protagonist.tank.tier),
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,18 +4,16 @@ import type { QuicklimeEvent } from "quicklime";
|
||||
import { Suspense, useEffect, useRef } from "react";
|
||||
import { LineBasicMaterial, MeshBasicMaterial, type Group } from "three";
|
||||
import { degToRad } from "three/src/math/MathUtils.js";
|
||||
import { api } from "../api/dynamic";
|
||||
import { jsxTree } from "../core/blitzkit/jsxTree";
|
||||
import {
|
||||
modelTransformEvent,
|
||||
type ModelTransformEventData,
|
||||
} from "../core/blitzkit/modelTransform";
|
||||
import { DuelModule, useDuel } from "../hooks/useDuel";
|
||||
import { useDuelModels } from "../hooks/useDuelModels";
|
||||
import { useDuelModules } from "../hooks/useDuelModules";
|
||||
import { useModel } from "../hooks/useModel";
|
||||
import { SmartCanvas } from "./SmartCanvas";
|
||||
|
||||
const modelDefinitions = await api.models();
|
||||
|
||||
const ZOOM = 60;
|
||||
|
||||
type Chunk = "gun" | "turret" | "body";
|
||||
@@ -67,16 +65,13 @@ function CameraPositioner() {
|
||||
function Content() {
|
||||
const hullWrapper = useRef<Group>(null);
|
||||
const gunWrapper = useRef<Group>(null);
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const turret = useDuel("protagonist", DuelModule.Turret);
|
||||
|
||||
const tankModel = modelDefinitions.models[tank.id];
|
||||
const turretModel = tankModel.turrets[turret.id];
|
||||
const models = useDuelModels("protagonist");
|
||||
|
||||
// baked-in turret mount tilt, applied as a static base rotation so the live
|
||||
// gun pitch composes on top of it
|
||||
const initialTurretPitch = -degToRad(
|
||||
tankModel.initial_turret_rotation?.pitch ?? 0,
|
||||
models.tank.initial_turret_rotation?.pitch ?? 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -94,7 +89,7 @@ function Content() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const origin = turretModel.gun_origin!;
|
||||
const origin = models.turret.gun_origin!;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -121,22 +116,18 @@ function Content() {
|
||||
}
|
||||
|
||||
function ModelChunk({ only }: { only: Chunk }) {
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const turret = useDuel("protagonist", DuelModule.Turret);
|
||||
const gun = useDuel("protagonist", DuelModule.Gun);
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
|
||||
const tankModel = modelDefinitions.models[tank.id];
|
||||
const turretModel = tankModel.turrets[turret.id];
|
||||
const gunModel = turretModel.guns[gun.id];
|
||||
const models = useDuelModels("protagonist");
|
||||
|
||||
const originZ = tankModel.turret_origin!.z + turretModel.gun_origin!.z;
|
||||
const originY = tankModel.turret_origin!.y + turretModel.gun_origin!.y;
|
||||
const originZ = models.tank.turret_origin!.z + models.turret.gun_origin!.z;
|
||||
const originY = models.tank.turret_origin!.y + models.turret.gun_origin!.y;
|
||||
|
||||
const { gltf } = useModel(tank.id);
|
||||
const { gltf } = useModel(protagonist.tank.id);
|
||||
const nodes = Object.values(gltf.nodes);
|
||||
|
||||
const gunId = gunModel.model_id.toString().padStart(2, "0");
|
||||
const turretId = turretModel.model_id.toString().padStart(2, "0");
|
||||
const gunId = models.gun.model_id.toString().padStart(2, "0");
|
||||
const turretId = models.turret.model_id.toString().padStart(2, "0");
|
||||
|
||||
return (
|
||||
<group position={[0, -originY, -originZ]}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { alias } from "@blitzkit/core";
|
||||
import { api } from "../../api/dynamic";
|
||||
import { DuelModule, useDuel } from "../../hooks/useDuel";
|
||||
import { useDuelModules } from "../../hooks/useDuelModules";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { useUnwrapper } from "../../hooks/useUnwrapper";
|
||||
import { Heading } from "../Heading";
|
||||
@@ -18,8 +18,8 @@ const [gameDefinitions, consumableDefinitions, provisionDefinitions, tanks] =
|
||||
export function TankopediaGameModeAbilities() {
|
||||
const unwrap = useUnwrapper();
|
||||
const strings = useStrings();
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const roles = Object.entries(tank.roles);
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
const roles = Object.entries(protagonist.tank.roles);
|
||||
|
||||
if (roles.length === 0) return null;
|
||||
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
.upgrade-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.crew-skills {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: inherit;
|
||||
}
|
||||
|
||||
.skill {
|
||||
position: relative;
|
||||
|
||||
.level {
|
||||
position: absolute;
|
||||
|
||||
right: var(--space-1);
|
||||
bottom: var(--space-1);
|
||||
|
||||
font-size: var(--font-size-minor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shells {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
|
||||
.state {
|
||||
font-size: 1.25em;
|
||||
|
||||
&[data-selected="true"] {
|
||||
img {
|
||||
filter: sepia(1) saturate(10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.provisions {
|
||||
width: 0;
|
||||
min-width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.consumables {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, max-content);
|
||||
gap: var(--space-3);
|
||||
|
||||
.consumable-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
|
||||
.consumable {
|
||||
font-size: var(--space-5);
|
||||
|
||||
img {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.equipment {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
|
||||
.slots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
|
||||
.slot {
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
&[data-selected="true"] {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tree {
|
||||
/* The game lays the tree out left to right; here depth runs top to bottom */
|
||||
--depth-gap: var(--space-5);
|
||||
--sibling-gap: var(--space-4);
|
||||
--line-thickness: var(--space-1);
|
||||
--line-border: var(--space-1) solid var(--gray-6);
|
||||
|
||||
&[data-has-bottom-cost="true"] {
|
||||
margin-bottom: calc(var(--space-2) + var(--font-size-minor));
|
||||
}
|
||||
|
||||
display: grid;
|
||||
gap: var(--depth-gap) var(--sibling-gap);
|
||||
|
||||
.cell {
|
||||
position: relative;
|
||||
|
||||
.trunk-left,
|
||||
.trunk-right {
|
||||
position: absolute;
|
||||
top: calc(var(--depth-gap) / -2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trunk-left,
|
||||
.trunk-right,
|
||||
.snake-left,
|
||||
.snake-right {
|
||||
border-top: var(--line-border);
|
||||
border-bottom: var(--line-border);
|
||||
}
|
||||
|
||||
.trunk-left {
|
||||
right: 50%;
|
||||
}
|
||||
|
||||
.trunk-right {
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.top,
|
||||
.bottom {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
height: calc(var(--depth-gap) / 2);
|
||||
|
||||
border-left: var(--line-border);
|
||||
border-right: var(--line-border);
|
||||
}
|
||||
|
||||
.top {
|
||||
bottom: 100%;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.snake-left,
|
||||
.snake-right {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: var(--sibling-gap);
|
||||
}
|
||||
|
||||
.snake-left {
|
||||
right: 100%;
|
||||
}
|
||||
|
||||
.snake-right {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.module {
|
||||
position: relative;
|
||||
|
||||
.icon {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
|
||||
.module[data-major="true"] & {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
|
||||
font-size: var(--font-size-minor);
|
||||
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
|
||||
transform: translateX(-50%);
|
||||
|
||||
margin-top: var(--space-2);
|
||||
|
||||
background-color: var(--gray-2);
|
||||
|
||||
svg,
|
||||
img {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: var(--space-3);
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
|
||||
gap: var(--space-3);
|
||||
|
||||
.chunks-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
.line-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
gap: var(--space-3);
|
||||
|
||||
.wrap-marker {
|
||||
padding-bottom: var(--font-size-minor);
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
|
||||
padding: var(--space-3);
|
||||
|
||||
font-size: 2em;
|
||||
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
|
||||
transform: translate(var(--space-1), var(--space-1));
|
||||
}
|
||||
|
||||
.tier {
|
||||
position: absolute;
|
||||
left: var(--space-2);
|
||||
top: var(--space-2);
|
||||
}
|
||||
|
||||
.price {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: var(--space-2);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,8 @@ import { Suspense, useEffect, useRef } from "react";
|
||||
import { applyPitchYawLimits } from "../../core/blitz/applyPitchYawLimits";
|
||||
import { modelTransformEvent } from "../../core/blitzkit/modelTransform";
|
||||
import { Pose, poseEvent } from "../../core/blitzkit/pose";
|
||||
import { DuelModule, useDuel } from "../../hooks/useDuel";
|
||||
import { useDuelModels } from "../../hooks/useDuelModels";
|
||||
import { useDuelModules } from "../../hooks/useDuelModules";
|
||||
import { useEquipment } from "../../hooks/useEquipment";
|
||||
import { useModel } from "../../hooks/useModel";
|
||||
import { Tankopedia } from "../../stores/tankopedia";
|
||||
@@ -44,12 +45,9 @@ export function TankopediaSandbox({
|
||||
const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122);
|
||||
const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124);
|
||||
|
||||
const gun = useDuel("protagonist", DuelModule.Gun);
|
||||
const turret = useDuel("protagonist", DuelModule.Turret);
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
|
||||
const model = Tankopedia.use((state) => state.protagonist.model);
|
||||
const turretModelDefinition = model.turrets[turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
||||
const { turret, gun } = useDuelModels("protagonist");
|
||||
const display = Tankopedia.use((state) => state.loaders.display.loaded);
|
||||
|
||||
function handlePointerDown() {
|
||||
@@ -73,8 +71,8 @@ export function TankopediaSandbox({
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
-Infinity,
|
||||
0,
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
gun.pitch!,
|
||||
turret.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
hasDownImprovedVerticalStabilizer,
|
||||
);
|
||||
@@ -88,8 +86,8 @@ export function TankopediaSandbox({
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
Infinity,
|
||||
0,
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
gun.pitch!,
|
||||
turret.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
@@ -102,8 +100,8 @@ export function TankopediaSandbox({
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
0,
|
||||
0,
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
gun.pitch!,
|
||||
turret.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
@@ -130,13 +128,13 @@ export function TankopediaSandbox({
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
modelTransformEvent.last!.pitch,
|
||||
modelTransformEvent.last!.yaw,
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
gun.pitch!,
|
||||
turret.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.dispatch({ pitch, yaw });
|
||||
}, [gun, turret]);
|
||||
}, [protagonist.gun, protagonist.turret]);
|
||||
|
||||
useEffect(() => {
|
||||
if (display !== TankopediaDisplay.DynamicArmor) {
|
||||
@@ -188,9 +186,9 @@ export function TankopediaSandbox({
|
||||
|
||||
function Content({ thicknessRange }: { thicknessRange: ThicknessRange }) {
|
||||
const display = Tankopedia.use((state) => state.loaders.display.loaded);
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
|
||||
const { hasPbr } = useModel(tank.id);
|
||||
const { hasPbr } = useModel(protagonist.tank.id);
|
||||
|
||||
// this causes a re-render of the ENTIRE thing, maybe move this elsewhere?
|
||||
const revealed = Tankopedia.use((state) => state.revealed);
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { useDuelModules } from "../../hooks/useDuelModules";
|
||||
import { hasSkinAnimations } from "../../hooks/useSkin";
|
||||
import { useUnwrapper } from "../../hooks/useUnwrapper";
|
||||
import { Tankopedia } from "../../stores/tankopedia";
|
||||
@@ -18,7 +18,7 @@ const camouflageDefinitions = await api.camouflages();
|
||||
export function TankopediaSkinSwitcher() {
|
||||
const unwrap = useUnwrapper();
|
||||
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
|
||||
const skin = Tankopedia.use((state) => state.loaders.skin.requested);
|
||||
const showControls = Tankopedia.use((state) => state.showControls);
|
||||
@@ -31,7 +31,7 @@ export function TankopediaSkinSwitcher() {
|
||||
|
||||
const skins = uniqBy(
|
||||
Object.values(camouflageDefinitions.camouflages).filter(
|
||||
(camouflage) => camouflage.tank_id === tank.id,
|
||||
(camouflage) => camouflage.tank_id === protagonist.tank.id,
|
||||
),
|
||||
(camouflage) => unwrap(camouflage.name!),
|
||||
);
|
||||
@@ -45,8 +45,8 @@ export function TankopediaSkinSwitcher() {
|
||||
|
||||
const isVisible = showControls && isModelDisplay;
|
||||
const hasAnimations =
|
||||
hasSkinAnimations(tank.id, loadedSkin) ||
|
||||
hasSkinAnimations(tank.id, loadedAttachment);
|
||||
hasSkinAnimations(protagonist.tank.id, loadedSkin) ||
|
||||
hasSkinAnimations(protagonist.tank.id, loadedAttachment);
|
||||
|
||||
return (
|
||||
<div className={styles["skin-switcher"]} data-visible={isVisible}>
|
||||
|
||||
@@ -12,14 +12,14 @@ import {
|
||||
type PointerEvent,
|
||||
} from "react";
|
||||
import { clamp, degToRad, radToDeg } from "three/src/math/MathUtils.js";
|
||||
import { api } from "../../api/dynamic";
|
||||
import type { ToyProps } from "../../config/toys";
|
||||
import {
|
||||
DEFAULT_PITCH_TRANSITION,
|
||||
applyPitchYawLimits,
|
||||
} from "../../core/blitz/applyPitchYawLimits";
|
||||
import { modelTransformEvent } from "../../core/blitzkit/modelTransform";
|
||||
import { DuelModule, useDuel } from "../../hooks/useDuel";
|
||||
import { useDuelModels } from "../../hooks/useDuelModels";
|
||||
import { useDuelModules } from "../../hooks/useDuelModules";
|
||||
import { useEquipment } from "../../hooks/useEquipment";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { Tankopedia } from "../../stores/tankopedia";
|
||||
@@ -35,7 +35,7 @@ import styles from "./index.module.css";
|
||||
|
||||
export function RicochetToy({ characteristics }: ToyProps) {
|
||||
const strings = useStrings();
|
||||
const shell = useDuel("protagonist", DuelModule.Shell);
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const normalizationValue = characteristics.get("normalization");
|
||||
const ricochetValue = characteristics.get("ricochet");
|
||||
@@ -45,7 +45,7 @@ export function RicochetToy({ characteristics }: ToyProps) {
|
||||
const ricochet = degToRad(
|
||||
typeof ricochetValue === "number" ? ricochetValue : 90,
|
||||
);
|
||||
const explodes = isExplosive(shell.type);
|
||||
const explodes = isExplosive(protagonist.shell.type);
|
||||
const [rawAngle, setAngle] = useState(degToRad(-25));
|
||||
const angle = explodes ? degToRad(-70) : rawAngle;
|
||||
const doesRicochet = Math.abs(angle) >= ricochet;
|
||||
@@ -98,7 +98,7 @@ export function RicochetToy({ characteristics }: ToyProps) {
|
||||
<div className={styles.line} />
|
||||
<img
|
||||
className={styles.shell}
|
||||
src={alias("api", `/icons/shells/${shell.icon}.webp`)}
|
||||
src={alias("api", `/icons/shells/${protagonist.shell.icon}.webp`)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -328,7 +328,7 @@ interface ReloadData {
|
||||
}
|
||||
|
||||
export function ReloadToy({ characteristics }: ToyProps) {
|
||||
const gun = useDuel("protagonist", DuelModule.Gun);
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
const strings = useStrings();
|
||||
|
||||
const reloadData = useMemo<ReloadData>(() => {
|
||||
@@ -336,14 +336,14 @@ export function ReloadToy({ characteristics }: ToyProps) {
|
||||
"_reload_coefficient",
|
||||
) as number;
|
||||
const intraClip =
|
||||
gun.gun_type!.$case === "regular"
|
||||
protagonist.gun.gun_type!.$case === "regular"
|
||||
? 0
|
||||
: (characteristics.get("intra_clip") as number);
|
||||
|
||||
let reloads: number[];
|
||||
let shells: number;
|
||||
|
||||
switch (gun.gun_type!.$case) {
|
||||
switch (protagonist.gun.gun_type!.$case) {
|
||||
case "regular":
|
||||
reloads = [characteristics.get("reload") as number];
|
||||
shells = 1;
|
||||
@@ -351,14 +351,14 @@ export function ReloadToy({ characteristics }: ToyProps) {
|
||||
|
||||
case "auto_loader":
|
||||
reloads = [characteristics.get("reload") as number];
|
||||
shells = gun.gun_type!.value.shell_count;
|
||||
shells = protagonist.gun.gun_type!.value.shell_count;
|
||||
break;
|
||||
|
||||
case "auto_reloader":
|
||||
reloads = gun.gun_type!.value.shell_reloads.map(
|
||||
reloads = protagonist.gun.gun_type!.value.shell_reloads.map(
|
||||
(reload) => reload * reloadCoefficient,
|
||||
);
|
||||
shells = gun.gun_type!.value.shell_count;
|
||||
shells = protagonist.gun.gun_type!.value.shell_count;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -366,10 +366,10 @@ export function ReloadToy({ characteristics }: ToyProps) {
|
||||
reloads,
|
||||
shells,
|
||||
intraClip,
|
||||
burstShells: gun.burst?.count ?? 1,
|
||||
burstInterShell: gun.burst?.interval,
|
||||
burstShells: protagonist.gun.burst?.count ?? 1,
|
||||
burstInterShell: protagonist.gun.burst?.interval,
|
||||
};
|
||||
}, [characteristics, gun]);
|
||||
}, [characteristics, protagonist.gun]);
|
||||
|
||||
const glow = useRef<HTMLDivElement>(null);
|
||||
const ring = useRef<HTMLDivElement>(null);
|
||||
@@ -567,7 +567,6 @@ export function ReloadToy({ characteristics }: ToyProps) {
|
||||
variant="outline"
|
||||
color="gray"
|
||||
highContrast
|
||||
aria-label={strings.tanks.toys.reload.skip}
|
||||
onClick={() => {
|
||||
state.current = Math.floor(state.current + 1);
|
||||
}}
|
||||
@@ -665,8 +664,6 @@ export function ViewRangeToy({ characteristics }: ToyProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const modelDefinitions = await api.models();
|
||||
|
||||
const ANGLE_COEFFICIENT = 1 / 10;
|
||||
|
||||
function mag(x: number) {
|
||||
@@ -686,27 +683,23 @@ export function FlexibilityToy() {
|
||||
const strings = useStrings();
|
||||
const degrees = (value: number) =>
|
||||
literals(strings.common.units.deg, { value: value.toFixed(0) });
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const turret = useDuel("protagonist", DuelModule.Turret);
|
||||
const gun = useDuel("protagonist", DuelModule.Gun);
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
const modelRequested = Tankopedia.use(
|
||||
(state) => state.loaders.model.requested,
|
||||
);
|
||||
const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122);
|
||||
const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124);
|
||||
|
||||
const tankModel = modelDefinitions.models[tank.id];
|
||||
const turretModel = tankModel.turrets[turret.id];
|
||||
const gunModel = turretModel.guns[gun.id];
|
||||
const p = gunModel.pitch!;
|
||||
const y = turretModel.yaw;
|
||||
const models = useDuelModels("protagonist");
|
||||
const p = models.gun.pitch!;
|
||||
const y = models.turret.yaw;
|
||||
const f = p.front;
|
||||
const b = p.back;
|
||||
const t = p.transition ?? DEFAULT_PITCH_TRANSITION;
|
||||
|
||||
// the turret's baked-in mount tilt is folded into displayed pitch values
|
||||
const initialTurretPitch = degToRad(
|
||||
tankModel.initial_turret_rotation?.pitch ?? 0,
|
||||
models.tank.initial_turret_rotation?.pitch ?? 0,
|
||||
);
|
||||
const tiltDeg = radToDeg(initialTurretPitch);
|
||||
const magTilted = (deg: number) => mag(deg + tiltDeg);
|
||||
@@ -727,7 +720,7 @@ export function FlexibilityToy() {
|
||||
setMinPitch(defaultMin);
|
||||
setMaxPitch(defaultMax);
|
||||
modelTransformEvent.dispatch({ pitch: 0, yaw: 0 });
|
||||
}, [tank.id, turret.id, gun.id]);
|
||||
}, [protagonist.tank.id, protagonist.turret.id, protagonist.gun.id]);
|
||||
|
||||
function outline(limit: "max" | "min") {
|
||||
const m = magTilted(p[limit]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { youtubers } from "@blitzkit/core";
|
||||
import { awaitableReviews } from "../../core/awaitables/reviews";
|
||||
import { DuelModule, useDuel } from "../../hooks/useDuel";
|
||||
import { useDuelModules } from "../../hooks/useDuelModules";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import { Heading } from "../Heading";
|
||||
import { Text } from "../Text";
|
||||
@@ -11,8 +11,8 @@ const reviews = await awaitableReviews;
|
||||
|
||||
export function TankopediaVideos() {
|
||||
const strings = useStrings();
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const videos = reviews.reviews[tank.id]?.videos;
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
const videos = reviews.reviews[protagonist.tank.id]?.videos;
|
||||
|
||||
if (videos === undefined || videos.length === 0) return null;
|
||||
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { classNames } from "../../ui/classNames";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
interface Props {
|
||||
interface Props extends ComponentProps<"div"> {
|
||||
children: ReactNode;
|
||||
tooltip: string;
|
||||
side?: "top" | "left";
|
||||
}
|
||||
|
||||
export function Tooltip({ children, tooltip, side = "top" }: Props) {
|
||||
export function Tooltip({
|
||||
children,
|
||||
tooltip,
|
||||
side = "top",
|
||||
className,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={styles.tooltip}>
|
||||
<div className={classNames(styles.tooltip, className)} {...props}>
|
||||
<div className={styles.text} data-side={side}>
|
||||
{tooltip}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { ProvisionDefinitions, TankDefinition } from "@blitzkit/protos";
|
||||
import type { TankDefinition } from "@blitzkit/protos";
|
||||
import { uniqueId } from "lodash-es";
|
||||
import type { CompareMember } from "../../stores/compareEphemeral";
|
||||
import { tankToDuelMember } from "./tankToDuelMember";
|
||||
import { createTankState } from "../../tankopedia/createTankState";
|
||||
|
||||
export function tankToCompareMember(
|
||||
tank: TankDefinition,
|
||||
provisionDefinitions: ProvisionDefinitions,
|
||||
) {
|
||||
export function tankToCompareMember(tank: TankDefinition) {
|
||||
return {
|
||||
...tankToDuelMember(tank, provisionDefinitions),
|
||||
key: uniqueId(),
|
||||
state: createTankState(tank),
|
||||
} satisfies CompareMember;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { CharacteristicType } from "../types/characteristics";
|
||||
import { useLocale } from "./useLocale";
|
||||
import { useStrings } from "./useStrings";
|
||||
|
||||
export function useCharacteristicRenderer() {
|
||||
export function useCharacteristicRenderer(units = true) {
|
||||
const locale = useLocale();
|
||||
const strings = useStrings();
|
||||
|
||||
@@ -21,6 +21,8 @@ export function useCharacteristicRenderer() {
|
||||
): ReactNode {
|
||||
if (characteristic === null) return null;
|
||||
|
||||
const unit = units ? config.units : undefined;
|
||||
|
||||
if (config.render)
|
||||
return config.render({ output: characteristic, strings });
|
||||
|
||||
@@ -58,7 +60,7 @@ export function useCharacteristicRenderer() {
|
||||
return characteristic as string;
|
||||
|
||||
case CharacteristicType.Number:
|
||||
return renderNumber(characteristic as number, config.units);
|
||||
return renderNumber(characteristic as number, unit);
|
||||
|
||||
case CharacteristicType.Interval: {
|
||||
const [low, high] = characteristic as Vector2Tuple;
|
||||
@@ -67,9 +69,9 @@ export function useCharacteristicRenderer() {
|
||||
max: renderNumber(high, undefined),
|
||||
});
|
||||
|
||||
if (config.units === undefined) return rendered;
|
||||
if (unit === undefined) return rendered;
|
||||
|
||||
return literalsArray(strings.units[config.units], {
|
||||
return literalsArray(strings.units[unit], {
|
||||
value: <Text>{rendered}</Text>,
|
||||
});
|
||||
}
|
||||
@@ -82,11 +84,11 @@ export function useCharacteristicRenderer() {
|
||||
</Fragment>
|
||||
));
|
||||
|
||||
if (config.units === undefined) return values;
|
||||
if (unit === undefined) return values;
|
||||
|
||||
return (
|
||||
<>
|
||||
{literalsArray(strings.units[config.units], {
|
||||
{literalsArray(strings.units[unit], {
|
||||
value: <Text>{values}</Text>,
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { api } from "../api/dynamic";
|
||||
import { Tankopedia, TankopediaRelativeAgainst } from "../stores/tankopedia";
|
||||
import { createTankState } from "../tankopedia/createTankState";
|
||||
import { computeCharacteristics } from "./useCharacteristics";
|
||||
import { DuelModule, useDuel } from "./useDuel";
|
||||
import { useDuelModules } from "./useDuelModules";
|
||||
|
||||
const tankDefinitions = await api.tanks();
|
||||
|
||||
@@ -26,10 +26,10 @@ const candidateStates = new Map(
|
||||
export function useComparisonCharacteristics() {
|
||||
const environment = Tankopedia.use((state) => state.environment);
|
||||
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
const gun = useDuel("protagonist", DuelModule.Gun);
|
||||
const shell = useDuel("protagonist", DuelModule.Shell);
|
||||
const protagonistShellIndex = gun.shells.findIndex((s) => s.id === shell.id);
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
const protagonistShellIndex = protagonist.gun.shells.findIndex(
|
||||
(s) => s.id === protagonist.shell.id,
|
||||
);
|
||||
|
||||
const relativeAgainst = Tankopedia.use((state) => state.relative_against);
|
||||
|
||||
@@ -37,16 +37,19 @@ export function useComparisonCharacteristics() {
|
||||
return Object.values(tankDefinitions.tanks).filter((candidate) => {
|
||||
switch (relativeAgainst) {
|
||||
case TankopediaRelativeAgainst.ClassAndTier:
|
||||
return candidate.tier === tank.tier && candidate.class === tank.class;
|
||||
return (
|
||||
candidate.tier === protagonist.tank.tier &&
|
||||
candidate.class === protagonist.tank.class
|
||||
);
|
||||
|
||||
case TankopediaRelativeAgainst.Tier:
|
||||
return candidate.tier === tank.tier;
|
||||
return candidate.tier === protagonist.tank.tier;
|
||||
|
||||
case TankopediaRelativeAgainst.All:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}, [tank, relativeAgainst]);
|
||||
}, [protagonist.tank, relativeAgainst]);
|
||||
|
||||
const characteristics = useMemo(() => {
|
||||
return candidates.flatMap((candidate) => {
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import type {
|
||||
EngineDefinition,
|
||||
GunDefinition,
|
||||
ShellDefinition,
|
||||
TankDefinition,
|
||||
TrackDefinition,
|
||||
TurretDefinition,
|
||||
} from "@blitzkit/protos";
|
||||
import { api } from "../api/dynamic";
|
||||
import { Tankopedia } from "../stores/tankopedia";
|
||||
import type { DuelSide } from "./useEquipment";
|
||||
|
||||
export enum DuelModule {
|
||||
Tank,
|
||||
Track,
|
||||
Engine,
|
||||
Turret,
|
||||
Gun,
|
||||
Shell,
|
||||
}
|
||||
|
||||
const tanks = await api.tanks();
|
||||
|
||||
export function useDuel(
|
||||
side: DuelSide,
|
||||
module: DuelModule.Tank,
|
||||
): TankDefinition;
|
||||
export function useDuel(
|
||||
side: DuelSide,
|
||||
module: DuelModule.Track,
|
||||
): TrackDefinition;
|
||||
export function useDuel(
|
||||
side: DuelSide,
|
||||
module: DuelModule.Engine,
|
||||
): EngineDefinition;
|
||||
export function useDuel(
|
||||
side: DuelSide,
|
||||
module: DuelModule.Turret,
|
||||
): TurretDefinition;
|
||||
export function useDuel(side: DuelSide, module: DuelModule.Gun): GunDefinition;
|
||||
export function useDuel(
|
||||
side: DuelSide,
|
||||
module: DuelModule.Shell,
|
||||
): ShellDefinition;
|
||||
|
||||
export function useDuel(side: DuelSide, module: DuelModule) {
|
||||
const tankId = Tankopedia.use((state) => state[side].tank);
|
||||
const tank = tanks.tanks[tankId];
|
||||
|
||||
if (module === DuelModule.Tank) return tank;
|
||||
|
||||
if (module === DuelModule.Track) {
|
||||
const trackId = Tankopedia.use((state) => state[side].track);
|
||||
return tank.tracks.find((track) => track.id === trackId)!;
|
||||
}
|
||||
|
||||
if (module === DuelModule.Engine) {
|
||||
const engineId = Tankopedia.use((state) => state[side].engine);
|
||||
return tank.engines.find((engine) => engine.id === engineId)!;
|
||||
}
|
||||
|
||||
const turretId = Tankopedia.use((state) => state[side].turret);
|
||||
const turret = tank.turrets.find((turret) => turret.id === turretId)!;
|
||||
|
||||
if (module === DuelModule.Turret) return turret;
|
||||
|
||||
const gunId = Tankopedia.use((state) => state[side].gun);
|
||||
const gun = turret.guns.find((gun) => gun.id === gunId)!;
|
||||
|
||||
if (module === DuelModule.Gun) return gun;
|
||||
|
||||
const shellId = Tankopedia.use((state) => state[side].shell);
|
||||
return gun.shells.find((shell) => shell.id === shellId)!;
|
||||
}
|
||||
|
||||
export function getDuel(
|
||||
side: DuelSide,
|
||||
module: DuelModule.Tank,
|
||||
): TankDefinition;
|
||||
export function getDuel(
|
||||
side: DuelSide,
|
||||
module: DuelModule.Track,
|
||||
): TrackDefinition;
|
||||
export function getDuel(
|
||||
side: DuelSide,
|
||||
module: DuelModule.Engine,
|
||||
): EngineDefinition;
|
||||
export function getDuel(
|
||||
side: DuelSide,
|
||||
module: DuelModule.Turret,
|
||||
): TurretDefinition;
|
||||
export function getDuel(side: DuelSide, module: DuelModule.Gun): GunDefinition;
|
||||
export function getDuel(
|
||||
side: DuelSide,
|
||||
module: DuelModule.Shell,
|
||||
): ShellDefinition;
|
||||
|
||||
export function getDuel(side: DuelSide, module: DuelModule) {
|
||||
const tank = tanks.tanks[Tankopedia.state[side].tank];
|
||||
|
||||
if (module === DuelModule.Tank) return tank;
|
||||
|
||||
if (module === DuelModule.Track) {
|
||||
return tank.tracks.find(
|
||||
(track) => track.id === Tankopedia.state[side].track,
|
||||
)!;
|
||||
}
|
||||
|
||||
if (module === DuelModule.Engine) {
|
||||
return tank.engines.find(
|
||||
(engine) => engine.id === Tankopedia.state[side].engine,
|
||||
)!;
|
||||
}
|
||||
|
||||
const turret = tank.turrets.find(
|
||||
(turret) => turret.id === Tankopedia.state[side].turret,
|
||||
)!;
|
||||
|
||||
if (module === DuelModule.Turret) return turret;
|
||||
|
||||
const gun = turret.guns.find((gun) => gun.id === Tankopedia.state[side].gun)!;
|
||||
|
||||
if (module === DuelModule.Gun) return gun;
|
||||
|
||||
return gun.shells.find((shell) => shell.id === Tankopedia.state[side].shell)!;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Tankopedia } from "../stores/tankopedia";
|
||||
import { resolveTankModels } from "../tankopedia/resolveTankModels";
|
||||
import type { DuelSide } from "./useEquipment";
|
||||
|
||||
// Every model of a side
|
||||
export function useDuelModels(side: DuelSide) {
|
||||
const state = Tankopedia.use((state) => state[side]);
|
||||
|
||||
return resolveTankModels(state);
|
||||
}
|
||||
|
||||
export function getDuelModels(side: DuelSide) {
|
||||
return resolveTankModels(Tankopedia.state[side]);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { resolveTankModules } from "../tankopedia/resolveTankModules";
|
||||
import { Tankopedia } from "../stores/tankopedia";
|
||||
import type { DuelSide } from "./useEquipment";
|
||||
|
||||
// Every module of a side
|
||||
export function useDuelModules(side: DuelSide) {
|
||||
const state = Tankopedia.use((state) => state[side]);
|
||||
|
||||
return resolveTankModules(state);
|
||||
}
|
||||
|
||||
export function getDuelModules(side: DuelSide) {
|
||||
return resolveTankModules(Tankopedia.state[side]);
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import sharp from "sharp";
|
||||
import { vfs } from "../../../core/blitzkit/vfs";
|
||||
|
||||
export { getStaticPaths } from "../_index";
|
||||
|
||||
export async function GET() {
|
||||
const bytes = await vfs.file("Data/Gfx/UI/Hangar/IconCamouflage.packed.webp");
|
||||
return new Response(bytes);
|
||||
const buffer = await sharp(
|
||||
await vfs.file("Data/Gfx/UI/Hangar/IconCamouflage.packed.webp"),
|
||||
)
|
||||
.trim()
|
||||
.toBuffer();
|
||||
|
||||
return new Response(new Uint8Array(buffer));
|
||||
}
|
||||
@@ -47,8 +47,6 @@ export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () => {
|
||||
),
|
||||
);
|
||||
|
||||
console.log(consumable);
|
||||
|
||||
if (!styleSheet) {
|
||||
console.warn(
|
||||
`No style sheet found for consumable ${consumable.icon}; skipping...`,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
.page {
|
||||
/* Fixed to the viewport so the table scrolls, not the page */
|
||||
height: 100dvh;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
overflow: hidden;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -1,112 +1,55 @@
|
||||
import { createDefaultSkills } from "@blitzkit/core";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createDefaultEnvironment } from "@blitzkit/core";
|
||||
import { useEffect } from "react";
|
||||
import { api } from "../../../../api/dynamic";
|
||||
import { CompareTable } from "../../../../components/Compare/CompareTable";
|
||||
import { Controls } from "../../../../components/Compare/Controls";
|
||||
import { DamageWarning } from "../../../../components/DamageWarning";
|
||||
import { tankCharacteristics } from "../../../../core/blitzkit/tankCharacteristics";
|
||||
import { CompareControls } from "../../../../components/CompareControls";
|
||||
import { CompareTable } from "../../../../components/CompareTable";
|
||||
import { EmptyState } from "../../../../components/EmptyState";
|
||||
import { tankToCompareMember } from "../../../../core/blitzkit/tankToCompareMember";
|
||||
import {
|
||||
type LocaleAcceptorProps,
|
||||
LocaleProvider,
|
||||
useLocale,
|
||||
} from "../../../../hooks/useLocale";
|
||||
import { withErrorWrapper } from "../../../../hocs/withErrorWrapper";
|
||||
import { withLocale } from "../../../../hocs/withLocale";
|
||||
import { useStrings } from "../../../../hooks/useStrings";
|
||||
import { CompareEphemeral } from "../../../../stores/compareEphemeral";
|
||||
import styles from "./_index.module.css";
|
||||
|
||||
const [
|
||||
modelDefinitions,
|
||||
equipmentDefinitions,
|
||||
provisionDefinitions,
|
||||
skillDefinitions,
|
||||
tankDefinitions,
|
||||
] = await Promise.all([
|
||||
api.models(),
|
||||
api.equipment(),
|
||||
api.provisions(),
|
||||
const [skillDefinitions, tankDefinitions] = await Promise.all([
|
||||
api.skills(),
|
||||
api.tanks(),
|
||||
]);
|
||||
|
||||
export function Page({ locale }: LocaleAcceptorProps) {
|
||||
CompareEphemeral.useInitialization(createDefaultSkills(skillDefinitions));
|
||||
export const Page = withLocale(
|
||||
withErrorWrapper(() => {
|
||||
CompareEphemeral.useInitialization(
|
||||
createDefaultEnvironment(skillDefinitions),
|
||||
);
|
||||
|
||||
return (
|
||||
<LocaleProvider locale={locale}>
|
||||
<Content />
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
return <Content />;
|
||||
}),
|
||||
);
|
||||
|
||||
function Content() {
|
||||
const strings = useStrings();
|
||||
const members = CompareEphemeral.use((state) => state.members);
|
||||
const equalize = CompareEphemeral.use((state) => state.equalize);
|
||||
const [addTankDialogOpen, setAddTankDialogOpen] = useState(false);
|
||||
const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
|
||||
const { strings } = useLocale();
|
||||
const stats = useMemo<ReturnType<typeof tankCharacteristics>[]>(
|
||||
() =>
|
||||
members.map((thisMember) =>
|
||||
tankCharacteristics(
|
||||
{
|
||||
...thisMember,
|
||||
crewSkills,
|
||||
stockEngine: thisMember.tank.engines[0],
|
||||
stockGun: thisMember.tank.turrets[0].guns[0],
|
||||
stockTrack: thisMember.tank.tracks[0],
|
||||
stockTurret: thisMember.tank.turrets[0],
|
||||
applyReactiveArmor: members.some(
|
||||
(member) =>
|
||||
member.key !== thisMember.key &&
|
||||
member.consumables.includes(33),
|
||||
),
|
||||
applyDynamicArmor: members.some(
|
||||
(member) =>
|
||||
member.key !== thisMember.key &&
|
||||
member.consumables.includes(73),
|
||||
),
|
||||
applySpallLiner: members.some(
|
||||
(member) =>
|
||||
member.key !== thisMember.key &&
|
||||
member.provisions.includes(101),
|
||||
),
|
||||
assaultDistance: thisMember.assaultDistance,
|
||||
equalize,
|
||||
},
|
||||
{
|
||||
equipmentDefinitions: equipmentDefinitions,
|
||||
provisionDefinitions: provisionDefinitions,
|
||||
tankModelDefinition: modelDefinitions.models[thisMember.tank.id],
|
||||
},
|
||||
),
|
||||
),
|
||||
[members, crewSkills, equalize],
|
||||
);
|
||||
|
||||
// Tanks come from the url on load
|
||||
useEffect(() => {
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
const tanksQuery = search.get("tanks");
|
||||
const tanks = new URLSearchParams(window.location.search).get("tanks");
|
||||
|
||||
if (tanksQuery === null) return;
|
||||
|
||||
const tanks = tanksQuery.split(",").map(Number);
|
||||
if (tanks === null) return;
|
||||
|
||||
CompareEphemeral.mutate((draft) => {
|
||||
draft.members = [];
|
||||
|
||||
tanks.forEach((id) => {
|
||||
const tank = tankDefinitions.tanks[id];
|
||||
|
||||
if (tank === undefined) return;
|
||||
|
||||
draft.members.push(tankToCompareMember(tank, provisionDefinitions));
|
||||
});
|
||||
draft.members = tanks
|
||||
.split(",")
|
||||
.map((id) => tankDefinitions.tanks[Number(id)])
|
||||
.filter((tank) => tank !== undefined)
|
||||
.map(tankToCompareMember);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ...and are written back whenever they change
|
||||
useEffect(() => {
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
|
||||
search.set("tanks", members.map((member) => member.tank.id).join(","));
|
||||
search.set("tanks", members.map(({ state }) => state.tank).join(","));
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
@@ -116,36 +59,23 @@ function Content() {
|
||||
}, [members]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex justify="center" gap="4" align="center" direction="column">
|
||||
<DamageWarning />
|
||||
<Controls
|
||||
addTankDialogOpen={addTankDialogOpen}
|
||||
onAddTankDialogOpenChange={setAddTankDialogOpen}
|
||||
/>
|
||||
</Flex>
|
||||
<div className={styles.page}>
|
||||
<div className={styles.top}>
|
||||
<CompareControls />
|
||||
</div>
|
||||
|
||||
{members.length > 0 && (
|
||||
<Flex justify="center" flexGrow="1" position="relative">
|
||||
<CompareTable stats={stats} />
|
||||
</Flex>
|
||||
<div className={styles.table}>
|
||||
<CompareTable />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{members.length === 0 && (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
direction="column"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Heading color="gray">
|
||||
{strings.website.tools.compare.no_tanks.title}
|
||||
</Heading>
|
||||
<Text color="gray">
|
||||
{strings.website.tools.compare.no_tanks.body}
|
||||
</Text>
|
||||
</Flex>
|
||||
<EmptyState
|
||||
title={strings.compare.no_tanks.title}
|
||||
body={strings.compare.no_tanks.body}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
import ToolOpenGraph from "../../../../components/ToolOpenGraph.astro";
|
||||
import { tools } from "../../../../constants/tools";
|
||||
import ColorScale from "../../../../components/ColorScale.astro";
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import { Page } from "./_index";
|
||||
|
||||
@@ -9,8 +8,13 @@ export { getStaticPaths } from "../index.astro";
|
||||
const { locale } = Astro.props;
|
||||
---
|
||||
|
||||
<ToolOpenGraph {locale} tool={tools.compare} />
|
||||
<BaseLayout accent="pink" {locale}>
|
||||
<ColorScale color="red" />
|
||||
<ColorScale color="red" alpha />
|
||||
<ColorScale color="jade" />
|
||||
<ColorScale color="amber" />
|
||||
<ColorScale color="amber" alpha />
|
||||
<ColorScale color="blue" />
|
||||
|
||||
<BaseLayout {locale} noFooter>
|
||||
<Page {locale} client:load />
|
||||
</BaseLayout>
|
||||
@@ -5,7 +5,7 @@ import { GuessRenderer } from "../../../../components/GuessRenderer";
|
||||
import { GuessRendererLoader } from "../../../../components/GuessRendererLoader";
|
||||
import { withErrorWrapper } from "../../../../hocs/withErrorWrapper";
|
||||
import { withLocale } from "../../../../hocs/withLocale";
|
||||
import { DuelModule, useDuel } from "../../../../hooks/useDuel";
|
||||
import { useDuelModules } from "../../../../hooks/useDuelModules";
|
||||
import { Guess, GuessState } from "../../../../stores/guess";
|
||||
import { Tankopedia } from "../../../../stores/tankopedia";
|
||||
import { createTankState } from "../../../../tankopedia/createTankState";
|
||||
@@ -42,12 +42,11 @@ function Container({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
}
|
||||
|
||||
function Content({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
|
||||
const protagonist = useDuelModules("protagonist");
|
||||
const tiers = Guess.use((state) => state.tiers);
|
||||
|
||||
useEffect(() => {
|
||||
const isTankAlreadyCompliant = tiers.includes(tank.tier);
|
||||
const isTankAlreadyCompliant = tiers.includes(protagonist.tank.tier);
|
||||
|
||||
if (isTankAlreadyCompliant) return;
|
||||
|
||||
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user