Files
blitzkit/packages/website/src/components/TankopediaLoadout/index.tsx
T

655 lines
19 KiB
TypeScript
Raw Normal View History

2026-10-01 11:59:19 -05:00
import { alias } from "@blitzkit/core";
2026-09-27 13:49:02 -05:00
import type { Consumable, EquipmentSlot } from "@blitzkit/protos";
import { ClockIcon, ResetIcon, TrashIcon } from "@radix-ui/react-icons";
import { chunk } from "lodash-es";
import { api } from "../../api/dynamic";
import { equipmentColumns } from "../../config/equipment";
2026-09-23 10:39:12 -05:00
import { useConsumables } from "../../hooks/useConsumables";
2026-09-27 13:49:02 -05:00
import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useEquipmentPreset } from "../../hooks/useEquipmentPreset";
2026-09-22 16:38:30 -05:00
import { useStrings } from "../../hooks/useStrings";
2026-09-23 17:25:51 -05:00
import { useTankCompatibility } from "../../hooks/useTankCompatibility";
2026-09-27 13:49:02 -05:00
import { useUnwrapper } from "../../hooks/useUnwrapper";
2026-09-22 16:38:30 -05:00
import { Tankopedia } from "../../stores/tankopedia";
2026-09-22 16:24:29 -05:00
import { hasUpgrades } from "../../tankopedia/hasUpgrades";
2026-09-23 10:25:47 -05:00
import { vehicleStatusKeys } from "../../tankopedia/tankState";
2026-09-22 16:17:55 -05:00
import type { ComputedCharacteristics } from "../../types/characteristics";
2026-09-27 13:49:02 -05:00
import { Button } from "../Button";
2026-09-23 10:22:57 -05:00
import { Heading } from "../Heading";
import { IconButton } from "../IconButton";
2026-09-27 13:49:02 -05:00
import { Text } from "../Text";
2026-09-23 13:48:50 -05:00
import { Tooltip } from "../Tooltip";
2026-09-23 10:22:57 -05:00
import styles from "./index.module.css";
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
const equipment = await api.equipment();
2026-09-22 16:17:55 -05:00
interface TankopediaLoadoutProps {
characteristics: ComputedCharacteristics;
}
export function TankopediaLoadout({ characteristics }: TankopediaLoadoutProps) {
2026-09-27 13:49:02 -05:00
const tank = useDuel("protagonist", DuelModule.Tank);
2026-09-22 16:24:29 -05:00
const showModules = hasUpgrades(tank);
2026-09-22 16:17:55 -05:00
return (
<>
2026-09-27 13:49:02 -05:00
{/* {showModules && <Modules />} */}
2026-09-22 16:17:55 -05:00
<Equipment />
<Consumables characteristics={characteristics} />
<State />
<Status />
</>
);
}
function State() {
const strings = useStrings();
const shell = Tankopedia.use((state) => state.protagonist.shell);
const speed = Tankopedia.use((state) => state.protagonist.speed);
return (
<div className={styles.section}>
<div className={styles.header}>
<Heading size="4">{strings.tanks.loadout.dynamics}</Heading>
<IconButton
color="red"
onClick={() => {
Tankopedia.mutate((draft) => {
draft.protagonist.consumables = [];
});
}}
>
<TrashIcon />
</IconButton>
</div>
<div className={styles.state}>
<span>shell: {shell}</span>
<span>speed: {speed}</span>
</div>
</div>
);
}
function Status() {
const strings = useStrings();
const status = Tankopedia.use((state) => state.protagonist.status);
return (
<div className={styles.section}>
<div className={styles.header}>
<Heading size="4">{strings.tanks.loadout.status}</Heading>
<IconButton
color="red"
onClick={() => {
Tankopedia.mutate((draft) => {
draft.protagonist.consumables = [];
});
}}
>
<TrashIcon />
</IconButton>
</div>
<div className={styles.status}>
{vehicleStatusKeys.map((group, index) => (
<div key={index} className={styles.group}>
{group.map((key) => (
<IconButton
key={key}
radius="1"
className={styles.state}
data-selected={status[key]}
color={status[key] ? "amber" : "gray"}
variant={status[key] ? "surface" : "soft"}
onClick={() => {
Tankopedia.mutate((draft) => {
draft.protagonist.status[key] =
!draft.protagonist.status[key];
});
}}
>
<img src={`/media/status/${key}.png`} />
</IconButton>
))}
</div>
))}
</div>
</div>
);
}
interface ConsumablesProps {
characteristics: ComputedCharacteristics;
}
function Consumables({ characteristics }: ConsumablesProps) {
const strings = useStrings();
const consumables = useConsumables();
2026-09-27 13:49:02 -05:00
const tank = useDuel("protagonist", DuelModule.Tank);
const gun = useDuel("protagonist", DuelModule.Gun);
2026-09-23 16:51:09 -05:00
const isCompatible = useTankCompatibility(tank, gun);
2026-09-22 16:17:55 -05:00
return (
<div className={styles.section}>
<div className={styles.header}>
<Heading size="4">{strings.tanks.loadout.consumables}</Heading>
<IconButton
color="red"
onClick={() => {
Tankopedia.mutate((draft) => {
draft.protagonist.consumables = [];
});
}}
>
<TrashIcon />
</IconButton>
</div>
<div className={styles.consumables}>
2026-09-23 10:39:12 -05:00
{Object.entries(consumables.consumables).map(([id, consumable]) => {
if (!isCompatible(consumable.include, consumable.exclude)) {
return null;
}
2026-09-22 16:17:55 -05:00
2026-09-23 13:48:50 -05:00
return (
2026-09-27 13:49:02 -05:00
<ConsumableEntry key={id} id={Number(id)} consumable={consumable} />
2026-09-23 13:48:50 -05:00
);
2026-09-23 10:39:12 -05:00
})}
2026-09-22 16:17:55 -05:00
</div>
</div>
);
}
interface ConsumableProps {
2026-09-23 13:48:50 -05:00
id: number;
2026-09-23 10:39:12 -05:00
consumable: Consumable;
2026-09-22 16:17:55 -05:00
}
2026-09-27 13:49:02 -05:00
function ConsumableEntry({ id, consumable }: ConsumableProps) {
2026-09-22 16:17:55 -05:00
const isSelected = Tankopedia.use((state) =>
state.protagonist.consumables.includes(id),
);
2026-09-27 13:49:02 -05:00
const tank = useDuel("protagonist", DuelModule.Tank);
const unwrap = useUnwrapper();
2026-09-22 16:17:55 -05:00
return (
<div className={styles["consumable-wrapper"]}>
2026-09-23 13:48:50 -05:00
<Tooltip tooltip={unwrap(consumable.name)}>
2026-09-22 16:17:55 -05:00
<Button
radius="1"
variant={isSelected ? "surface" : "soft"}
color={isSelected ? undefined : "gray"}
className={styles.consumable}
onClick={() => {
Tankopedia.mutate((draft) => {
const index = draft.protagonist.consumables.indexOf(id);
if (index === -1) {
draft.protagonist.consumables.push(id);
} else {
draft.protagonist.consumables.splice(index, 1);
}
});
}}
>
2026-10-01 11:59:19 -05:00
<img src={alias("api", `/icons/consumables/${consumable.id}.webp`)} />
2026-09-22 16:17:55 -05:00
<div className={styles.info}>
<Text lowContrast size="minor">
<div className={styles.entry}>
<ClockIcon />
10s
</div>
</Text>
<Text lowContrast size="minor">
<div className={styles.entry}>
<ResetIcon />
10s
</div>
</Text>
</div>
</Button>
</Tooltip>
2026-09-27 13:49:02 -05:00
{/* <Price price={price} /> */}
2026-09-22 16:17:55 -05:00
</div>
);
}
function Equipment() {
const strings = useStrings();
2026-09-27 13:49:02 -05:00
const tank = useDuel("protagonist", DuelModule.Tank);
const equipment = useEquipmentPreset(tank.equipment_preset);
2026-09-22 16:17:55 -05:00
return (
<div className={styles.section}>
<div className={styles.header}>
<Heading size="4">{strings.tanks.loadout.equipment}</Heading>
<IconButton
color="red"
onClick={() => {
Tankopedia.mutate((draft) => {
for (const key in draft.protagonist.equipment) {
delete draft.protagonist.equipment[key];
}
});
}}
>
<TrashIcon />
</IconButton>
</div>
<div className={styles.equipment}>
2026-09-27 13:49:02 -05:00
{chunk(equipment.slots, equipmentColumns).map((chunk, index) => (
2026-09-22 16:17:55 -05:00
<EquipmentRow key={index} chunk={chunk} rowIndex={index} />
))}
</div>
</div>
);
}
interface EquipmentRowProps {
2026-09-27 13:49:02 -05:00
chunk: EquipmentSlot[];
2026-09-22 16:17:55 -05:00
rowIndex: number;
}
function EquipmentRow({ chunk, rowIndex }: EquipmentRowProps) {
return (
<div className={styles.row}>
{chunk.map((slot, index) => (
2026-09-27 13:49:02 -05:00
<EquipmentSlotEntry
2026-09-22 16:17:55 -05:00
key={index}
slot={slot}
rowIndex={rowIndex}
columnIndex={index}
/>
))}
</div>
);
}
interface EquipmentSlotProps {
2026-09-27 13:49:02 -05:00
slot: EquipmentSlot;
2026-09-22 16:17:55 -05:00
rowIndex: number;
columnIndex: number;
}
2026-09-27 13:49:02 -05:00
function EquipmentSlotEntry({
slot,
rowIndex,
columnIndex,
}: EquipmentSlotProps) {
2026-09-22 16:17:55 -05:00
const equipmentIndex = rowIndex * equipmentColumns + columnIndex;
2026-09-27 13:49:02 -05:00
const tank = useDuel("protagonist", DuelModule.Tank);
const equipment = useEquipmentPreset(tank.equipment_preset);
// const { price } = equipment.price.unlock_slot_prices[equipmentIndex];
2026-09-22 16:17:55 -05:00
return (
<div className={styles.slots}>
<div className={styles.options}>
2026-09-27 13:49:02 -05:00
{slot.options.map((id, index) => (
2026-09-22 16:17:55 -05:00
<EquipmentOption
key={id}
optionIndex={index}
rowIndex={rowIndex}
columnIndex={columnIndex}
/>
))}
</div>
2026-09-27 13:49:02 -05:00
{/* <Price price={price!} /> */}
2026-09-22 16:17:55 -05:00
</div>
);
}
interface EquipmentOptionProps {
optionIndex: number;
rowIndex: number;
columnIndex: number;
}
function EquipmentOption({
optionIndex,
rowIndex,
columnIndex,
}: EquipmentOptionProps) {
const equipmentIndex = rowIndex * equipmentColumns + columnIndex;
2026-09-27 13:49:02 -05:00
const tank = useDuel("protagonist", DuelModule.Tank);
const equipmentPreset = useEquipmentPreset(tank.equipment_preset);
const unwrap = useUnwrapper();
const slot = equipmentPreset.slots[equipmentIndex];
const id = slot.options[optionIndex];
const specificEquipment = equipment.equipments[id];
2026-09-22 16:17:55 -05:00
const isSelected = Tankopedia.use(
(state) =>
equipmentIndex in state.protagonist.equipment &&
state.protagonist.equipment[equipmentIndex] === optionIndex,
);
return (
2026-09-27 13:49:02 -05:00
<Tooltip tooltip={unwrap(specificEquipment.name)}>
2026-10-01 11:59:19 -05:00
<IconButton
2026-09-22 16:17:55 -05:00
color={isSelected ? undefined : "gray"}
variant={isSelected ? "surface" : "soft"}
data-selected={isSelected}
radius="1"
2026-10-01 11:59:19 -05:00
parentListChild
2026-09-22 16:17:55 -05:00
className={styles.slot}
onClick={() => {
Tankopedia.mutate((draft) => {
if (isSelected) {
delete draft.protagonist.equipment[equipmentIndex];
} else {
draft.protagonist.equipment[equipmentIndex] = optionIndex;
}
});
}}
>
2026-10-01 11:59:19 -05:00
<img
className={styles.icon}
alt={unwrap(specificEquipment.name)}
src={alias("api", `/icons/equipment/${id}.webp`)}
/>
</IconButton>
2026-09-22 16:17:55 -05:00
</Tooltip>
);
}
2026-09-27 13:49:02 -05:00
// function Modules() {
// const strings = useStrings();
// const tank = useDuel("protagonist", DuelModule.Tank);
// const upgrades = Tankopedia.use((state) => state.protagonist.upgrades);
// const alternates = Tankopedia.use((state) => state.protagonist.alternates);
// const isStock = useMemo(() => {
// return tank.tank!.upgrade_lines.every((line) => {
// if (isAlternativeLine(line.name)) return true;
// return upgrades[line.name] === 0;
// });
// }, [upgrades]);
// const isUpgraded = useMemo(() => {
// return tank.tank!.upgrade_lines.every((line) => {
// if (isAlternativeLine(line.name)) return true;
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// return (
// upgrades[line.name] === line.stages.length - 1 ||
// (line.name in alternates && alternates[line.name])
// );
// });
// }, [upgrades]);
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// return (
// <div className={styles.section}>
// <div className={styles.header}>
// <Heading size="4">{strings.tanks.loadout.modules}</Heading>
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// <div className={styles["upgrade-buttons"]}>
// <IconButton
// variant={isStock ? "solid" : "soft"}
// size="minor"
// array
// onClick={() => {
// Tankopedia.mutate((draft) => {
// for (const line of tank.tank!.upgrade_lines) {
// if (isAlternativeLine(line.name)) continue;
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// draft.protagonist.upgrades[line.name] = 0;
// draft.protagonist.alternates[line.name] = false;
// }
// });
// }}
// color="gray"
// >
// <DoubleArrowLeftIcon />
// </IconButton>
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// <IconButton
// variant={isUpgraded ? "solid" : "soft"}
// size="minor"
// array
// onClick={() => {
// Tankopedia.mutate((draft) => {
// for (const line of tank.tank!.upgrade_lines) {
// draft.protagonist.upgrades[line.name] =
// line.stages.length - 1;
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// if (isAlternativeLine(line.name)) {
// const original = originalLineName(line.name)!;
// draft.protagonist.alternates[original] = true;
// }
// }
// });
// }}
// color="gray"
// >
// <DoubleArrowRightIcon />
// </IconButton>
// </div>
// </div>
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// <div className={styles.lines}>
// {/* TODO: hide lines with 1 module except for alts */}
// {tank.tank!.upgrade_lines.map((line) => (
// <Line
// key={line.name}
// name={line.name}
// lines={tank.tank!.upgrade_lines}
// />
// ))}
// </div>
// </div>
// );
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// interface LineProps {
// name: string;
// lines: UpgradeLine[];
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// function Line({ name, lines }: LineProps) {
// const combinedLines = useMemo(() => {
// const names: string[] = [name];
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// for (const originalLine in alternativeLines) {
// const alternativeLine = alternativeLines[originalLine];
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// if (
// originalLine === name &&
// lines.some((line) => line.name === alternativeLine)
// ) {
// names.push(alternativeLine);
// }
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// return names;
// }, [name]);
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// return (
// <div className={styles.line}>
// {!isAlternativeLine(name) &&
// combinedLines.map((name) => (
// <div className={styles["chunks-wrapper"]}>
// <LineInner key={name} name={name} lines={lines} />
// </div>
// ))}
// </div>
// );
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// function LineInner({ name, lines }: LineProps) {
// const line = lines.find((line) => line.name === name)!;
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// return chunk(line.stages, maxModulesPerRow).map((chunk, wrapIndex) => (
// <div className={styles["line-wrap"]}>
// {wrapIndex > 0 && (
// <Text size="minor" className={styles["wrap-marker"]} lowContrast>
// <CornerBottomLeftIcon />
// </Text>
// )}
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// {chunk.map((stage, elementIndex) => (
// <LineElement
// key={elementIndex}
// index={wrapIndex * maxModulesPerRow + elementIndex}
// lineName={name}
// stage={stage}
// />
// ))}
// </div>
// ));
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// interface LineElementProps {
// index: number;
// lineName: string;
// stage: StageParameters;
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// function LineElement({ index, lineName, stage }: LineElementProps) {
// const upgrades = Tankopedia.use((state) => state.protagonist.upgrades);
// const alternates = Tankopedia.use((state) => state.protagonist.alternates);
// const tank = useDuel("protagonist", DuelModule.Tank);
// const strings = useStrings();
// const gameStrings = useGameStrings("TankEntity");
// const upgradePreset = useUpgradePreset(tank.tank!.tank_upgrade_preset);
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// let price: StandardPrice | undefined;
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// switch (stage.grade) {
// case Grade.GRADE_COMMON:
// price = upgradePreset.common_grade_price;
// break;
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// case Grade.GRADE_RARE:
// price = upgradePreset.rare_grade_price;
// break;
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// case Grade.GRADE_EPIC:
// price = upgradePreset.unique_grade_price;
// break;
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// case Grade.GRADE_LEGENDARY:
// price = upgradePreset.legendary_grade_price;
// break;
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// for (const override of upgradePreset.prices_overrides) {
// if (override.stage_tech_name !== stage.tech_name) continue;
// price = override.price;
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// price ??= StandardPrice.create();
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// let isSelected: boolean;
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// if (isAlternativeLine(lineName)) {
// const originalLine = originalLineName(lineName);
// isSelected = alternates[originalLine!];
// } else {
// if (alternates[lineName]) {
// isSelected = false;
// } else {
// isSelected = upgrades[lineName] === index;
// }
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// return (
// <div className={styles.wrapper}>
// <Tooltip tooltip={gameStrings[stage.display_name]}>
// <Button
// className={styles.stage}
// color={isSelected ? undefined : "gray"}
// variant={isSelected ? "surface" : "soft"}
// radius="1"
// onClick={() => {
// // TODO: make minimum selectable upgrade the last free module for tanks like the destiny
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// Tankopedia.mutate((draft) => {
// const tankData = tank.tank!;
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// if (isAlternativeLine(lineName)) {
// const originalLine = originalLineName(lineName)!;
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// draft.protagonist.alternates[originalLine] = true;
// draft.protagonist.upgrades[lineName] = index;
// } else {
// draft.protagonist.alternates[lineName] = false;
// draft.protagonist.upgrades[lineName] = index;
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// for (const required of stage.required_upgrades) {
// for (const line of tankData.upgrade_lines) {
// let i = 0;
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// for (const candidateStage of line.stages) {
// if (candidateStage.tech_name === required) {
// draft.protagonist.upgrades[line.name] = Math.max(
// draft.protagonist.upgrades[line.name],
// i,
// );
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// i++;
// }
// }
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// for (const line of tankData.upgrade_lines) {
// if (draft.protagonist.alternates[line.name]) {
// continue;
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// let i = draft.protagonist.upgrades[line.name];
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// while (i > 0) {
// const currentStage = line.stages[i];
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// const valid = currentStage.required_upgrades.every(
// (required) => {
// for (const otherLine of tankData.upgrade_lines) {
// const idx = draft.protagonist.upgrades[otherLine.name];
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// if (otherLine.stages[idx]?.tech_name === required) {
// return true;
// }
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// return false;
// },
// );
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// if (valid) break;
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// i--;
// }
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// draft.protagonist.upgrades[line.name] = i;
// }
// });
// }}
// >
// <img
// className={styles.icon}
// src={`/media/modules/${stage.stage_type}.webp`}
// />
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// <Text weight="light" lowContrast size="minor" className={styles.tier}>
// {isAlternativeLine(lineName)
// ? strings.tanks.loadout.alternative
// : romanize(stage.number)}
// </Text>
// </Button>
// </Tooltip>
2026-09-22 16:17:55 -05:00
2026-09-27 13:49:02 -05:00
// {<Price className={styles.price} price={price} />}
// </div>
// );
// }