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:
tresabhi committed 2026-11-14 01:17:38 -06:00
1 parent 287aba853f
commit c311a6ab8b
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,
};
+17 -12
View File
@@ -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) => {
-126
View File
@@ -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