ComputedCharacteristics

This commit is contained in:
tresabhi committed 2026-09-22 16:17:55 -05:00
1 parent 7d02f6311f
commit 7592381b12
4 files changed
+841 -4

No files matched your search

@@ -1,5 +1,7 @@
export function computeCharacteristics() {
const characteristics = {};
import type { ComputedCharacteristics } from "../types/characteristics";
return characteristics;
export function computeCharacteristics() {
const characteristics: Partial<ComputedCharacteristics> = {};
return characteristics as ComputedCharacteristics;
}
@@ -0,0 +1,183 @@
.section {
display: flex;
flex-direction: column;
gap: var(--space-4);
.header {
display: flex;
align-items: center;
gap: var(--space-3);
.upgrade-buttons {
display: flex;
}
}
.state {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.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);
}
}
}
}
}
.consumables {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
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;
padding: var(--space-3);
font-size: var(--space-5);
&[data-selected="true"] {
z-index: 1;
}
}
}
}
}
}
.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%);
}
}
}
}
}
}
}
}
@@ -0,0 +1,644 @@
import type { ComputedCharacteristics } from "../../types/characteristics";
interface TankopediaLoadoutProps {
characteristics: ComputedCharacteristics;
}
export function TankopediaLoadout({ characteristics }: TankopediaLoadoutProps) {
const tank = useProtagonist();
const showModules = tank.tank!.upgrade_lines.some(
(line) => line.stages.length > 1 || isAlternativeLine(line.name),
);
return (
<>
{showModules && <Modules />}
<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();
const tank = useProtagonist();
const isCompatible = useCompatibility(tank, characteristics);
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}>
{Object.entries(consumables.consumables).map(
([id, { compatibility, consumable, purchase }]) => {
if (!isCompatible(compatibility!)) return null;
return (
<Consumable
key={id}
id={id}
consumable={consumable!}
purchase={purchase!}
/>
);
},
)}
</div>
</div>
);
}
interface ConsumableProps {
id: string;
consumable: ConsumableComponent;
purchase: BlitzStaticPurchaseComponent;
}
function Consumable({ id, consumable, purchase }: ConsumableProps) {
const gameStrings = useGameStrings("ConsumableEntity");
const isSelected = Tankopedia.use((state) =>
state.protagonist.consumables.includes(id),
);
const tank = useProtagonist();
const tierPrices = useTierPrices();
const price = useMemo(() => {
const { prices } = tierPrices.prices[purchase.price_per_tier_catalog_id];
for (const { tier_catalog_id, unlock_price } of prices) {
if (tier_catalog_id === tank.tank!.tier_catalog_id) {
return unlock_price;
}
}
throw new Error(
`No price found for tier catalog id ${tank.tank!.tier_catalog_id}`,
);
}, [tank.id, purchase.price_per_tier_catalog_id]);
return (
<div className={styles["consumable-wrapper"]}>
<Tooltip tooltip={gameStrings[consumable!.name_key]}>
<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);
}
});
}}
>
<img src={`/media/consumables/${id}.webp`} />
<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>
<Price price={price} />
</div>
);
}
function Equipment() {
const strings = useStrings();
const tank = useProtagonist();
const equipment = useEquipment(tank.tank!);
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}>
{chunk(equipment.preset.slots, equipmentColumns).map((chunk, index) => (
<EquipmentRow key={index} chunk={chunk} rowIndex={index} />
))}
</div>
</div>
);
}
interface EquipmentRowProps {
chunk: BlitzStaticEquipmentPresetComponent_EquipmentSlot[];
rowIndex: number;
}
function EquipmentRow({ chunk, rowIndex }: EquipmentRowProps) {
return (
<div className={styles.row}>
{chunk.map((slot, index) => (
<EquipmentSlot
key={index}
slot={slot}
rowIndex={rowIndex}
columnIndex={index}
/>
))}
</div>
);
}
interface EquipmentSlotProps {
slot: BlitzStaticEquipmentPresetComponent_EquipmentSlot;
rowIndex: number;
columnIndex: number;
}
function EquipmentSlot({ slot, rowIndex, columnIndex }: EquipmentSlotProps) {
const equipmentIndex = rowIndex * equipmentColumns + columnIndex;
const tank = useProtagonist();
const equipment = useEquipment(tank.tank!);
const { price } = equipment.price.unlock_slot_prices[equipmentIndex];
return (
<div className={styles.slots}>
<div className={styles.options}>
{slot.options_catalog_i_ds.map((id, index) => (
<EquipmentOption
key={id}
optionIndex={index}
rowIndex={rowIndex}
columnIndex={columnIndex}
/>
))}
</div>
<Price price={price!} />
</div>
);
}
interface EquipmentOptionProps {
optionIndex: number;
rowIndex: number;
columnIndex: number;
}
function EquipmentOption({
optionIndex,
rowIndex,
columnIndex,
}: EquipmentOptionProps) {
const equipmentIndex = rowIndex * equipmentColumns + columnIndex;
const tank = useProtagonist();
const equipment = useEquipment(tank.tank!);
const slot = equipment.preset.slots[equipmentIndex];
const id = slot.options_catalog_i_ds[optionIndex];
const gameStrings = useGameStrings("EquipmentEntity");
const isSelected = Tankopedia.use(
(state) =>
equipmentIndex in state.protagonist.equipment &&
state.protagonist.equipment[equipmentIndex] === optionIndex,
);
return (
<Tooltip tooltip={gameStrings[equipment.equipments[id].equipment_name]}>
<Button
color={isSelected ? undefined : "gray"}
variant={isSelected ? "surface" : "soft"}
data-selected={isSelected}
radius="1"
parentArray
className={styles.slot}
onClick={() => {
Tankopedia.mutate((draft) => {
if (isSelected) {
delete draft.protagonist.equipment[equipmentIndex];
} else {
draft.protagonist.equipment[equipmentIndex] = optionIndex;
}
});
}}
>
<img src={`/media/equipment/${id}.webp`} />
</Button>
</Tooltip>
);
}
function Modules() {
const strings = useStrings();
const tank = useProtagonist();
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;
return (
upgrades[line.name] === line.stages.length - 1 ||
(line.name in alternates && alternates[line.name])
);
});
}, [upgrades]);
return (
<div className={styles.section}>
<div className={styles.header}>
<Heading size="4">{strings.tanks.loadout.modules}</Heading>
<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;
draft.protagonist.upgrades[line.name] = 0;
draft.protagonist.alternates[line.name] = false;
}
});
}}
color="gray"
>
<DoubleArrowLeftIcon />
</IconButton>
<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;
if (isAlternativeLine(line.name)) {
const original = originalLineName(line.name)!;
draft.protagonist.alternates[original] = true;
}
}
});
}}
color="gray"
>
<DoubleArrowRightIcon />
</IconButton>
</div>
</div>
<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>
);
}
interface LineProps {
name: string;
lines: UpgradeLine[];
}
function Line({ name, lines }: LineProps) {
const combinedLines = useMemo(() => {
const names: string[] = [name];
for (const originalLine in alternativeLines) {
const alternativeLine = alternativeLines[originalLine];
if (
originalLine === name &&
lines.some((line) => line.name === alternativeLine)
) {
names.push(alternativeLine);
}
}
return names;
}, [name]);
return (
<div className={styles.line}>
{!isAlternativeLine(name) &&
combinedLines.map((name) => (
<div className={styles["chunks-wrapper"]}>
<LineInner key={name} name={name} lines={lines} />
</div>
))}
</div>
);
}
function LineInner({ name, lines }: LineProps) {
const line = lines.find((line) => line.name === name)!;
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>
)}
{chunk.map((stage, elementIndex) => (
<LineElement
key={elementIndex}
index={wrapIndex * maxModulesPerRow + elementIndex}
lineName={name}
stage={stage}
/>
))}
</div>
));
}
interface LineElementProps {
index: number;
lineName: string;
stage: StageParameters;
}
function LineElement({ index, lineName, stage }: LineElementProps) {
const upgrades = Tankopedia.use((state) => state.protagonist.upgrades);
const alternates = Tankopedia.use((state) => state.protagonist.alternates);
const tank = useProtagonist();
const strings = useStrings();
const gameStrings = useGameStrings("TankEntity");
const upgradePreset = useUpgradePreset(tank.tank!.tank_upgrade_preset);
let price: StandardPrice | undefined;
switch (stage.grade) {
case Grade.GRADE_COMMON:
price = upgradePreset.common_grade_price;
break;
case Grade.GRADE_RARE:
price = upgradePreset.rare_grade_price;
break;
case Grade.GRADE_EPIC:
price = upgradePreset.unique_grade_price;
break;
case Grade.GRADE_LEGENDARY:
price = upgradePreset.legendary_grade_price;
break;
}
for (const override of upgradePreset.prices_overrides) {
if (override.stage_tech_name !== stage.tech_name) continue;
price = override.price;
}
price ??= StandardPrice.create();
let isSelected: boolean;
if (isAlternativeLine(lineName)) {
const originalLine = originalLineName(lineName);
isSelected = alternates[originalLine!];
} else {
if (alternates[lineName]) {
isSelected = false;
} else {
isSelected = upgrades[lineName] === index;
}
}
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
Tankopedia.mutate((draft) => {
const tankData = tank.tank!;
if (isAlternativeLine(lineName)) {
const originalLine = originalLineName(lineName)!;
draft.protagonist.alternates[originalLine] = true;
draft.protagonist.upgrades[lineName] = index;
} else {
draft.protagonist.alternates[lineName] = false;
draft.protagonist.upgrades[lineName] = index;
}
for (const required of stage.required_upgrades) {
for (const line of tankData.upgrade_lines) {
let i = 0;
for (const candidateStage of line.stages) {
if (candidateStage.tech_name === required) {
draft.protagonist.upgrades[line.name] = Math.max(
draft.protagonist.upgrades[line.name],
i,
);
}
i++;
}
}
}
for (const line of tankData.upgrade_lines) {
if (draft.protagonist.alternates[line.name]) {
continue;
}
let i = draft.protagonist.upgrades[line.name];
while (i > 0) {
const currentStage = line.stages[i];
const valid = currentStage.required_upgrades.every(
(required) => {
for (const otherLine of tankData.upgrade_lines) {
const idx = draft.protagonist.upgrades[otherLine.name];
if (otherLine.stages[idx]?.tech_name === required) {
return true;
}
}
return false;
},
);
if (valid) break;
i--;
}
draft.protagonist.upgrades[line.name] = i;
}
});
}}
>
<img
className={styles.icon}
src={`/media/modules/${stage.stage_type}.webp`}
/>
<Text weight="light" lowContrast size="minor" className={styles.tier}>
{isAlternativeLine(lineName)
? strings.tanks.loadout.alternative
: romanize(stage.number)}
</Text>
</Button>
</Tooltip>
{<Price className={styles.price} price={price} />}
</div>
);
}
@@ -23,7 +23,7 @@ interface CharacteristicContext {
assault_distance: number;
characteristic(
name: keyof typeof characteristics,
name: CharacteristicName,
): ReturnType<Characteristic["compute"]>;
}
@@ -31,3 +31,11 @@ export enum CharacteristicType {
Enum,
Number,
}
type CharacteristicName = keyof typeof characteristics;
type CharacteristicReturnType = ReturnType<Characteristic["compute"]>;
export type ComputedCharacteristics = Record<
CharacteristicName,
CharacteristicReturnType
>;