From 7592381b1254c419945d36eeaedca4ff31399e85 Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:17:55 -0500 Subject: [PATCH] ComputedCharacteristics --- .../src/blitzkit/computeCharacteristics.ts | 8 +- .../TankopediaLoadout/index.module.css | 183 +++++ .../components/TankopediaLoadout/index.tsx | 644 ++++++++++++++++++ packages/website/src/types/characteristics.ts | 10 +- 4 files changed, 841 insertions(+), 4 deletions(-) create mode 100644 packages/website/src/components/TankopediaLoadout/index.module.css create mode 100644 packages/website/src/components/TankopediaLoadout/index.tsx diff --git a/packages/website/src/blitzkit/computeCharacteristics.ts b/packages/website/src/blitzkit/computeCharacteristics.ts index 1bcbf78fb..ed53640b2 100644 --- a/packages/website/src/blitzkit/computeCharacteristics.ts +++ b/packages/website/src/blitzkit/computeCharacteristics.ts @@ -1,5 +1,7 @@ -export function computeCharacteristics() { - const characteristics = {}; +import type { ComputedCharacteristics } from "../types/characteristics"; - return characteristics; +export function computeCharacteristics() { + const characteristics: Partial = {}; + + return characteristics as ComputedCharacteristics; } diff --git a/packages/website/src/components/TankopediaLoadout/index.module.css b/packages/website/src/components/TankopediaLoadout/index.module.css new file mode 100644 index 000000000..04a0e04a1 --- /dev/null +++ b/packages/website/src/components/TankopediaLoadout/index.module.css @@ -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%); + } + } + } + } + } + } + } +} diff --git a/packages/website/src/components/TankopediaLoadout/index.tsx b/packages/website/src/components/TankopediaLoadout/index.tsx new file mode 100644 index 000000000..71bd8b29d --- /dev/null +++ b/packages/website/src/components/TankopediaLoadout/index.tsx @@ -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 && } + + + + + + ); +} + +function State() { + const strings = useStrings(); + + const shell = Tankopedia.use((state) => state.protagonist.shell); + const speed = Tankopedia.use((state) => state.protagonist.speed); + + return ( +
+
+ {strings.tanks.loadout.dynamics} + + { + Tankopedia.mutate((draft) => { + draft.protagonist.consumables = []; + }); + }} + > + + +
+ +
+ shell: {shell} + speed: {speed} +
+
+ ); +} + +function Status() { + const strings = useStrings(); + const status = Tankopedia.use((state) => state.protagonist.status); + + return ( +
+
+ {strings.tanks.loadout.status} + + { + Tankopedia.mutate((draft) => { + draft.protagonist.consumables = []; + }); + }} + > + + +
+ +
+ {vehicleStatusKeys.map((group, index) => ( +
+ {group.map((key) => ( + { + Tankopedia.mutate((draft) => { + draft.protagonist.status[key] = + !draft.protagonist.status[key]; + }); + }} + > + + + ))} +
+ ))} +
+
+ ); +} + +interface ConsumablesProps { + characteristics: ComputedCharacteristics; +} + +function Consumables({ characteristics }: ConsumablesProps) { + const strings = useStrings(); + const consumables = useConsumables(); + const tank = useProtagonist(); + const isCompatible = useCompatibility(tank, characteristics); + + return ( +
+
+ {strings.tanks.loadout.consumables} + + { + Tankopedia.mutate((draft) => { + draft.protagonist.consumables = []; + }); + }} + > + + +
+ +
+ {Object.entries(consumables.consumables).map( + ([id, { compatibility, consumable, purchase }]) => { + if (!isCompatible(compatibility!)) return null; + + return ( + + ); + }, + )} +
+
+ ); +} + +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 ( +
+ + + + + +
+ ); +} + +function Equipment() { + const strings = useStrings(); + const tank = useProtagonist(); + const equipment = useEquipment(tank.tank!); + + return ( +
+
+ {strings.tanks.loadout.equipment} + + { + Tankopedia.mutate((draft) => { + for (const key in draft.protagonist.equipment) { + delete draft.protagonist.equipment[key]; + } + }); + }} + > + + +
+ +
+ {chunk(equipment.preset.slots, equipmentColumns).map((chunk, index) => ( + + ))} +
+
+ ); +} + +interface EquipmentRowProps { + chunk: BlitzStaticEquipmentPresetComponent_EquipmentSlot[]; + rowIndex: number; +} + +function EquipmentRow({ chunk, rowIndex }: EquipmentRowProps) { + return ( +
+ {chunk.map((slot, index) => ( + + ))} +
+ ); +} + +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 ( +
+
+ {slot.options_catalog_i_ds.map((id, index) => ( + + ))} +
+ + +
+ ); +} + +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 ( + + + + ); +} + +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 ( +
+
+ {strings.tanks.loadout.modules} + +
+ { + 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" + > + + + + { + 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" + > + + +
+
+ +
+ {/* TODO: hide lines with 1 module except for alts */} + {tank.tank!.upgrade_lines.map((line) => ( + + ))} +
+
+ ); +} + +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 ( +
+ {!isAlternativeLine(name) && + combinedLines.map((name) => ( +
+ +
+ ))} +
+ ); +} + +function LineInner({ name, lines }: LineProps) { + const line = lines.find((line) => line.name === name)!; + + return chunk(line.stages, maxModulesPerRow).map((chunk, wrapIndex) => ( +
+ {wrapIndex > 0 && ( + + + + )} + + {chunk.map((stage, elementIndex) => ( + + ))} +
+ )); +} + +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 ( +
+ + + + + {} +
+ ); +} diff --git a/packages/website/src/types/characteristics.ts b/packages/website/src/types/characteristics.ts index 07c176d34..7433c3715 100644 --- a/packages/website/src/types/characteristics.ts +++ b/packages/website/src/types/characteristics.ts @@ -23,7 +23,7 @@ interface CharacteristicContext { assault_distance: number; characteristic( - name: keyof typeof characteristics, + name: CharacteristicName, ): ReturnType; } @@ -31,3 +31,11 @@ export enum CharacteristicType { Enum, Number, } + +type CharacteristicName = keyof typeof characteristics; +type CharacteristicReturnType = ReturnType; + +export type ComputedCharacteristics = Record< + CharacteristicName, + CharacteristicReturnType +>;