Compare commits

...
6 Commits
Author SHA1 Message Date
tresabhi de80519f06 ricochet toy 2026-10-11 16:49:54 -05:00
tresabhi f935adb70e start of port 2026-10-11 16:27:53 -05:00
tresabhi 3902c63a65 rename 2026-10-11 16:02:08 -05:00
tresabhi e7b81f39b8 shell selector 2026-10-11 16:01:02 -05:00
tresabhi 9692110733 invert logic 2026-10-11 15:47:02 -05:00
tresabhi ae2839cb83 ranks 2026-10-11 15:44:42 -05:00
26 changed files with 876 additions and 301 deletions

No files matched your search

+20 -1
View File
@@ -15,11 +15,29 @@
"bhp_t": "{value}bhp/t",
"kN": "{value}kN",
"hp_s": "{value}hp/s",
"plus": "+{value}"
"plus": "+{value}",
"fraction": "{numerator} / {denominator}"
},
"tanks": {
"characteristics": {
"compare": {
"label": "Compare against",
"class": "Tier {tier} {class}",
"tier": "All tier {tier}",
"all": "All tanks"
},
"toys": {
"ricochet": {
"angle": "Angle",
"normalization": "Normalization",
"effective": "Effective",
"armor": "Armor",
"no_ricochet": "Explosive shells don't ricochet"
}
},
"titles": {
"gun": "Gun",
"clip": "Clip",
@@ -114,6 +132,7 @@
"provisions": "Provisions",
"modules": "Modules",
"equipment": "Equipment",
"shells": "Shells",
"alternative": "Alt"
},
@@ -0,0 +1,54 @@
.options {
position: relative;
display: grid;
border-radius: var(--radius-max);
background-color: var(--gray-3);
.indicator {
position: absolute;
top: 0;
height: 100%;
background-color: var(--accent-3);
outline: var(--outline-focus);
border-radius: var(--radius-max);
transition: var(--duration-brief);
}
.option {
position: relative;
display: flex;
justify-content: center;
padding: var(--space-3) var(--space-4);
color: var(--gray-11);
cursor: pointer;
input {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
.label {
white-space: nowrap;
}
&:has(input:checked) {
color: var(--gray-12);
}
&:has(input:focus-visible) {
outline: 2px solid var(--accent-8);
outline-offset: 2px;
border-radius: var(--radius-max);
}
}
}
@@ -0,0 +1,51 @@
import { type ReactNode } from "react";
import styles from "./index.module.css";
export interface SegmentedControlOption<Value extends string | number> {
value: Value;
label: ReactNode;
}
export interface SegmentedControlProps<Value extends string | number> {
options: readonly SegmentedControlOption<Value>[];
value: Value;
onValueChange(value: Value): void;
}
export function SegmentedControl<Value extends string | number>({
options,
value,
onValueChange,
}: SegmentedControlProps<Value>) {
const selectedIndex = options.findIndex((option) => option.value === value);
const segmentWidth = 100 / options.length;
return (
<div
className={styles.options}
role="radiogroup"
style={{ gridTemplateColumns: `repeat(${options.length}, 1fr)` }}
>
<div
className={styles.indicator}
style={{
left: `${Math.max(selectedIndex, 0) * segmentWidth}%`,
width: `${segmentWidth}%`,
}}
aria-hidden="true"
/>
{options.map((option) => (
<label className={styles.option} key={option.value}>
<input
type="radio"
value={option.value}
checked={option.value === value}
onChange={() => onValueChange(option.value)}
/>
<span className={styles.label}>{option.label}</span>
</label>
))}
</div>
);
}
@@ -1,60 +0,0 @@
import { TIER_ROMAN_NUMERALS } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n/src/literals";
import { Flex, SegmentedControl, Text, type FlexProps } from "@radix-ui/themes";
import { useLocale } from "../../../../../../hooks/useLocale";
import { Duel } from "../../../../../../stores/duel";
import {
Tankopedia,
TankopediaRelativeAgainst,
} from "../../../../../../stores/tankopedia";
export function CompareOptions(props: FlexProps) {
const relativeAgainst = Tankopedia.use((state) => state.relativeAgainst);
const tank = Duel.use((state) => state.protagonist.tank);
const { strings } = useLocale();
return (
<Flex
align="center"
gap="2"
justify="center"
direction={{ initial: "column", sm: "row" }}
{...props}
>
<Text color="gray">
{strings.website.tools.tankopedia.characteristics.compare.label}
</Text>
<SegmentedControl.Root
variant="classic"
size="1"
value={`${relativeAgainst}`}
onValueChange={(value) => {
Tankopedia.mutate((draft) => {
draft.relativeAgainst = Number(value) as TankopediaRelativeAgainst;
});
}}
>
<SegmentedControl.Item value={`${TankopediaRelativeAgainst.Class}`}>
{literals(
strings.website.tools.tankopedia.characteristics.compare.class,
{
tier: TIER_ROMAN_NUMERALS[tank.tier],
class:
strings.common.tank_class_short_plural_lowercase[tank.class],
}
)}
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaRelativeAgainst.Tier}`}>
{literals(
strings.website.tools.tankopedia.characteristics.compare.tier,
{ tier: TIER_ROMAN_NUMERALS[tank.tier] }
)}
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaRelativeAgainst.All}`}>
{strings.website.tools.tankopedia.characteristics.compare.all}
</SegmentedControl.Item>
</SegmentedControl.Root>
</Flex>
);
}
@@ -296,7 +296,7 @@ export function GunFlexibilityVisualizer({
height="100%"
style={{
opacity: 2 ** -3,
backgroundImage: `url(/assets/images/tankopedia/visualizers/ricochet/armor-hash.png)`,
backgroundImage: `url(/media/random/armor-hash.png)`,
boxShadow: "inset 0 0 4rem 1rem black",
}}
/>
@@ -1,185 +0,0 @@
import { createDefaultSkills } from "@blitzkit/core";
import { Flex, Progress, Text } from "@radix-ui/themes";
import { clamp } from "lodash-es";
import { memo, useMemo, type ComponentProps, type ReactNode } from "react";
import { api } from "../../../../../../api/dynamic";
import {
tankCharacteristics,
type TankCharacteristics,
} from "../../../../../../core/blitzkit/tankCharacteristics";
import { tankToDuelMember } from "../../../../../../core/blitzkit/tankToDuelMember";
import { useDelta } from "../../../../../../hooks/useDelta";
import { useLocale } from "../../../../../../hooks/useLocale";
import { Duel } from "../../../../../../stores/duel";
import {
Tankopedia,
TankopediaRelativeAgainst,
} from "../../../../../../stores/tankopedia";
import { Info, type InfoProps } from "./Info";
type InfoWithDeltaProps = Omit<InfoProps, "name"> & {
stats: TankCharacteristics;
noRanking?: boolean;
} & (
| {
value: keyof TankCharacteristics;
}
| {
value: (tank: TankCharacteristics) => number | undefined;
name: ReactNode;
}
);
const [
tankDefinitions,
provisionDefinitions,
equipmentDefinitions,
modelDefinitions,
skillDefinitions,
] = await Promise.all([
api.tanks(),
api.provisions(),
api.equipment(),
api.models(),
api.skills(),
]);
export const InfoWithDelta = memo<InfoWithDeltaProps>(
({ stats, indent, noRanking, deltaType, ...props }) => {
const { strings } = useLocale();
const relativeAgainst = Tankopedia.use((state) => state.relativeAgainst);
const uhWhatDoICallThisVariable =
typeof props.value === "function"
? props.value(stats)!
: (stats[props.value] as number);
const delta = useDelta(uhWhatDoICallThisVariable);
const protagonistTank = Duel.use((state) => state.protagonist.tank);
const shellIndex = Duel.use((state) =>
state.protagonist.gun.shells.indexOf(state.protagonist.shell),
);
const equipmentMatrix = Duel.use(
(state) => state.protagonist.equipmentMatrix,
);
const equalize = Duel.use((state) => state.equalize);
const others = useMemo(() => {
const defaultSkills = createDefaultSkills(skillDefinitions);
return Object.values(tankDefinitions.tanks)
.filter(
(tank) =>
(relativeAgainst === TankopediaRelativeAgainst.Class &&
tank.tier === protagonistTank.tier &&
tank.class === protagonistTank.class) ||
(relativeAgainst === TankopediaRelativeAgainst.Tier &&
tank.tier === protagonistTank.tier) ||
relativeAgainst === TankopediaRelativeAgainst.All,
)
.map((tank) => {
const member = tankToDuelMember(tank, provisionDefinitions);
if (member.gun.shells[shellIndex] === undefined) {
return undefined;
}
return tankCharacteristics(
{
applyDynamicArmor: false,
applyReactiveArmor: false,
applySpallLiner: false,
camouflage: member.camouflage,
consumables: member.consumables,
crewSkills: defaultSkills,
provisions: member.provisions,
engine: member.engine,
gun: member.gun,
equipmentMatrix: equipmentMatrix,
shell: member.gun.shells[shellIndex],
stockEngine: tank.engines[0],
stockGun: tank.turrets[0].guns[0],
stockTrack: tank.tracks[0],
stockTurret: tank.turrets[0],
tank,
track: member.track,
turret: member.turret,
assaultDistance: member.assaultDistance,
equalize,
},
{
equipmentDefinitions,
provisionDefinitions,
tankModelDefinition: modelDefinitions.models[tank.id],
},
);
})
.filter((tank) => {
if (tank === undefined) return false;
const othersValue =
typeof props.value === "function"
? props.value(tank)!
: (tank[props.value] as number);
return othersValue !== undefined;
}) as TankCharacteristics[];
}, [relativeAgainst, shellIndex, equipmentMatrix, equalize]);
const betterTanks = others.filter((tank) => {
const othersValue =
typeof props.value === "function"
? props.value(tank)!
: (tank[props.value] as number);
if (othersValue === undefined) return false;
return deltaType === "lowerIsBetter"
? othersValue < uhWhatDoICallThisVariable
: othersValue > uhWhatDoICallThisVariable;
});
const goodness = (others.length - betterTanks.length) / others.length;
let color: ComponentProps<typeof Progress>["color"];
if (goodness <= 0.25) color = "red";
else if (goodness <= 0.5) color = "orange";
else if (goodness <= 0.75) color = "yellow";
else color = "green";
return (
<Flex direction="column">
<Info
deltaType={deltaType}
indent={indent}
name={
"name" in props
? props.name
: strings.website.tools.tankopedia.characteristics.values[
props.value
]
}
delta={delta}
{...props}
>
{uhWhatDoICallThisVariable}
</Info>
{!noRanking && (
<Flex pl={indent ? "2" : "0"} align="center" gap="2">
<Progress
variant="soft"
size="1"
value={goodness * 100}
color={color}
style={{ height: "0.125rem", opacity: 0.5 }}
/>
<Text color="gray" size="1">
{clamp(betterTanks.length + 1, 1, others.length)} /{" "}
{others.length}
</Text>
</Flex>
)}
</Flex>
);
},
(a, b) =>
(typeof a.value === "function" ? a.value(a.stats) : a.stats[a.value]) ===
(typeof b.value === "function" ? b.value(b.stats) : b.stats[b.value]),
);
@@ -60,7 +60,7 @@ export function RicochetVisualizer({ stats }: StatsAcceptorProps) {
height="100%"
style={{
opacity: 2 ** -3,
backgroundImage: `url(/assets/images/tankopedia/visualizers/ricochet/armor-hash.png)`,
backgroundImage: `url(/media/random/armor-hash.png)`,
}}
/>
</Box>
@@ -1,16 +1,27 @@
.characteristics-wrapper {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin: var(--space-4);
--base-cell-width: 20rem;
--group-span: 7;
container-type: inline-size;
.compare-options {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-4);
}
.characteristics {
--N: round(down, 100cqw / var(--base-cell-width));
--cell-width: calc(100cqw / var(--N));
--cell-height: calc(var(--cell-width) / var(--group-span));
margin-bottom: calc(-1 * var(--cell-height));
display: grid;
grid-template-columns: repeat(
auto-fill,
@@ -124,10 +135,6 @@
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);
@@ -1,13 +1,24 @@
import { TIER_ROMAN_NUMERALS } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n/src/literals";
import { useRef } from "react";
import { characteristicsGroups } from "../../config/characteristicsGroups";
import { toys } from "../../config/toys";
import { useCharacteristicRenderer } from "../../hooks/useCharacteristicRenderer";
import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useRankings } from "../../hooks/useRankings";
import { useStrings } from "../../hooks/useStrings";
import { Tankopedia, TankopediaRelativeAgainst } from "../../stores/tankopedia";
import type { CharacteristicRank } from "../../tankopedia/calculateRankings";
import type {
CharacteristicName,
CharacteristicRenderConfig,
CharacteristicReturnType,
CharacteristicsGroup,
ComputedCharacteristics,
ToyName,
} from "../../types/characteristics";
import { Heading } from "../Heading";
import { SegmentedControl } from "../SegmentedControl";
import { Text } from "../Text";
import styles from "./index.module.css";
@@ -18,8 +29,12 @@ interface TankopediaCharacteristicsProps {
export function TankopediaCharacteristics({
characteristics,
}: TankopediaCharacteristicsProps) {
const rankings = useRankings(characteristics);
return (
<div className={styles["characteristics-wrapper"]}>
<CompareOptions />
<div className={styles.characteristics}>
{characteristicsGroups.map((group) => {
const items = group.order.filter(
@@ -34,6 +49,7 @@ export function TankopediaCharacteristics({
key={group.name}
group={group}
characteristics={characteristics}
rankings={rankings}
/>
);
})}
@@ -45,9 +61,10 @@ export function TankopediaCharacteristics({
interface GroupProps {
group: CharacteristicsGroup;
characteristics: ComputedCharacteristics;
rankings: Map<CharacteristicName, CharacteristicRank>;
}
function Group({ group, characteristics }: GroupProps) {
function Group({ group, characteristics, rankings }: GroupProps) {
const strings = useStrings();
const toys = group.order.filter((item) => "toy" in item).length;
const characteristicsCount = group.order.filter(
@@ -92,10 +109,16 @@ function Group({ group, characteristics }: GroupProps) {
>
{group.order.map((item) => (
<>
{"toy" in item && <Toy {...item} />}
{"toy" in item && (
<ToyAssigner characteristics={characteristics} {...item} />
)}
{"label" in item && <Label {...item} />}
{"name" in item && characteristics.has(item.name) && (
<Item config={item} value={characteristics.get(item.name)!} />
<Item
config={item}
value={characteristics.get(item.name)!}
ranking={rankings.get(item.name)}
/>
)}
</>
))}
@@ -125,9 +148,10 @@ function Label({ label }: LabelProps) {
interface ItemProps {
config: CharacteristicRenderConfig;
value: CharacteristicReturnType;
ranking?: CharacteristicRank;
}
function Item({ config, value }: ItemProps) {
function Item({ config, value, ranking }: ItemProps) {
const strings = useStrings();
const renderCharacteristic = useCharacteristicRenderer();
const hasUnits = config.units !== undefined;
@@ -150,17 +174,64 @@ function Item({ config, value }: ItemProps) {
</Text>
</div>
<div className={styles.ranking}>
<Bar value={Math.random()} />
{ranking && (
<div className={styles.ranking}>
<Bar value={ranking.score} />
<Text lowContrast size="minor" className={styles.number}>
1 / 132
</Text>
</div>
<Text lowContrast size="minor" className={styles.number}>
{literals(strings.units.fraction, {
numerator: ranking.rank,
denominator: ranking.total,
})}
</Text>
</div>
)}
</span>
);
}
function CompareOptions() {
const tank = useDuel("protagonist", DuelModule.Tank);
const relativeAgainst = Tankopedia.use((state) => state.relative_against);
const strings = useStrings();
const options = useRef([
{
value: TankopediaRelativeAgainst.ClassAndTier,
label: literals(strings.tanks.characteristics.compare.class, {
tier: TIER_ROMAN_NUMERALS[tank.tier],
class: strings.common.tank_class_short_plural_lowercase[tank.class],
}),
},
{
value: TankopediaRelativeAgainst.Tier,
label: literals(strings.tanks.characteristics.compare.tier, {
tier: TIER_ROMAN_NUMERALS[tank.tier],
}),
},
{
value: TankopediaRelativeAgainst.All,
label: strings.tanks.characteristics.compare.all,
},
]);
return (
<div className={styles["compare-options"]}>
<Text lowContrast>{strings.tanks.characteristics.compare.label}</Text>
<SegmentedControl
options={options.current}
value={relativeAgainst}
onValueChange={(value) => {
Tankopedia.mutate((draft) => {
draft.relative_against = value;
});
}}
/>
</div>
);
}
interface BarProps {
value: number;
}
@@ -190,15 +261,22 @@ function Bar({ value }: BarProps) {
);
}
interface ToyProps {
toy: string;
interface ToyAssignerProps {
toy: ToyName;
characteristics: ComputedCharacteristics;
}
function Toy({ toy }: ToyProps) {
function ToyAssigner({ toy, characteristics }: ToyAssignerProps) {
const Toy = toys[toy];
return (
<div className={styles.toy}>
<div className={styles.sandbox}>
<Text lowContrast>{toy} toy</Text>
{Toy ? (
<Toy characteristics={characteristics} />
) : (
<Text lowContrast>{toy}</Text>
)}
</div>
</div>
);
@@ -15,7 +15,7 @@ const [gameDefinitions, consumableDefinitions, provisionDefinitions, tanks] =
api.tanks(),
]);
export function GameModeSection() {
export function TankopediaGameModeAbilities() {
const unwrap = useUnwrapper();
const strings = useStrings();
const tank = useDuel("protagonist", DuelModule.Tank);
@@ -22,6 +22,10 @@
gap: var(--space-2);
}
.shells {
display: flex;
}
.status {
display: flex;
flex-direction: column;
@@ -11,7 +11,6 @@ import { useStrings } from "../../hooks/useStrings";
import { useTankCompatibility } from "../../hooks/useTankCompatibility";
import { useUnwrapper } from "../../hooks/useUnwrapper";
import { Tankopedia } from "../../stores/tankopedia";
import { hasUpgrades } from "../../tankopedia/hasUpgrades";
import { vehicleStatusKeys } from "../../tankopedia/tankState";
import type { ComputedCharacteristics } from "../../types/characteristics";
import { Button } from "../Button";
@@ -31,11 +30,12 @@ interface TankopediaLoadoutProps {
export function TankopediaLoadout({ characteristics }: TankopediaLoadoutProps) {
const tank = useDuel("protagonist", DuelModule.Tank);
const showModules = hasUpgrades(tank);
// const showModules = hasUpgrades(tank);
return (
<>
{/* {showModules && <Modules />} */}
<Shells />
<Equipment />
<Provisions />
<Consumables characteristics={characteristics} />
@@ -105,7 +105,6 @@ function Status() {
key={key}
radius="1"
className={styles.state}
data-selected={status[key]}
color={status[key] ? "amber" : "gray"}
variant={status[key] ? "surface" : "soft"}
onClick={() => {
@@ -125,6 +124,52 @@ function Status() {
);
}
function Shells() {
const strings = useStrings();
const unwrap = useUnwrapper();
const gun = useDuel("protagonist", DuelModule.Gun);
const shell = useDuel("protagonist", DuelModule.Shell);
return (
<div className={styles.section}>
<div className={styles.header}>
<Heading size="4">{strings.tanks.loadout.shells}</Heading>
</div>
<div className={styles.shells}>
{gun.shells.map((candidate) => {
const isSelected = candidate.id === shell.id;
const name = unwrap(candidate.name!);
return (
<Tooltip key={candidate.id} tooltip={name}>
<IconButton
aria-label={name}
size="major"
radius="1"
color={isSelected ? undefined : "gray"}
variant={isSelected ? "surface" : "soft"}
parentListChild
onClick={() => {
Tankopedia.mutate((draft) => {
draft.protagonist.shell = candidate.id;
});
}}
>
<img
alt={name}
src={alias("api", `/icons/shells/${candidate.icon}.webp`)}
/>
</IconButton>
</Tooltip>
);
})}
</div>
</div>
);
}
function Provisions() {
const strings = useStrings();
const tank = useDuel("protagonist", DuelModule.Tank);
@@ -447,7 +492,6 @@ function EquipmentOption({
size="major"
color={isSelected ? undefined : "gray"}
variant={isSelected ? "surface" : "soft"}
data-selected={isSelected}
radius="1"
parentListChild
className={styles.slot}
@@ -0,0 +1,151 @@
.visualizer {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
touch-action: none;
border-radius: inherit;
background-color: var(--gray-2);
}
.armor {
position: absolute;
top: 0;
left: 0;
width: 33%;
height: 100%;
background: linear-gradient(to right, transparent, var(--gray-a4));
}
.armor-texture {
width: 100%;
height: 100%;
opacity: 0.125;
background-image: url("/media/random/armor-hash.png");
}
.zones {
position: absolute;
top: 0;
left: 33%;
width: 67%;
height: 100%;
background: conic-gradient(
at 0 50%,
var(--ruby-a2),
var(--ruby-a1) calc(90deg - var(--ricochet-angle)),
var(--jade-a2) calc(90deg - var(--ricochet-angle)),
var(--jade-a4) calc(90deg - var(--normalization-angle)),
var(--cyan-a5) calc(90deg - var(--normalization-angle)),
var(--cyan-a3) calc(90deg + var(--normalization-angle)),
var(--jade-a2) calc(90deg + var(--normalization-angle)),
var(--jade-a4) calc(90deg + var(--ricochet-angle)),
var(--ruby-a1) calc(90deg + var(--ricochet-angle)),
var(--ruby-a2) 180deg
);
}
.shell-path,
.effective-path {
position: absolute;
top: 50%;
left: 33%;
transform: translateX(-50%) rotate(var(--rotation-angle));
transform-origin: top center;
}
.shell-path {
width: 0;
height: 100%;
}
.shell-path-extension {
position: absolute;
left: 50%;
bottom: 100%;
width: var(--space-0);
height: 100%;
transform: translateX(-50%);
background-color: var(--gray-a8);
}
.shell-path-line {
position: absolute;
top: 0;
left: 50%;
width: var(--space-2);
height: 100%;
transform: translateX(-50%);
background: linear-gradient(var(--gray-a11), var(--gray-a4));
}
.shell {
position: absolute;
top: 6rem;
left: 0;
width: var(--space-5);
height: var(--space-5);
object-fit: contain;
transform: translate(-50%, -50%) rotate(-45deg);
filter: drop-shadow(0 0 var(--space-4) black);
}
.effective-path {
width: 0;
height: 100%;
}
.effective-path-line {
position: absolute;
left: 50%;
top: -100%;
width: var(--space-2);
height: 100%;
transform: translateX(-50%);
background: linear-gradient(var(--gray-a4), var(--gray-a11));
}
.corner-stat {
position: absolute;
display: flex;
gap: var(--space-2);
color: var(--gray-11);
font-size: var(--font-size-minor);
}
.corner-stat-value {
color: var(--gray-12);
font-size: inherit;
}
.corner-stat[data-position="top-left"] {
top: var(--space-3);
left: var(--space-3);
}
.corner-stat[data-position="top-right"] {
top: var(--space-3);
right: var(--space-3);
}
.corner-stat[data-position="bottom-left"] {
bottom: var(--space-3);
left: var(--space-3);
}
.corner-stat[data-position="bottom-right"] {
right: var(--space-3);
bottom: var(--space-3);
}
.explosive-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-3);
color: var(--gray-11);
text-align: center;
background: radial-gradient(var(--black-a7), var(--black-a11));
}
@@ -0,0 +1,171 @@
import { alias, isExplosive } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import {
useRef,
useState,
type CSSProperties,
type PointerEvent,
} from "react";
import { clamp, degToRad, radToDeg } from "three/src/math/MathUtils.js";
import type { ToyProps } from "../../config/toys";
import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useStrings } from "../../hooks/useStrings";
import { Code } from "../Code";
import styles from "./index.module.css";
export function RicochetToy({ characteristics }: ToyProps) {
const strings = useStrings();
const shell = useDuel("protagonist", DuelModule.Shell);
const container = useRef<HTMLDivElement>(null);
const normalizationValue = characteristics.get("normalization");
const ricochetValue = characteristics.get("ricochet");
const normalization = degToRad(
typeof normalizationValue === "number" ? normalizationValue : 0,
);
const ricochet = degToRad(
typeof ricochetValue === "number" ? ricochetValue : 90,
);
const explodes = isExplosive(shell.type);
const [rawAngle, setAngle] = useState(degToRad(-25));
const angle = explodes ? degToRad(-70) : rawAngle;
const doesRicochet = Math.abs(angle) >= ricochet;
const effectiveAngle = doesRicochet
? -angle + Math.PI
: Math.sign(angle) * Math.max(0, Math.abs(angle) - normalization);
const armorPercentage = Math.min(999, 100 / Math.cos(effectiveAngle));
function updateAngle(event: PointerEvent<HTMLDivElement>) {
event.preventDefault();
if (!container.current || explodes) return;
const rect = container.current.getBoundingClientRect();
const x = event.clientX - rect.left - rect.width / 3;
const y = event.clientY - rect.top - rect.height / 2;
setAngle(clamp(Math.atan2(-y, x), -Math.PI / 2, Math.PI / 2));
}
return (
<div
ref={container}
className={styles.visualizer}
onPointerMove={updateAngle}
>
<div className={styles.armor}>
<div className={styles["armor-texture"]} />
</div>
<div
className={styles.zones}
style={
{
"--normalization-angle": `${normalization}rad`,
"--ricochet-angle": `${ricochet}rad`,
} as CSSProperties
}
/>
<div
className={styles["shell-path"]}
style={
{
"--rotation-angle": `${-Math.PI / 2 - angle}rad`,
} as CSSProperties
}
>
<div className={styles["shell-path-extension"]} />
<div className={styles["shell-path-line"]} />
<img
className={styles.shell}
alt=""
src={alias("api", `/icons/shells/${shell.icon}.webp`)}
/>
</div>
<div
className={styles["effective-path"]}
style={
{
"--rotation-angle": `${-Math.PI / 2 - effectiveAngle}rad`,
} as CSSProperties
}
>
<div className={styles["effective-path-line"]} />
</div>
<CornerStat
label={strings.tanks.characteristics.toys.ricochet.angle}
value={literals(strings.common.units.deg, {
value: radToDeg(Math.abs(angle)).toFixed(0),
})}
position={CornerStatPosition.TopLeft}
/>
<CornerStat
label={strings.tanks.characteristics.toys.ricochet.normalization}
value={
doesRicochet
? "-"
: literals(strings.common.units.deg, {
value: radToDeg(
Math.min(normalization, Math.abs(angle)),
).toFixed(0),
})
}
position={CornerStatPosition.TopRight}
/>
<CornerStat
label={strings.tanks.characteristics.toys.ricochet.effective}
value={
doesRicochet
? "-"
: literals(strings.common.units.deg, {
value: radToDeg(Math.abs(effectiveAngle)).toFixed(0),
})
}
position={CornerStatPosition.BottomLeft}
/>
<CornerStat
label={strings.tanks.characteristics.toys.ricochet.armor}
value={
doesRicochet
? "-"
: literals(strings.common.units.percentage, {
value: armorPercentage.toFixed(0),
})
}
position={CornerStatPosition.BottomRight}
/>
{explodes && (
<div className={styles["explosive-overlay"]}>
{strings.tanks.characteristics.toys.ricochet.no_ricochet}
</div>
)}
</div>
);
}
enum CornerStatPosition {
TopLeft = "top-left",
TopRight = "top-right",
BottomLeft = "bottom-left",
BottomRight = "bottom-right",
}
interface CornerStatProps {
label: string;
value: string;
position: CornerStatPosition;
}
function CornerStat({ label, value, position }: CornerStatProps) {
return (
<div className={styles["corner-stat"]} data-position={position}>
<span>{label}</span>
<Code className={styles["corner-stat-value"]} size="minor">
{value}
</Code>
</div>
);
}
@@ -661,13 +661,6 @@ export const characteristics = {
},
},
hull_traverse_speed: {
type: CharacteristicType.Number,
compute({ track, progressive }) {
return track.traverse_speed * progressive("driver");
},
},
hull_traverse_speed_hard: {
type: CharacteristicType.Number,
compute(context): number {
@@ -1,5 +1,6 @@
import type { CaseType } from "@blitzkit/core";
import type { GunDefinition } from "@blitzkit/protos";
import { CharacteristicRanking } from "../types/characteristics";
import type { CharacteristicsGroup } from "../types/characteristics";
export const characteristicsGroups: CharacteristicsGroup[] = [
@@ -80,7 +81,12 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
{ name: "damage_range", decimals: 0, units: "hp", localize: true },
{ name: "module_damage", decimals: 0, units: "hp", localize: true },
{ name: "module_damage_range", decimals: 0, units: "hp", localize: true },
{ name: "reload", decimals: 1, units: "s" },
{
name: "reload",
decimals: 1,
units: "s",
ranking: CharacteristicRanking.LowerIsBetter,
},
{ name: "reloads", decimals: 1, units: "s" },
{ toy: "reload" },
@@ -94,7 +100,12 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
{ name: "clipping_potential", localize: true, units: "hp" },
{ name: "clip_size" },
{ name: "optimal_shell" },
{ name: "intra_clip", decimals: 2, units: "s" },
{
name: "intra_clip",
decimals: 2,
units: "s",
ranking: CharacteristicRanking.LowerIsBetter,
},
{ name: "burst_size" },
{ name: "intra_burst", decimals: 2, units: "s" },
@@ -114,18 +125,28 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
name: "penetration_loss_by_distance",
units: "percentage",
indent: true,
ranking: CharacteristicRanking.LowerIsBetter,
},
{
name: "penetration_loss_after_ricochet",
units: "percentage",
indent: true,
ranking: CharacteristicRanking.LowerIsBetter,
},
{ name: "shell_velocity", units: "m_s" },
{ name: "shell_range", units: "m" },
{ name: "shell_capacity" },
{ name: "caliber", units: "mm" },
{ name: "ricochet", units: "deg" },
{ name: "normalization", units: "deg" },
{
name: "ricochet",
units: "deg",
ranking: CharacteristicRanking.None,
},
{
name: "normalization",
units: "deg",
ranking: CharacteristicRanking.None,
},
{ toy: "ricochet" },
],
@@ -135,8 +156,18 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
name: "accuracy",
// render: CharacteristicsGroupRender.Statistical,
order: [
{ name: "aim_time", decimals: 2, units: "s" },
{ name: "dispersion", decimals: 3, units: "m" },
{
name: "aim_time",
decimals: 2,
units: "s",
ranking: CharacteristicRanking.LowerIsBetter,
},
{
name: "dispersion",
decimals: 3,
units: "m",
ranking: CharacteristicRanking.LowerIsBetter,
},
{ name: "dispersion_angle", decimals: 3, units: "deg" },
{ toy: "aim_time" },
],
@@ -167,7 +198,12 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
{ name: "speed_forward", units: "km_h", indent: true },
{ name: "speed_backward", units: "km_h", indent: true },
{ name: "weight", decimals: 1, units: "t" },
{
name: "weight",
decimals: 1,
units: "t",
ranking: CharacteristicRanking.LowerIsBetter,
},
{ name: "engine_power", units: "bhp" },
{ name: "power_to_weight", decimals: 1, units: "bhp_t" },
// { name: "brake_force", decimals: 1, units: "kN" },
@@ -185,9 +221,24 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
{ name: "soft_terrain_coefficient", decimals: 0, indent: true },
{ label: "raw_terrain_coefficient" },
{ name: "hard_terrain_coefficient_raw", decimals: 2, indent: true },
{ name: "medium_terrain_coefficient_raw", decimals: 2, indent: true },
{ name: "soft_terrain_coefficient_raw", decimals: 2, indent: true },
{
name: "hard_terrain_coefficient_raw",
decimals: 2,
indent: true,
ranking: CharacteristicRanking.LowerIsBetter,
},
{
name: "medium_terrain_coefficient_raw",
decimals: 2,
indent: true,
ranking: CharacteristicRanking.LowerIsBetter,
},
{
name: "soft_terrain_coefficient_raw",
decimals: 2,
indent: true,
ranking: CharacteristicRanking.LowerIsBetter,
},
{ toy: "traverse" },
@@ -202,7 +253,11 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
// render: CharacteristicsGroupRender.Statistical,
order: [
{ name: "health", units: "hp" },
{ name: "fire_chance", units: "percentage" },
{
name: "fire_chance",
units: "percentage",
ranking: CharacteristicRanking.LowerIsBetter,
},
// { name: "fire_rate", units: "hp_s" },
// { name: "ramming_resistance", units: "percentage" },
@@ -221,9 +276,21 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
{ toy: "view_range" },
{ name: "width", units: "m" },
{ name: "height", units: "m" },
{ name: "length", units: "m" },
{
name: "width",
units: "m",
ranking: CharacteristicRanking.LowerIsBetter,
},
{
name: "height",
units: "m",
ranking: CharacteristicRanking.LowerIsBetter,
},
{
name: "length",
units: "m",
ranking: CharacteristicRanking.LowerIsBetter,
},
],
},
];
+11
View File
@@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import { RicochetToy } from "../components/TankopediaToys";
import type { ComputedCharacteristics } from "../types/characteristics";
export interface ToyProps {
characteristics: ComputedCharacteristics;
}
export const toys: Record<string, (props: ToyProps) => ReactNode> = {
ricochet: RicochetToy,
};
@@ -0,0 +1,74 @@
import { useMemo } from "react";
import { api } from "../api/dynamic";
import { Tankopedia, TankopediaRelativeAgainst } from "../stores/tankopedia";
import { createTankState } from "../tankopedia/createTankState";
import { computeCharacteristics } from "./useCharacteristics";
import { DuelModule, useDuel } from "./useDuel";
const tankDefinitions = await api.tanks();
const candidateStates = new Map(
Object.values(tankDefinitions.tanks).map((candidate) => {
const state = createTankState(candidate);
const turret = candidate.turrets.find((t) => t.id === state.turret)!;
const gun = turret.guns.find((g) => g.id === state.gun)!;
return [
candidate.id,
{
state,
shells: gun.shells.map((shell) => shell.id),
},
];
}),
);
export function useComparisonCharacteristics() {
const environment = Tankopedia.use((state) => state.environment);
const tank = useDuel("protagonist", DuelModule.Tank);
const gun = useDuel("protagonist", DuelModule.Gun);
const shell = useDuel("protagonist", DuelModule.Shell);
const protagonistShellIndex = gun.shells.findIndex((s) => s.id === shell.id);
const relativeAgainst = Tankopedia.use((state) => state.relative_against);
const candidates = useMemo(() => {
return Object.values(tankDefinitions.tanks).filter((candidate) => {
switch (relativeAgainst) {
case TankopediaRelativeAgainst.ClassAndTier:
return candidate.tier === tank.tier && candidate.class === tank.class;
case TankopediaRelativeAgainst.Tier:
return candidate.tier === tank.tier;
case TankopediaRelativeAgainst.All:
return true;
}
});
}, [tank, relativeAgainst]);
const characteristics = useMemo(() => {
return candidates.flatMap((candidate) => {
const { state: candidateState, shells } = candidateStates.get(
candidate.id,
)!;
const maxStateShellIndex = shells.length - 1;
const stateShellIndex = Math.min(
maxStateShellIndex,
protagonistShellIndex,
);
const state = { ...candidateState, shell: shells[stateShellIndex] };
return [
computeCharacteristics(
{ protagonist: state, antagonist: state },
environment,
),
];
});
}, [candidates, environment, protagonistShellIndex]);
return characteristics;
}
+21
View File
@@ -0,0 +1,21 @@
import { useMemo } from "react";
import {
calculateRankings,
type CharacteristicRank,
} from "../tankopedia/calculateRankings";
import type {
CharacteristicName,
ComputedCharacteristics,
} from "../types/characteristics";
import { useComparisonCharacteristics } from "./useComparisonCharacteristics";
export function useRankings(
characteristics: ComputedCharacteristics,
): Map<CharacteristicName, CharacteristicRank> {
const comparisonCharacteristics = useComparisonCharacteristics();
return useMemo(
() => calculateRankings(characteristics, comparisonCharacteristics),
[characteristics, comparisonCharacteristics],
);
}
@@ -2,8 +2,8 @@ import { useMemo } from "react";
import { api } from "../../../../../api/dynamic";
import type { ThicknessRange } from "../../../../../components/StaticArmor";
import { TankopediaCharacteristics } from "../../../../../components/TankopediaCharacteristics";
import { TankopediaGameModeAbilities } from "../../../../../components/TankopediaGameModeAbilities";
import { TankopediaLoadout } from "../../../../../components/TankopediaLoadout";
import { GameModeSection } from "../../../../../components/GameModeSection";
import { defaultEqualizer } from "../../../../../config/equalizer";
import { withErrorWrapper } from "../../../../../hocs/withErrorWrapper";
import { withLocale } from "../../../../../hocs/withLocale";
@@ -59,7 +59,7 @@ export const Page = withErrorWrapper(
<div className={styles.sandbox}>
{/* {!skeleton && <TankopediaSandbox thicknessRange={thicknessRange} />} */}
<TankopediaCharacteristics characteristics={characteristics} />
<GameModeSection />
<TankopediaGameModeAbilities />
</div>
</div>
);
@@ -21,11 +21,13 @@ const { id, locale } = Astro.props;
<BaseLayout {locale} accent="violet">
<ColorScale color="amber" alpha />
<ColorScale color="red" />
<ColorScale color="ruby" alpha />
<ColorScale color="orange" />
<ColorScale color="amber" />
<ColorScale color="jade" />
<ColorScale color="jade" alpha />
<ColorScale color="cyan" alpha />
<Page {id} {locale} client:only="react">
<Page slot="fallback" {id} {locale} skeleton />
+5 -3
View File
@@ -71,7 +71,7 @@ export type ArmorPiercingLayer =
};
export enum TankopediaRelativeAgainst {
Class,
ClassAndTier,
Tier,
All,
}
@@ -90,9 +90,10 @@ interface Tankopedia {
antagonist: TankState;
environment: TankEnvironment;
relative_against: TankopediaRelativeAgainst;
modelRequested: boolean;
shot?: Shot;
relativeAgainst: TankopediaRelativeAgainst;
editStatic: boolean;
highlightArmor?: {
editingPlate: boolean;
@@ -133,9 +134,10 @@ export const Tankopedia = new Soapstone<Tankopedia, [TankDefinition]>(
skills: createDefaultSkills(skills),
},
relative_against: TankopediaRelativeAgainst.ClassAndTier,
modelRequested: false,
relativeAgainst: TankopediaRelativeAgainst.Class,
editStatic: false,
xpMultiplier: 1,
requestedDisplay: TankopediaDisplay.Model,
@@ -0,0 +1,61 @@
import { characteristicsGroups } from "../config/characteristicsGroups";
import {
CharacteristicRanking as CharacteristicRankingDirection,
type CharacteristicName,
type CharacteristicRenderConfig,
type ComputedCharacteristics,
} from "../types/characteristics";
const characteristicConfigs = new Map<
CharacteristicName,
CharacteristicRenderConfig
>();
for (const { order } of characteristicsGroups) {
for (const item of order) {
if (!("name" in item)) continue;
characteristicConfigs.set(item.name, item);
}
}
export interface CharacteristicRank {
rank: number;
total: number;
score: number;
}
export function calculateRankings(
characteristics: ComputedCharacteristics,
comparisons: ComputedCharacteristics[],
) {
const rankings = new Map<CharacteristicName, CharacteristicRank>();
for (const [name, value] of characteristics) {
if (typeof value !== "number" || name[0] === "_") continue;
const config = characteristicConfigs.get(name)!;
if (config.ranking === CharacteristicRankingDirection.None) continue;
const lowerIsBetter =
config.ranking === CharacteristicRankingDirection.LowerIsBetter;
const values = comparisons
.map((comparison) => comparison.get(name))
.filter((other) => typeof other === "number");
if (values.length === 0) continue;
const betterCount = values.filter((other) =>
lowerIsBetter ? other < value : other > value,
).length;
rankings.set(name, {
rank: Math.min(betterCount + 1, values.length),
total: values.length,
score: (values.length - betterCount) / values.length,
});
}
return rankings;
}
+12 -2
View File
@@ -10,7 +10,8 @@ import type {
TurretDefinition,
} from "@blitzkit/protos";
import type { ReactNode } from "react";
import { characteristics } from "../config/characteristics";
import type { characteristics } from "../config/characteristics";
import type { toys } from "../config/toys";
import type { DuelSide } from "../hooks/useEquipment";
import type { TankEnvironment } from "../stores/tankopedia";
import type { TankState } from "../tankopedia/tankState";
@@ -64,6 +65,12 @@ export enum CharacteristicType {
Number,
}
export enum CharacteristicRanking {
HigherIsBetter,
LowerIsBetter,
None,
}
export type CharacteristicName = keyof typeof characteristics;
export type CharacteristicReturnType = ReturnType<Characteristic["compute"]>;
@@ -79,6 +86,7 @@ export interface CharacteristicRenderConfig {
units?: keyof Strings["units"];
localize?: boolean;
indent?: boolean;
ranking?: CharacteristicRanking;
strings?: string;
render?: (data: {
@@ -92,9 +100,11 @@ export interface ToyRenderConfig {
}
export interface LabelRenderConfig {
label: string;
label: ToyName;
}
export type ToyName = keyof typeof toys;
export type CharacteristicsGroup = {
name: string;
order: (CharacteristicRenderConfig | LabelRenderConfig | ToyRenderConfig)[];