diff --git a/packages/i18n/src/literals.ts b/packages/i18n/src/literals.ts index e2efe5056..861ff02a5 100644 --- a/packages/i18n/src/literals.ts +++ b/packages/i18n/src/literals.ts @@ -1,16 +1,45 @@ export function literals( string: string, - literals?: Record + literals?: Record, ) { 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( + string: string, + literals?: Record, +) { + 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; +} diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json index 7f5173202..5985a405a 100644 --- a/packages/i18n/strings/en.json +++ b/packages/i18n/strings/en.json @@ -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": { diff --git a/packages/website/src/components/TankopediaCharacteristics/index.module.css b/packages/website/src/components/TankopediaCharacteristics/index.module.css new file mode 100644 index 000000000..a1b25fe43 --- /dev/null +++ b/packages/website/src/components/TankopediaCharacteristics/index.module.css @@ -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; + } + } + } +} diff --git a/packages/website/src/components/TankopediaCharacteristics/index.tsx b/packages/website/src/components/TankopediaCharacteristics/index.tsx new file mode 100644 index 000000000..a502865fb --- /dev/null +++ b/packages/website/src/components/TankopediaCharacteristics/index.tsx @@ -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 ( +
+
+ {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 ( + + ); + })} +
+
+ ); +} + +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 ( +
+
+
+ +
+ { + ( + strings.tanks.characteristics.titles as Record + )[group.name] + } + +
+
+
+
+ +
+ {group.order.map((item) => ( + <> + {"toy" in item && } + {"name" in item && characteristics[item.name] !== null && ( + + )} + + ))} + +
+
+
+ ); +} + +interface ItemProps { + config: CharacteristicRenderConfig; + value: CharacteristicReturnType; +} + +function Item({ config, value }: ItemProps) { + const strings = useStrings(); + const renderCharacteristic = useCharacteristicRenderer(); + const hasUnits = config.units !== undefined; + + return ( + +
+ + { + (strings.tanks.characteristics.items as Record)[ + config.name + ] + } + + + +
+ {renderCharacteristic(value, config)} +
+
+
+ +
+ + + + 1 / 132 + +
+
+ ); +} + +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 ( +
+
+
+ ); +} + +interface ToyProps { + toy: string; +} + +function Toy({ toy }: ToyProps) { + return ( +
+
+ {toy} toy +
+
+ ); +} diff --git a/packages/website/src/config/characteristicsGroups.ts b/packages/website/src/config/characteristicsGroups.ts new file mode 100644 index 000000000..e0ec09157 --- /dev/null +++ b/packages/website/src/config/characteristicsGroups.ts @@ -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", + }, + ], + }, +]; diff --git a/packages/website/src/core/blitzkit/tankCharacteristics.ts b/packages/website/src/core/blitzkit/tankCharacteristics.ts index dc33cb807..645bea104 100644 --- a/packages/website/src/core/blitzkit/tankCharacteristics.ts +++ b/packages/website/src/core/blitzkit/tankCharacteristics.ts @@ -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"], - }, -]; diff --git a/packages/website/src/hooks/useCharacteristicRenderer.tsx b/packages/website/src/hooks/useCharacteristicRenderer.tsx new file mode 100644 index 000000000..d2fac9380 --- /dev/null +++ b/packages/website/src/hooks/useCharacteristicRenderer.tsx @@ -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: {value}, + }); + } + + return value; + } + + return `${characteristic < 0 ? "-" : ""}∞`; + } + + if (typeof characteristic === "string") { + return characteristic; + } + + throw new Error("Unknown characteristic type"); + } + + return renderCharacteristic; +} diff --git a/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/_index.tsx b/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/_index.tsx index 304828a33..ca338d736 100644 --- a/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/_index.tsx +++ b/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/_index.tsx @@ -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(({ 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 (
@@ -34,8 +60,10 @@ export const Page = withErrorWrapper(
- - + +
); diff --git a/packages/website/src/types/characteristics.ts b/packages/website/src/types/characteristics.ts index 752c3afd9..c82c432ba 100644 --- a/packages/website/src/types/characteristics.ts +++ b/packages/website/src/types/characteristics.ts @@ -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; +export type CharacteristicName = keyof typeof characteristics; +export type CharacteristicReturnType = ReturnType; -export type ComputedCharacteristics = Record< - CharacteristicName, - CharacteristicReturnType +export type ComputedCharacteristics = Partial< + Record >; + +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)[]; +};