mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 02:37:05 +02:00
TankopediaCharacteristics
This commit is contained in:
1 parent
5cae5d1583
commit
74fbd15c5f
9 files changed
+521
-68
No files matched your search
@@ -1,16 +1,45 @@
|
||||
export function literals(
|
||||
string: string,
|
||||
literals?: Record<string, string | number | undefined>
|
||||
literals?: Record<string, string | number | undefined>,
|
||||
) {
|
||||
for (const key in literals) {
|
||||
const mutated = string.replaceAll(`{${key}}`, `${literals[key]}`);
|
||||
|
||||
if (import.meta.env.DEV && mutated === string) {
|
||||
console.warn(`Could not find literal {${key}} in string "${string}"`);
|
||||
}
|
||||
|
||||
string = mutated;
|
||||
string = string.replaceAll(`{${key}}`, `${literals[key]}`);
|
||||
}
|
||||
|
||||
return string;
|
||||
}
|
||||
|
||||
export function literalsArray<Type, Literal = Type | string>(
|
||||
string: string,
|
||||
literals?: Record<string, Literal>,
|
||||
) {
|
||||
if (!literals) return [string];
|
||||
|
||||
let array: (string | Literal)[] = [string];
|
||||
|
||||
for (const key in literals) {
|
||||
const value = literals[key];
|
||||
const next: (string | Literal)[] = [];
|
||||
|
||||
for (const chunk of array) {
|
||||
if (typeof chunk !== "string") {
|
||||
next.push(chunk);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = chunk.split(`{${key}}`);
|
||||
|
||||
for (let index = 0; index < parts.length; index++) {
|
||||
if (parts[index]) next.push(parts[index]);
|
||||
|
||||
if (index < parts.length - 1) {
|
||||
next.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
array = next;
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
{
|
||||
"units": {
|
||||
"m": "{value}m",
|
||||
"mm": "{value}mm",
|
||||
"deg": "{value}°",
|
||||
"hp": "{value}hp",
|
||||
"x": "x{value}",
|
||||
"percentage": "{value}%",
|
||||
"deg_s": "{value}°/s",
|
||||
"plus": "+{value}"
|
||||
},
|
||||
|
||||
"tanks": {
|
||||
"characteristics": {
|
||||
"titles": {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
.characteristics-wrapper {
|
||||
margin: var(--space-4);
|
||||
|
||||
--base-cell-width: 20rem;
|
||||
--group-span: 7;
|
||||
|
||||
container-type: inline-size;
|
||||
|
||||
.characteristics {
|
||||
--N: round(down, 100cqw / var(--base-cell-width));
|
||||
--cell-width: calc(100cqw / var(--N));
|
||||
--cell-height: calc(var(--cell-width) / var(--group-span));
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(var(--base-cell-width), 1fr)
|
||||
);
|
||||
grid-auto-rows: var(--cell-height);
|
||||
grid-auto-flow: dense;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
.group {
|
||||
--gradient-size: min(100%, var(--space-8));
|
||||
|
||||
grid-column: span 1;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: inherit;
|
||||
grid-auto-rows: inherit;
|
||||
|
||||
.title {
|
||||
grid-row: span 1;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 calc(var(--space-4));
|
||||
|
||||
.center {
|
||||
flex: 1;
|
||||
padding: var(--space-3);
|
||||
|
||||
.text {
|
||||
.align {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
img {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 0 var(--space-4);
|
||||
|
||||
gap: inherit;
|
||||
height: 100%;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: inherit;
|
||||
grid-auto-rows: inherit;
|
||||
|
||||
.item {
|
||||
padding: 0 var(--space-3);
|
||||
|
||||
grid-row: span 1;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.units {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.ranking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
.bar-wrapper {
|
||||
flex: 1;
|
||||
height: var(--space-1);
|
||||
|
||||
background-color: var(--gray-3);
|
||||
border-radius: var(--radius-max);
|
||||
overflow: hidden;
|
||||
|
||||
.bar {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toy {
|
||||
grid-row: span var(--group-span);
|
||||
padding: var(--space-4) 0;
|
||||
|
||||
.sandbox {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
background-color: var(--gray-2);
|
||||
outline: var(--gray-5) solid var(--space-1);
|
||||
border-radius: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
.dead {
|
||||
grid-row: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dead {
|
||||
grid-row: span 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { characteristicsGroups } from "../../config/characteristicsGroups";
|
||||
import { useCharacteristicRenderer } from "../../hooks/useCharacteristicRenderer";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import type {
|
||||
CharacteristicRenderConfig,
|
||||
CharacteristicReturnType,
|
||||
CharacteristicsGroup,
|
||||
ComputedCharacteristics,
|
||||
} from "../../types/characteristics";
|
||||
import { Heading } from "../Heading";
|
||||
import { Text } from "../Text";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
interface TankopediaCharacteristicsProps {
|
||||
characteristics: ComputedCharacteristics;
|
||||
}
|
||||
|
||||
export function TankopediaCharacteristics({
|
||||
characteristics,
|
||||
}: TankopediaCharacteristicsProps) {
|
||||
return (
|
||||
<div className={styles["characteristics-wrapper"]}>
|
||||
<div className={styles.characteristics}>
|
||||
{characteristicsGroups.map((group) => {
|
||||
const items = group.order.filter(
|
||||
(item) => "name" in item && characteristics[item.name] !== null,
|
||||
).length;
|
||||
const shouldRender = items > 0;
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<Group
|
||||
key={group.name}
|
||||
group={group}
|
||||
characteristics={characteristics}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupProps {
|
||||
group: CharacteristicsGroup;
|
||||
characteristics: ComputedCharacteristics;
|
||||
}
|
||||
|
||||
function Group({ group, characteristics }: GroupProps) {
|
||||
const strings = useStrings();
|
||||
const toys = group.order.filter((item) => "toy" in item).length;
|
||||
const items = group.order.filter(
|
||||
(item) => "name" in item && characteristics[item.name] !== null,
|
||||
).length;
|
||||
const groupSpan = `calc(${toys} * var(--group-span) + ${items} + 2)`;
|
||||
const contentSpan = `calc(${toys} * var(--group-span) + ${items})`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.group}
|
||||
style={{
|
||||
gridRow: `span ${groupSpan}`,
|
||||
gridTemplateRows: `repeat(${groupSpan}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
<div className={styles.title}>
|
||||
<div className={styles.center}>
|
||||
<Heading size="3" className={styles.text}>
|
||||
<div className={styles.align}>
|
||||
{
|
||||
(
|
||||
strings.tanks.characteristics.titles as Record<string, string>
|
||||
)[group.name]
|
||||
}
|
||||
<img src={`/media/groups/${group.name}.png`} />
|
||||
</div>
|
||||
</Heading>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.content}
|
||||
style={{
|
||||
gridRow: `span ${contentSpan}`,
|
||||
}}
|
||||
>
|
||||
{group.order.map((item) => (
|
||||
<>
|
||||
{"toy" in item && <Toy {...item} />}
|
||||
{"name" in item && characteristics[item.name] !== null && (
|
||||
<Item config={item} value={characteristics[item.name]!} />
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
|
||||
<div className={styles.dead} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
config: CharacteristicRenderConfig;
|
||||
value: CharacteristicReturnType;
|
||||
}
|
||||
|
||||
function Item({ config, value }: ItemProps) {
|
||||
const strings = useStrings();
|
||||
const renderCharacteristic = useCharacteristicRenderer();
|
||||
const hasUnits = config.units !== undefined;
|
||||
|
||||
return (
|
||||
<span className={styles.item}>
|
||||
<div className={styles.value}>
|
||||
<Text lowContrast>
|
||||
{
|
||||
(strings.tanks.characteristics.items as Record<string, string>)[
|
||||
config.name
|
||||
]
|
||||
}
|
||||
</Text>
|
||||
|
||||
<Text lowContrast={hasUnits} size={hasUnits ? "minor" : undefined}>
|
||||
<div className={styles.units}>
|
||||
{renderCharacteristic(value, config)}
|
||||
</div>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.ranking}>
|
||||
<Bar value={0.5} />
|
||||
|
||||
<Text lowContrast size="minor" className={styles.number}>
|
||||
1 / 132
|
||||
</Text>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface BarProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
const barColors = ["red", "orange", "amber", "jade"];
|
||||
|
||||
function Bar({ value }: BarProps) {
|
||||
const index = (barColors.length - 1) * value;
|
||||
const iA = Math.floor(index);
|
||||
const iB = Math.ceil(index);
|
||||
const A = barColors[iA];
|
||||
const B = barColors[iB];
|
||||
const x = index % 1;
|
||||
const p = (1 - x) * 100;
|
||||
const v = 7;
|
||||
|
||||
return (
|
||||
<div className={styles["bar-wrapper"]}>
|
||||
<div
|
||||
className={styles.bar}
|
||||
style={{
|
||||
backgroundColor: `color-mix(in srgb, var(--${A}-${v}) ${p}%, var(--${B}-${v}))`,
|
||||
width: `${value * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToyProps {
|
||||
toy: string;
|
||||
}
|
||||
|
||||
function Toy({ toy }: ToyProps) {
|
||||
return (
|
||||
<div className={styles.toy}>
|
||||
<div className={styles.sandbox}>
|
||||
<Text lowContrast>{toy} toy</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { CharacteristicsGroup } from "../types/characteristics";
|
||||
|
||||
export const characteristicsGroups: CharacteristicsGroup[] = [
|
||||
{
|
||||
name: "firepower",
|
||||
order: [
|
||||
{
|
||||
name: "gun_type",
|
||||
},
|
||||
{
|
||||
name: "damage",
|
||||
},
|
||||
{
|
||||
name: "clip_size",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -613,56 +613,3 @@ export function tankCharacteristics(
|
||||
burstInterShell,
|
||||
};
|
||||
}
|
||||
|
||||
export enum TankCharacteristicsCondition {
|
||||
Regular,
|
||||
AutoLoader,
|
||||
AutoReloader,
|
||||
}
|
||||
|
||||
export const characteristicsOrder: {
|
||||
name: string;
|
||||
items: TankCharacteristicsKey[];
|
||||
}[] = [
|
||||
{
|
||||
name: "Firepower",
|
||||
items: [
|
||||
"gunType",
|
||||
"dpm",
|
||||
"shells",
|
||||
"shellReload",
|
||||
"shellReloads",
|
||||
"intraClip",
|
||||
"penetration",
|
||||
"penetrationAt250m",
|
||||
"damage",
|
||||
"clipDamage",
|
||||
"moduleDamage",
|
||||
"caliber",
|
||||
"shellNormalization",
|
||||
"shellRicochet",
|
||||
"shellVelocity",
|
||||
"shellRange",
|
||||
"shellCapacity",
|
||||
"aimTime",
|
||||
"dispersion",
|
||||
"dispersionMoving",
|
||||
"dispersionHullTraversing",
|
||||
"dispersionTurretTraversing",
|
||||
"dispersionShooting",
|
||||
"dispersionGunDamaged",
|
||||
"gunDepression",
|
||||
"gunElevation",
|
||||
"gunFrontalDepression",
|
||||
"gunFrontalElevation",
|
||||
"gunRearDepression",
|
||||
"gunRearElevation",
|
||||
"azimuthLeft",
|
||||
"azimuthRight",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Crew",
|
||||
items: ["crewCount"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,75 @@
|
||||
import { literalsArray } from "@blitzkit/i18n/src/literals";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text } from "../components/Text";
|
||||
import { characteristicsGroups } from "../config/characteristicsGroups";
|
||||
import type {
|
||||
CharacteristicRenderConfig,
|
||||
CharacteristicReturnType,
|
||||
} from "../types/characteristics";
|
||||
import { useLocale } from "./useLocale";
|
||||
import { useStrings } from "./useStrings";
|
||||
|
||||
export function useCharacteristicRenderer() {
|
||||
const groups = useMemo(() => {
|
||||
const groups: string[] = [];
|
||||
|
||||
for (const group of characteristicsGroups) {
|
||||
for (const item of group.order) {
|
||||
if ("strings" in item && item.strings) {
|
||||
groups.push(item.strings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, []);
|
||||
|
||||
const locale = useLocale();
|
||||
const strings = useStrings();
|
||||
|
||||
function renderCharacteristic(
|
||||
characteristic: CharacteristicReturnType,
|
||||
config: CharacteristicRenderConfig,
|
||||
): ReactNode {
|
||||
if (characteristic === null) return null;
|
||||
|
||||
if (config.render)
|
||||
return config.render({ output: characteristic, strings });
|
||||
|
||||
if (typeof characteristic === "number") {
|
||||
if (Number.isFinite(characteristic)) {
|
||||
let value: ReactNode = characteristic;
|
||||
|
||||
if (config.decimals !== undefined) {
|
||||
value *= 10 ** config.decimals;
|
||||
value = Math.round(value);
|
||||
value /= 10 ** config.decimals;
|
||||
}
|
||||
|
||||
if (config.localize) {
|
||||
value = value.toLocaleString(locale);
|
||||
} else {
|
||||
value = value.toFixed(config.decimals);
|
||||
}
|
||||
|
||||
if (config.units !== undefined) {
|
||||
value = literalsArray(strings.units[config.units], {
|
||||
value: <Text>{value}</Text>,
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${characteristic < 0 ? "-" : ""}∞`;
|
||||
}
|
||||
|
||||
if (typeof characteristic === "string") {
|
||||
return characteristic;
|
||||
}
|
||||
|
||||
throw new Error("Unknown characteristic type");
|
||||
}
|
||||
|
||||
return renderCharacteristic;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { api } from "../../../../../api/dynamic";
|
||||
import type { ThicknessRange } from "../../../../../components/StaticArmor";
|
||||
import { TankopediaCharacteristics } from "../../../../../components/TankopediaCharacteristics";
|
||||
import { TankopediaLoadout } from "../../../../../components/TankopediaLoadout";
|
||||
import { TankopediaSandbox } from "../../../../../components/TankopediaSandbox";
|
||||
import { defaultEqualizer } from "../../../../../config/equalizer";
|
||||
import { withErrorWrapper } from "../../../../../hocs/withErrorWrapper";
|
||||
import { withLocale } from "../../../../../hocs/withLocale";
|
||||
import { useAwait } from "../../../../../hooks/useAwait";
|
||||
@@ -13,6 +16,8 @@ interface PageProps {
|
||||
id: number;
|
||||
}
|
||||
|
||||
const tanks = await api.tanks();
|
||||
|
||||
export const Page = withErrorWrapper(
|
||||
withLocale<PageProps>(({ id }) => {
|
||||
const protagonistTank = useAwait(() => api.tank(id), `tank-${id}`);
|
||||
@@ -27,6 +32,27 @@ export const Page = withErrorWrapper(
|
||||
[protagonist],
|
||||
);
|
||||
|
||||
const equalize = Tankopedia.use((state) => state.equalize);
|
||||
const thicknessRange = useMemo(() => {
|
||||
const entries = Object.values(tanks.tanks);
|
||||
const filtered = entries.filter(
|
||||
(thisTank) => thisTank.tier === protagonistTank.tier,
|
||||
);
|
||||
const value =
|
||||
(filtered.reduce((accumulator, thisTank) => {
|
||||
return (
|
||||
accumulator +
|
||||
thisTank.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration!.near *
|
||||
((equalize ? thisTank.equalizer : undefined) ?? defaultEqualizer)
|
||||
.penetration
|
||||
);
|
||||
}, 0) /
|
||||
filtered.length) *
|
||||
(3 / 4);
|
||||
|
||||
return { value } satisfies ThicknessRange;
|
||||
}, [protagonist, equalize]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.loadout}>
|
||||
@@ -34,8 +60,10 @@ export const Page = withErrorWrapper(
|
||||
</div>
|
||||
|
||||
<div className={styles.sandbox}>
|
||||
<TankopediaSandbox parameters={parameters} />
|
||||
<TankopediaCharacteristics characteristics={characteristics} />
|
||||
<TankopediaSandbox thicknessRange={thicknessRange} />
|
||||
<TankopediaCharacteristics
|
||||
computedCharacteristics={characteristics}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Strings } from "@blitzkit/i18n";
|
||||
import type {
|
||||
Equalizer,
|
||||
GunDefinition,
|
||||
ShellDefinition,
|
||||
} from "@blitzkit/protos";
|
||||
import type { ReactNode } from "react";
|
||||
import { characteristics } from "../config/characteristics";
|
||||
|
||||
export type Characteristic = {
|
||||
@@ -36,10 +38,32 @@ export enum CharacteristicType {
|
||||
Number,
|
||||
}
|
||||
|
||||
type CharacteristicName = keyof typeof characteristics;
|
||||
type CharacteristicReturnType = ReturnType<Characteristic["compute"]>;
|
||||
export type CharacteristicName = keyof typeof characteristics;
|
||||
export type CharacteristicReturnType = ReturnType<Characteristic["compute"]>;
|
||||
|
||||
export type ComputedCharacteristics = Record<
|
||||
CharacteristicName,
|
||||
CharacteristicReturnType
|
||||
export type ComputedCharacteristics = Partial<
|
||||
Record<CharacteristicName, CharacteristicReturnType>
|
||||
>;
|
||||
|
||||
export interface CharacteristicRenderConfig {
|
||||
name: CharacteristicName;
|
||||
|
||||
decimals?: number;
|
||||
units?: keyof Strings["units"];
|
||||
localize?: boolean;
|
||||
strings?: string;
|
||||
|
||||
render?: (data: {
|
||||
output: CharacteristicReturnType;
|
||||
strings: Strings;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
export interface ToyRenderConfig {
|
||||
toy: string;
|
||||
}
|
||||
|
||||
export type CharacteristicsGroup = {
|
||||
name: string;
|
||||
order: (CharacteristicRenderConfig | ToyRenderConfig)[];
|
||||
};
|
||||
Reference in new issue
Block a user