Compare commits

...
3 Commits
Author SHA1 Message Date
tresabhi d9b22431fa refine layout 2026-05-28 14:13:19 -05:00
tresabhi 0a186399b6 some more tankoepdia layout 2026-05-28 14:03:27 -05:00
tresabhi d2b4cc3e7d consumable prices 2026-05-28 13:14:38 -05:00
13 changed files with 289 additions and 95 deletions

No files matched your search

+2 -1
View File
@@ -6,6 +6,7 @@
"uasset",
"oodle",
"blitzkit",
"webp"
"webp",
"hocs"
]
}
+18
View File
@@ -1,4 +1,8 @@
{
"price": {
"free": "Free"
},
"nations": {
"0": "Unspecified",
"1": "USSR",
@@ -198,6 +202,20 @@
},
"tanks": {
"characteristics": {
"titles": {
"meta": "Meta",
"firepower": "Firepower",
"maneuverability": "Maneuverability",
"survivability": "Survivability"
}
},
"navigation": {
"tank": "Tank",
"characteristics": "Characteristics"
},
"loadout": {
"consumables": "Consumables",
"modules": "Modules",
@@ -6,23 +6,35 @@ import { useLocale } from "../../hooks/useLocale";
import { classNames } from "../../ui/classNames";
import { Text } from "../Text";
import styles from "./index.module.css";
import { useStrings } from "../../hooks/useStrings";
interface PriceProps extends ComponentProps<"div"> {
price: StandardPrice;
price?: StandardPrice;
}
export function Price({ price, className, ...props }: PriceProps) {
const strings = useStrings();
const normalized =
price === undefined
? []
: price.price_list.filter(
(entry) =>
entry.currency_price === undefined ||
entry.currency_price.amount !== 0,
);
return (
<div className={classNames(styles.price, className)} {...props}>
{price.price_list.map((entry, index) => (
{normalized.map((entry, index) => (
<PriceEntry key={index} entry={entry} />
))}
{price.price_list.length === 0 && (
<Text className={styles.entry} lowContrast>
Free
</Text>
)}
{price === undefined ||
(normalized.length === 0 && (
<Text className={styles.entry} lowContrast>
{strings.price.free}
</Text>
))}
</div>
);
}
@@ -0,0 +1,48 @@
.characteristics {
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-5);
box-sizing: border-box;
.group {
display: flex;
flex-direction: column;
gap: var(--space-4);
.title-wrapper {
display: flex;
align-items: center;
gap: var(--space-4);
.title {
.title-content {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
svg {
width: 1em;
height: 1em;
}
}
}
.separator {
height: var(--space-1);
flex: 1;
background-color: var(--gray-5);
}
}
.content {
display: flex;
flex-direction: column;
}
}
}
@@ -0,0 +1,58 @@
import { CaretDownIcon, CaretRightIcon } from "@radix-ui/react-icons";
import { useStrings } from "../../hooks/useStrings";
import { Tankopedia } from "../../stores/tankopedia";
import {
characteristicsOrder,
type CharacteristicRenderConfig,
type CharacteristicsGroup,
} from "../../tankopedia/characteristicsOrder";
import { Heading } from "../Heading";
import styles from "./index.module.css";
export function TankopediaCharacteristics() {
return (
<div className={styles.characteristics}>
{characteristicsOrder.map((group) => (
<Group {...group} />
))}
</div>
);
}
function Group({ group, order }: CharacteristicsGroup) {
const isOpen = Tankopedia.use((state) => state.groups[group]);
const strings = useStrings();
return (
<div className={styles.group}>
<div className={styles["title-wrapper"]}>
<Heading className={styles.title} size="3">
<div className={styles["title-content"]}>
{isOpen ? <CaretDownIcon /> : <CaretRightIcon />}
{strings.tanks.characteristics.titles[group]}
</div>
</Heading>
<div className={styles.separator} />
</div>
<div className={styles.content}>
{order.map((item) =>
"toy" in item ? <Toy {...item} /> : <Item {...item} />,
)}
</div>
</div>
);
}
interface ToyProps {
toy: string;
}
function Toy({ toy }: ToyProps) {
return <span>{toy}</span>;
}
function Item({ name }: CharacteristicRenderConfig) {
return <span>{name}</span>;
}
@@ -18,25 +18,32 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
.consumable {
font-size: var(--space-5);
.consumable-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
img {
width: 1em;
height: 1em;
}
.consumable {
font-size: var(--space-5);
.info {
display: flex;
flex-direction: column;
img {
width: 1em;
height: 1em;
}
.entry {
.info {
display: flex;
gap: var(--space-2);
flex-direction: column;
svg {
width: 1em;
height: 1em;
.entry {
display: flex;
gap: var(--space-2);
svg {
width: 1em;
height: 1em;
}
}
}
}
@@ -1,5 +1,7 @@
import { romanize } from "@blitzkit/core";
import type { ConsumableComponent } from "@protos/blitz_static_consumable_component";
import type { BlitzStaticEquipmentPresetComponent_EquipmentSlot } from "@protos/blitz_static_equipment_preset_component";
import type { BlitzStaticPurchaseComponent } from "@protos/blitz_static_purchase_component";
import { Grade } from "@protos/blitz_static_standard_grades_enum";
import { StandardPrice } from "@protos/blitz_static_standard_price";
import type { UpgradeLine } from "@protos/blitz_static_tank_upgrade_line";
@@ -24,25 +26,20 @@ import {
import { useCompatibility } from "../../hooks/useCompatibility";
import { useConsumables } from "../../hooks/useConsumables";
import { useEquipment } from "../../hooks/useEquipment";
import { useGameStrings } from "../../hooks/useGameStrings";
import { useProtagonist } from "../../hooks/useProtagonist";
import { useStrings } from "../../hooks/useStrings";
import { useTierPrices } from "../../hooks/useTierPrices";
import { useUpgradePreset } from "../../hooks/useUpgradePreset";
import { Tankopedia, TankopediaCompare } from "../../stores/tankopedia";
import { Tankopedia } from "../../stores/tankopedia";
import type { ComputedCharacteristics } from "../../tankopedia/computeCharacteristics";
import { Button } from "../Button";
import { Heading } from "../Heading";
import { IconButton } from "../IconButton";
import { Price } from "../Price";
import { Section } from "../Section";
import { Text } from "../Text";
import styles from "./index.module.css";
import type { ComputedCharacteristics } from "../../tankopedia/computeCharacteristics";
import { Tooltip } from "../Tooltip";
import { useGameStrings } from "../../hooks/useGameStrings";
import { TankopediaCollapsible } from "../TankopediaCollapsible";
import type { ConsumableComponent } from "@protos/blitz_static_consumable_component";
import { Tank } from "@protos/tank";
import { select } from "three/src/nodes/math/ConditionalNode.js";
import styles from "./index.module.css";
interface TankopediaLoadoutProps {
characteristics: ComputedCharacteristics;
@@ -95,7 +92,13 @@ function Consumables({ characteristics }: ConsumablesProps) {
{Object.entries(consumables.consumables).map(
([id, { compatibility, consumable, purchase }]) => {
if (!isCompatible(compatibility!)) return null;
return <Consumable id={id} consumable={consumable!} />;
return (
<Consumable
id={id}
consumable={consumable!}
purchase={purchase!}
/>
);
},
)}
</div>
@@ -106,51 +109,72 @@ function Consumables({ characteristics }: ConsumablesProps) {
interface ConsumableProps {
id: string;
consumable: ConsumableComponent;
purchase: BlitzStaticPurchaseComponent;
}
function Consumable({ id, consumable }: ConsumableProps) {
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 (
<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);
<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>
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>
<Text lowContrast size="minor">
<div className={styles.entry}>
<ResetIcon />
10s
</div>
</Text>
</div>
</Button>
</Tooltip>
<Price price={price} />
</div>
);
}
@@ -484,8 +508,8 @@ function LineElement({ index, lineName, stage }: LineElementProps) {
}
return (
<Tooltip tooltip={gameStrings[stage.display_name]}>
<div className={styles.wrapper}>
<div className={styles.wrapper}>
<Tooltip tooltip={gameStrings[stage.display_name]}>
<Button
className={styles.stage}
color={isSelected ? undefined : "gray"}
@@ -569,9 +593,9 @@ function LineElement({ index, lineName, stage }: LineElementProps) {
: romanize(stage.number)}
</Text>
</Button>
</Tooltip>
{<Price className={styles.price} price={price} />}
</div>
</Tooltip>
{<Price className={styles.price} price={price} />}
</div>
);
}
@@ -1,7 +1,12 @@
.sandbox {
.wrapper {
height: calc(100dvh - 16rem);
position: relative;
height: 100%;
.canvas {
.sandbox {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}
@@ -1,14 +1,21 @@
import { Canvas, useLoader } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import styles from "./index.module.css";
import { Canvas, useLoader } from "@react-three/fiber";
import { GLTFLoader } from "three-stdlib";
import styles from "./index.module.css";
export function TankopediaSandbox() {
return (
<div className={styles.sandbox}>
<Canvas shadows className={styles.canvas}>
{!import.meta.env.SSR && <Content />}
</Canvas>
<div className={styles.wrapper}>
<div className={styles.sandbox}>
<Canvas
// scene={{ fog: new Fog("black", 15, 40) }}
shadows
className={styles.canvas}
frameloop="demand"
>
{!import.meta.env.SSR && <Content />}
</Canvas>
</div>
</div>
);
}
@@ -26,8 +33,8 @@ function Content() {
<pointLight castShadow position={[3, 10, 5]} intensity={1000} />
<mesh receiveShadow position={[0, -1, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[30, 30]} />
<meshStandardMaterial color="green" />
<planeGeometry args={[2 ** 8, 2 ** 8]} />
<meshStandardMaterial color="black" />
</mesh>
<OrbitControls />
@@ -5,10 +5,11 @@
.sandbox {
flex-grow: 1;
background-color: black;
overflow-y: auto;
}
.stats {
.loadout {
display: flex;
flex-direction: column;
@@ -16,5 +17,7 @@
padding: var(--space-5);
overflow-y: auto;
background-color: var(--gray-2);
}
}
@@ -1,6 +1,8 @@
import { Fragment, useMemo } from "react";
import { api } from "../../../../api/dynamic";
import { TankopediaCharacteristics } from "../../../../components/TankopediaCharacteristics";
import { TankopediaLoadout } from "../../../../components/TankopediaLoadout";
import { TankopediaSandbox } from "../../../../components/TankopediaSandbox";
import { withErrorWrapper } from "../../../../hocs/withErrorWrapper";
import { withLocale } from "../../../../hocs/withLocale";
import { useAwait } from "../../../../hooks/useAwait";
@@ -10,7 +12,6 @@ import { Tankopedia } from "../../../../stores/tankopedia";
import { characteristicsOrder } from "../../../../tankopedia/characteristicsOrder";
import { computeCharacteristics } from "../../../../tankopedia/computeCharacteristics";
import styles from "./_index.module.css";
import { TankopediaSandbox } from "../../../../components/TankopediaSandbox";
interface PageProps {
id: string;
@@ -22,8 +23,6 @@ export const Page = withErrorWrapper(
Tankopedia.useInitialization(protagonistTank);
// const renderCharacteristic = useCharacteristicRenderer();
const protagonist = Tankopedia.use((state) => state.protagonist);
const protagonistEquipment = useEquipment(protagonistTank.tank!);
@@ -39,12 +38,13 @@ export const Page = withErrorWrapper(
return (
<div className={styles.page}>
<div className={styles.sandbox}>
<TankopediaSandbox />
<div className={styles.loadout}>
<TankopediaLoadout characteristics={characteristics} />
</div>
<div className={styles.stats}>
<TankopediaLoadout characteristics={characteristics} />
<div className={styles.sandbox}>
<TankopediaSandbox />
<TankopediaCharacteristics />
</div>
</div>
);
@@ -2,9 +2,12 @@ import type { Tank } from "@protos/tank";
import { Varuna } from "varuna";
import { createTankState } from "../tankopedia/createTankState";
import { type TankState } from "../tankopedia/tankState";
import { characteristicsOrder } from "../tankopedia/characteristicsOrder";
interface Tankopedia {
compare: TankopediaCompare;
groups: Record<string, boolean>;
protagonist: TankState;
}
@@ -14,7 +17,15 @@ export enum TankopediaCompare {
All,
}
const groups: Record<string, boolean> = {};
for (const { group } of characteristicsOrder) {
groups[group] = true;
}
export const Tankopedia = new Varuna<Tankopedia, Tank>((tank) => ({
compare: TankopediaCompare.TierAndClass,
groups,
protagonist: createTankState(tank),
}));
@@ -29,7 +29,7 @@ export interface CharacteristicRenderConfig {
}) => ReactNode;
}
type CharacteristicsOrder = {
export type CharacteristicsGroup = {
group: string;
order: (
| CharacteristicRenderConfig
@@ -37,9 +37,9 @@ type CharacteristicsOrder = {
toy: string;
}
)[];
}[];
};
export const characteristicsOrder: CharacteristicsOrder = [
export const characteristicsOrder: CharacteristicsGroup[] = [
{
group: "meta",
order: [