shot card

This commit is contained in:
tresabhi committed 2026-11-09 22:46:59 -06:00
1 parent 7ff386cecc
commit e6158dec8d
17 files changed
+373 -1158

No files matched your search

+25
View File
@@ -50,6 +50,31 @@
"reset": "Reset"
},
"shot_card": {
"element": {
"primary": "Primary",
"spaced": "Spaced",
"external": "External",
"gap": "Gap",
"gun": "Gun",
"track": "Tracks"
},
"status": {
"penetration": "{damage}hp penetration",
"blocked": "Blocked",
"ricochet": "Ricochet",
"splash": "{damage}hp splash"
},
"stats": {
"thickness_and_angle": "{thickness}mm {angle}°",
"nominal": "{thickness}mm nominal",
"ricochet_loss": "{loss}% penetration",
"ricochet_divider": "Ricochet (-25% penetration)"
}
},
"tabs": {
"model": "Model",
"dynamic_armor": "Dynamic armor",
@@ -15,7 +15,7 @@ import { LocaleProvider, useLocale } from "../hooks/useLocale";
import { useStrings } from "../hooks/useStrings";
import { App } from "../stores/app";
import { Tankopedia } from "../stores/tankopedia";
import { layerTypeNames } from "./ShotDisplayCard";
import { layerTypeNames } from "./ShotCard";
import { ArmorType } from "./SpacedArmorScene";
export function ArmorPlateDisplay() {
@@ -102,11 +102,11 @@ export function ArmorPlateDisplay() {
Tankopedia.mutate((draft) => {
draft.highlightArmor = undefined;
const { protagonist } = Duel.state;
const tank = draft.model;
const turret = tank.turrets[protagonist.turret.id];
const gun = turret.guns[protagonist.gun.id];
const track = tank.tracks[protagonist.track.id];
const { protagonist } = draft;
const tank = protagonist.model;
const turret = tank.turrets[protagonist.turret];
const gun = turret.guns[protagonist.gun];
const track = tank.tracks[protagonist.track];
if (highlightArmor.name.startsWith("hull_")) {
tank.armor!.thickness[index] = thickness;
@@ -155,18 +155,17 @@ export function ArmorPlateDisplay() {
draft.highlightArmor = undefined;
const initialState = Tankopedia.initial;
const { protagonist } = Duel.state;
const tank = draft.model;
const initialTank = initialState.model;
const turret = tank.turrets[protagonist.turret.id];
const { protagonist } = draft;
const tank = protagonist.model;
const initialTank = initialState.protagonist.model;
const turret = tank.turrets[protagonist.turret];
const initialTurret =
initialTank.turrets[protagonist.turret.id];
const gun = turret.guns[protagonist.gun.id];
const initialGun =
initialTurret.guns[protagonist.gun.id];
const track = tank.tracks[protagonist.track.id];
initialTank.turrets[protagonist.turret];
const gun = turret.guns[protagonist.gun];
const initialGun = initialTurret.guns[protagonist.gun];
const track = tank.tracks[protagonist.track];
const initialTrack =
initialTank.tracks[protagonist.track.id];
initialTank.tracks[protagonist.track];
if (highlightArmor.name.startsWith("hull_")) {
tank.armor!.thickness[index] =
@@ -0,0 +1,85 @@
.cardWrapper {
pointer-events: none;
/* keeps the card clear of the tracer, on the side it isn't heading toward */
&[data-going-up="true"] {
transform: translateY(50%);
margin-top: 4rem;
padding-top: 1rem;
}
&[data-going-up="false"] {
transform: translateY(-50%);
margin-bottom: 4rem;
padding-bottom: 1rem;
}
}
.point {
opacity: 0.75;
border-radius: 50%;
}
.card {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-4);
border-radius: var(--space-3);
border: 1px solid var(--gray-a4);
background-color: var(--gray-2);
white-space: nowrap;
}
.section {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
.layers {
display: table;
/* column-gap doesn't apply to tables; border-spacing also pads the outer
edges, so pull them back in */
border-spacing: var(--space-3) var(--space-2);
margin: 0 calc(-1 * var(--space-3));
}
.row {
display: table-row;
}
.cell {
display: table-cell;
&[data-align="right"] {
text-align: right;
}
}
.divider {
display: flex;
align-items: center;
gap: var(--space-3);
/* bleed to the card edges */
margin: 0 calc(-1 * var(--space-4));
&::before,
&::after {
content: "";
flex: 1;
height: 1px;
background-color: var(--gray-a4);
}
}
@@ -1,5 +1,5 @@
import { J_HAT, K_HAT } from "@blitzkit/core";
import { Box, Flex, Text } from "@radix-ui/themes";
import { literals } from "@blitzkit/i18n/src/literals";
import { Html } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { clamp } from "lodash-es";
@@ -16,37 +16,236 @@ import {
Path,
Quaternion,
} from "three";
import { isHalloween } from "../core/blitzkit/isHalloween";
import { LocaleProvider, useLocale } from "../hooks/useLocale";
import { radToDeg } from "three/src/math/MathUtils.js";
import { isHalloween } from "../../core/blitzkit/isHalloween";
import { LocaleProvider, useLocale } from "../../hooks/useLocale";
import { useStrings } from "../../hooks/useStrings";
import {
Tankopedia,
type Shot,
type ShotLayer,
type ShotLayerNonExternal,
type ShotStatus,
} from "../stores/tankopedia";
import { ShotDisplayCard } from "./ShotDisplayCard";
} from "../../stores/tankopedia";
import { classNames } from "../../ui/classNames";
import { ArmorType } from "../SpacedArmorScene";
import { Text, type TextProps } from "../Text";
import styles from "./index.module.css";
export const shotStatusColors: Record<
ShotStatus,
ComponentProps<typeof Text>["color"]
> = {
export const shotStatusColors: Record<ShotStatus, TextProps["color"]> = {
blocked: "red",
penetration: "green",
ricochet: "yellow",
splash: "orange",
};
export const layerTypeNames = {
[ArmorType.Primary]: "primary",
[ArmorType.Spaced]: "spaced",
[ArmorType.External]: "external",
null: "gap",
} as const;
interface LayerEntryProps {
layerIndex?: string;
color: TextProps["color"];
layerName: string;
layer: ShotLayer;
angle?: number;
}
function LayerEntry({
layerIndex,
color,
layerName,
layer,
angle,
}: LayerEntryProps) {
const locale = useLocale();
const strings = useStrings();
const { stats } = strings.tanks.shot_card;
const format = (value: number) => Math.round(value).toLocaleString(locale);
const mm = (value: number) =>
literals(strings.common.units.mm, { value: format(value) });
return (
<div className={styles.row}>
<div className={styles.cell}>
<Text size="minor" lowContrast>
{layerIndex}
</Text>
</div>
<div className={styles.cell}>
<Text color={color}>{layerName}</Text>
</div>
<div className={styles.cell}>
<Text color={color}>
{layer.type === null && mm(layer.distance * 1000)}
{layer.type === ArmorType.External && mm(layer.thickness)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) &&
(angle === undefined
? mm(layer.thicknessAngled)
: literals(stats.thickness_and_angle, {
thickness: format(layer.thicknessAngled),
angle: radToDeg(angle).toFixed(0),
}))}
</Text>
</div>
<div className={styles.cell} data-align="right">
{layer.type === null && (
<Text lowContrast>
{literals(stats.ricochet_loss, {
loss: Math.max(-100, -50 * layer.distance).toFixed(0),
})}
</Text>
)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
<Text lowContrast>
{literals(stats.nominal, { thickness: format(layer.thickness) })}
</Text>
)}
</div>
</div>
);
}
interface LayersProps {
layers: ShotLayer[];
status: Shot["in"]["status"];
containsGaps: boolean;
/** Offset applied to displayed layer numbers (for post-ricochet layers). */
offset?: number;
}
function Layers({ layers, status, containsGaps, offset = 0 }: LayersProps) {
const strings = useStrings();
return (
<div className={styles.layers}>
{layers.map((layer, index) => {
const shiftedIndex = index + offset;
return (
<LayerEntry
key={index}
layer={layer}
layerName={
strings.tanks.shot_card.element[
layer.type === ArmorType.External
? layer.variant
: layerTypeNames[`${layer.type}`]
]
}
layerIndex={
layer.type === null
? undefined
: `${(containsGaps ? shiftedIndex / 2 : shiftedIndex) + 1}.`
}
color={
status === "splash"
? shotStatusColors.splash
: shotStatusColors[layer.status]
}
angle={
layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced
? layer.angle
: undefined
}
/>
);
})}
</div>
);
}
interface ShotCardHtmlProps extends ComponentProps<"div"> {
shot: Shot;
}
function ShotCardHtml({ shot, className, ...props }: ShotCardHtmlProps) {
const locale = useLocale();
const strings = useStrings();
if (!shot) return null;
const { status, stats } = strings.tanks.shot_card;
const damage = Math.round(shot.damage).toLocaleString(locale);
const hasOutLayers = shot.out !== undefined && shot.out.layers.length > 0;
const penetrationChance = shot.penetrationChance !== undefined && (
<Text lowContrast weight="bold">
{literals(strings.common.units.percentage, {
value: (shot.penetrationChance * 100).toFixed(0),
})}
</Text>
);
return (
<div className={classNames(styles.card, className)} {...props}>
<div className={styles.section}>
<div className={styles.header}>
<Text color={shotStatusColors[shot.in.status]} weight="bold">
{literals(status[shot.in.status], { damage })}
</Text>
{!hasOutLayers && penetrationChance}
</div>
<Layers
layers={shot.in.layers}
status={shot.in.status}
containsGaps={shot.containsGaps}
/>
</div>
{shot.out && hasOutLayers && (
<>
<div className={styles.divider}>
<Text size="minor" lowContrast>
{stats.ricochet_divider}
</Text>
</div>
<div className={styles.section}>
<div className={styles.header}>
<Text color={shotStatusColors[shot.out.status]} weight="bold">
{literals(status[shot.out.status], { damage })}
</Text>
{penetrationChance}
</div>
<Layers
layers={shot.out.layers}
status={shot.out.status}
containsGaps={shot.containsGaps}
offset={shot.in.layers.length}
/>
</div>
</>
)}
</div>
);
}
const SURFACE_POINT_SIZE = 12;
export const SHOT_DISPLAY_LENGTH_INFINITY = 4;
const SHOT_DISPLAY_LENGTH_INFINITY = 4;
const TRACER_THICK = 1 / 32;
const TRACER_THIN = TRACER_THICK / 2;
export function ShotDisplay() {
export function ShotCard() {
const shot = Tankopedia.use((state) => state.shot);
const inTracer = useRef<Mesh>(null);
const outTracer = useRef<Mesh>(null);
const splashRadiusWrapper = useRef<Group>(null);
const splashRadiusMaterial = useRef<MeshBasicMaterial>(null);
const { locale } = useLocale();
const locale = useLocale();
useEffect(() => {
if (shot?.splashRadius !== undefined) {
@@ -140,20 +339,11 @@ export function ShotDisplay() {
)}
<Html position={inLast.point} center>
<Box
style={{
pointerEvents: "none",
transform: `translateY(${tracerGoingUp ? 50 : -50}%)`,
}}
mb={tracerGoingUp ? "0" : "9"}
pb={tracerGoingUp ? "0" : "4"}
mt={tracerGoingUp ? "9" : "0"}
pt={tracerGoingUp ? "4" : "0"}
>
<div className={styles.cardWrapper} data-going-up={tracerGoingUp}>
<LocaleProvider locale={locale}>
<ShotDisplayCard shot={shot} />
<ShotCardHtml shot={shot} />
</LocaleProvider>
</Box>
</div>
</Html>
{[...shot.in.layers, ...(shot.out?.layers ?? [])].map((layer, index) => {
@@ -173,21 +363,15 @@ export function ShotDisplay() {
transform: "translateY(-50%)",
}}
>
<Flex
<div
className={styles.point}
style={{
width: SURFACE_POINT_SIZE,
height: SURFACE_POINT_SIZE,
marginLeft: -SURFACE_POINT_SIZE / 2,
backgroundColor: shotStatusColor,
}}
>
<div
style={{
width: SURFACE_POINT_SIZE,
height: SURFACE_POINT_SIZE,
backgroundColor: shotStatusColor,
opacity: 0.75,
borderRadius: SURFACE_POINT_SIZE / 2,
}}
/>
</Flex>
/>
</Html>
);
})}
@@ -1,274 +0,0 @@
import { literals } from "@blitzkit/i18n/src/literals";
import {
Card,
Flex,
Inset,
Separator,
Text,
type CardProps,
} from "@radix-ui/themes";
import type { ComponentProps } from "react";
import { radToDeg } from "three/src/math/MathUtils.js";
import { useLocale } from "../hooks/useLocale";
import type { Shot, ShotLayer } from "../stores/tankopedia";
import { shotStatusColors } from "./ShotDisplay";
import { ArmorType } from "./SpacedArmorScene";
export const layerTypeNames = {
[ArmorType.Primary]: "primary",
[ArmorType.Spaced]: "spaced",
[ArmorType.External]: "external",
null: "gap",
} as const;
function LayerEntry({
layerIndex,
shotStatusColor,
layerName,
layer,
angle,
}: {
layerIndex?: string;
shotStatusColor: ComponentProps<typeof Text>["color"];
layerName: string;
layer: ShotLayer;
angle?: number;
}) {
const { locale, strings } = useLocale();
return (
<tr>
<td>
<Text size="1" color="gray">
{layerIndex}
</Text>
</td>
<td>
<Text size="2" color={shotStatusColor}>
{layerName}
</Text>
</td>
<td>
{layer.type === null && (
<Text size="2" color={shotStatusColor}>
{literals(strings.common.units.mm, {
value: Math.round(layer.distance * 1000).toLocaleString(locale),
})}
</Text>
)}
{layer.type === ArmorType.External && (
<Text size="2" color={shotStatusColor}>
{literals(strings.common.units.mm, {
value: Math.round(layer.thickness).toLocaleString(locale),
})}
</Text>
)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
<Text size="2" color={shotStatusColor}>
{angle === undefined
? literals(strings.common.units.mm, {
value: Math.round(layer.thicknessAngled).toLocaleString(
locale
),
})
: literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.stats.thickness_and_angle,
{
thickness: Math.round(layer.thicknessAngled).toLocaleString(
locale
),
angle: radToDeg(angle ?? 0).toFixed(0),
}
)}
</Text>
)}
</td>
<td>
{layer.type === null && (
<Text size="2" color="gray">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
.ricochet_loss,
{ loss: Math.max(-100, -50 * layer.distance).toFixed(0) }
)}
</Text>
)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
<Text size="2" color="gray">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
.nominal,
{ thickness: Math.round(layer.thickness).toLocaleString(locale) }
)}
</Text>
)}
</td>
</tr>
);
}
interface ShotDisplayCardProps extends CardProps {
shot: Shot;
}
export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
const { locale, strings } = useLocale();
if (!shot) return null;
const outTitleColor = shot.out
? shotStatusColors[shot.out.status]
: undefined;
const inTitleColor = shotStatusColors[shot.in.status];
const hasOutLayers = shot.out !== undefined && shot.out.layers.length > 0;
const penetrationChance = shot.penetrationChance !== undefined && (
<Text color="gray" weight="bold">
{literals(strings.common.units.percentage, {
value: (shot.penetrationChance * 100).toFixed(0),
})}
</Text>
);
return (
<Card variant="classic" {...props}>
<Flex direction="column" gap="2">
<Flex direction="column" gap="1">
<Flex justify="between" align="center" gap="4">
<Text color={inTitleColor} weight="bold">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.status[shot.in.status],
{ damage: Math.round(shot.damage).toLocaleString(locale) }
)}
</Text>
{!hasOutLayers && penetrationChance}
</Flex>
<table
style={{
whiteSpace: "nowrap",
borderSpacing: "var(--space-1) 0",
}}
>
{shot.in.layers.map((layer, index) => {
const layerName =
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.element[
layer.type === ArmorType.External
? layer.variant
: layerTypeNames[`${layer.type}`]
];
const layerIndex =
layer.type === null
? undefined
: `${(shot.containsGaps ? index / 2 : index) + 1}.`;
const shotStatusColor =
shot.in.status === "splash"
? shotStatusColors.splash
: shotStatusColors[layer.status];
return (
<LayerEntry
key={index}
layer={layer}
layerIndex={layerIndex}
layerName={layerName}
shotStatusColor={shotStatusColor}
angle={
layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced
? layer.angle
: undefined
}
/>
);
})}
</table>
</Flex>
{shot.out && shot.out.layers.length > 0 && (
<>
<Inset side="x">
<Flex align="center" gap="2">
<Separator
style={{
flex: 1,
}}
/>
<Text size="1" color="gray">
ricochet (-25% penetration)
</Text>
<Separator
style={{
flex: 1,
}}
/>
</Flex>
</Inset>
<Flex justify="between" align="center" gap="4">
<Text color={outTitleColor} weight="bold">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.status[shot.out.status],
{ damage: Math.round(shot.damage).toLocaleString(locale) }
)}
</Text>
{penetrationChance}
</Flex>
<Flex direction="column">
{shot.out.layers.map((layer, index) => {
const layerName =
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.element[
layer.type === ArmorType.External
? layer.variant
: layerTypeNames[`${layer.type}`]
];
const shiftedIndex = index + shot.out!.layers.length;
const layerIndex =
layer.type === null
? undefined
: `${
(shot.containsGaps ? shiftedIndex / 2 : shiftedIndex) +
1
}.`;
const shotStatusColor =
shot.out!.status === "splash"
? shotStatusColors.splash
: shotStatusColors[layer.status];
return (
<LayerEntry
key={index}
layer={layer}
layerIndex={layerIndex}
layerName={layerName}
shotStatusColor={shotStatusColor}
angle={
layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced
? layer.angle
: undefined
}
/>
);
})}
</Flex>
</>
)}
</Flex>
</Card>
);
}
@@ -15,6 +15,10 @@ import {
import { degToRad } from "three/src/math/MathUtils.js";
import { defaultEqualizer } from "../config/equalizer";
import { jsxTree } from "../core/blitzkit/jsxTree";
import {
SPALL_LINER_HE_DAMAGE_DELTA,
SPALL_LINER_PROVISION_ID,
} from "../core/blitzkit/spallLiner";
import { discardClippingPlane } from "../core/three/discardClippingPlane";
import { DuelModule, getDuel } from "../hooks/useDuel";
import { hasEquipment } from "../hooks/useEquipment";
@@ -240,8 +244,8 @@ export function SpacedArmorSceneComponent({
shell.type === ShellType.SHELL_TYPE_HE
? "blocked"
: blocked
? "blocked"
: "penetration",
? "blocked"
: "penetration",
variant: layer.variant,
});
} else {
@@ -292,8 +296,8 @@ export function SpacedArmorSceneComponent({
layer.type !== ArmorType.Primary
? "blocked"
: blocked
? "blocked"
: "penetration",
? "blocked"
: "penetration",
angle,
});
}
@@ -415,7 +419,7 @@ export function SpacedArmorSceneComponent({
shot.damage *= antagonistEqualizer.damage;
const hasSpallLiner = Duel.state.protagonist.provisions.includes(
const hasSpallLiner = Tankopedia.state.protagonist.provisions.includes(
SPALL_LINER_PROVISION_ID,
);
if (hasSpallLiner && shell.type === ShellType.SHELL_TYPE_HE) {
@@ -1,24 +0,0 @@
import { type FlexProps, Flex } from "@radix-ui/themes";
import type { ReactNode } from "react";
type ConfigurationChildWrapperProps = FlexProps & {
children: ReactNode;
};
export function ConfigurationChildWrapper({
children,
style,
...props
}: ConfigurationChildWrapperProps) {
return (
<Flex
gap="2"
direction="column"
align={{ initial: "center", sm: "start" }}
style={{ width: "100%", ...style }}
{...props}
>
{children}
</Flex>
);
}
@@ -1,95 +0,0 @@
import { checkConsumableProvisionInclusivity } from "@blitzkit/core/src/blitzkit/checkConsumableProvisionInclusivity";
import { InfoCircledIcon } from "@radix-ui/react-icons";
import {
Button,
Flex,
Heading,
IconButton,
Popover,
Text,
} from "@radix-ui/themes";
import { api } from "../../../../api/dynamic";
import { useEquipment } from "../../../../hooks/useEquipment";
import { useLocale } from "../../../../hooks/useLocale";
import { Duel } from "../../../../stores/duel";
import { ConsumablesManager } from "../../../ConsumablesManager";
import { ConfigurationChildWrapper } from "./ConfigurationChildWrapper";
const consumableDefinitions = await api.consumables();
export function Consumables() {
const protagonist = Duel.use((state) => state.protagonist);
const consumables = Duel.use((state) => state.protagonist.consumables);
const consumablesList = Object.values(
consumableDefinitions.consumables,
).filter((consumable) =>
checkConsumableProvisionInclusivity(
consumable,
protagonist.tank,
protagonist.gun,
),
);
const cooldownBooster = Duel.use(
(state) => state.protagonist.cooldownBooster,
);
const hasConsumableDeliverySystem = useEquipment("protagonist", 118);
const hasHighEndConsumables = useEquipment("protagonist", 101);
const { strings } = useLocale();
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Flex gap="2" align="center">
<Heading size="4">
{strings.website.tools.tankopedia.configuration.consumables.title}
</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Text>
{
strings.website.tools.tankopedia.configuration.consumables
.info
}
</Text>
</Popover.Content>
</Popover.Root>
</Flex>
<Button
variant="ghost"
color="red"
onClick={() => {
Duel.mutate((draft) => {
draft.protagonist.consumables = [];
});
}}
>
{strings.website.tools.tankopedia.configuration.consumables.clear}
</Button>
</Flex>
<ConsumablesManager
consumables={consumablesList}
selected={consumables}
cooldownBooster={cooldownBooster}
disabled={protagonist.tank.max_consumables === consumables.length}
hasConsumableDeliverySystem={hasConsumableDeliverySystem}
hasHighEndConsumables={hasHighEndConsumables}
timers
onConsumablesChange={(consumables) => {
Duel.mutate((draft) => {
draft.protagonist.consumables = consumables;
});
}}
justify={{ initial: "center", sm: "start" }}
/>
</ConfigurationChildWrapper>
);
}
@@ -1,53 +0,0 @@
import { Button, Flex, Heading } from "@radix-ui/themes";
import { api } from "../../../../api/dynamic";
import { useLocale } from "../../../../hooks/useLocale";
import { Duel } from "../../../../stores/duel";
import { EquipmentManager } from "../../../EquipmentManager";
import { ConfigurationChildWrapper } from "./ConfigurationChildWrapper";
const equipmentDefinitions = await api.equipment();
export function Equipment() {
const protagonist = Duel.use((state) => state.protagonist);
const equipmentPreset =
equipmentDefinitions.presets[protagonist.tank.equipment_preset];
const equipmentMatrix = Duel.use(
(state) => state.protagonist.equipmentMatrix,
);
const { strings } = useLocale();
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Heading size="4">
{strings.website.tools.tankopedia.configuration.equipment.title}
</Heading>
<Button
variant="ghost"
color="red"
onClick={() => {
Duel.mutate((draft) => {
draft.protagonist.equipmentMatrix.forEach((row) => {
row.forEach((_, index) => {
row[index] = 0;
});
});
});
}}
>
{strings.website.tools.tankopedia.configuration.equipment.clear}
</Button>
</Flex>
<EquipmentManager
matrix={equipmentMatrix}
preset={equipmentPreset}
onChange={(matrix) => {
Duel.mutate((draft) => {
draft.protagonist.equipmentMatrix = matrix;
});
}}
/>
</ConfigurationChildWrapper>
);
}
@@ -1,65 +0,0 @@
import { alias } from "@blitzkit/core";
import { Flex, Heading } from "@radix-ui/themes";
import { useLocale } from "../../../../hooks/useLocale";
import { Duel } from "../../../../stores/duel";
import { GenericTankComponentButton } from "../../../ModuleButtons/GenericTankComponentButton";
import { ConfigurationChildWrapper } from "./ConfigurationChildWrapper";
export function Miscellaneous() {
const camouflage = Duel.use((state) => state.protagonist.camouflage);
const cooldownBooster = Duel.use(
(state) => state.protagonist.cooldownBooster,
);
const { tank } = Duel.use((state) => state.protagonist);
const { strings } = useLocale();
return (
<ConfigurationChildWrapper>
<Heading size="4">
{strings.website.tools.tankopedia.configuration.miscellaneous.title}
</Heading>
<Flex gap="2" align="center">
{!tank.fixed_camouflage && (
<GenericTankComponentButton
icon={alias("api", "/icons/camo.webp")}
selected={camouflage}
iconStyles={{
top: "50%",
left: "50%",
transform: "translate(calc(-50% + 4px), calc(-50% + 4px))",
}}
onClick={() => {
Duel.mutate((draft) => {
draft.protagonist.camouflage = !camouflage;
});
}}
/>
)}
<GenericTankComponentButton
icon={alias("api", "/icons/boosters/equipment.webp")}
selected={cooldownBooster > 0}
banner={
cooldownBooster === 0
? undefined
: cooldownBooster === 1
? "#afb0abc0"
: cooldownBooster === 2
? "#5f72cbc0"
: "#9b3cc0c0"
}
iconStyles={{
transform: "translate(-50%, -50%) scale(0.8)",
}}
onClick={() => {
Duel.mutate((draft) => {
draft.protagonist.cooldownBooster++;
if (draft.protagonist.cooldownBooster === 4)
draft.protagonist.cooldownBooster = 0;
});
}}
/>
</Flex>
</ConfigurationChildWrapper>
);
}
@@ -1,446 +0,0 @@
import {
alias,
formatCompact,
tankIcon,
romanize,
} from "@blitzkit/core";
import {
EngineDefinition,
GunDefinition,
ModuleType,
TrackDefinition,
TurretDefinition,
type TankDefinition,
type Unlock,
} from "@blitzkit/protos";
import {
Button,
ChevronDownIcon,
Flex,
Heading,
IconButton,
Text,
Tooltip,
} from "@radix-ui/themes";
import { api } from "../../../../api/dynamic";
import { useLocale } from "../../../../hooks/useLocale";
import { Duel } from "../../../../stores/duel";
import { LinkI18n } from "../../../LinkI18n";
import { ConfigurationChildWrapper } from "./ConfigurationChildWrapper";
type ModuleDefinition =
| TurretDefinition
| GunDefinition
| TrackDefinition
| EngineDefinition;
function extractModule(raw: ModuleDefinition | TankDefinition) {
if ("gun_type" in raw) {
return raw;
} else
return raw as Exclude<ModuleDefinition | TankDefinition, GunDefinition>;
}
interface ModuleWithUnlocks {
unlocks: Unlock[];
}
const moduleTypeTypeIconName: Record<ModuleType, string> = {
[ModuleType.MODULE_TYPE_VEHICLE]: "",
[ModuleType.MODULE_TYPE_ENGINE]: "engine",
[ModuleType.MODULE_TYPE_TRACKS]: "chassis",
[ModuleType.MODULE_TYPE_TURRET]: "turret",
[ModuleType.MODULE_TYPE_GUN]: "gun",
};
function ModuleButton({
tier,
selected,
unlock,
top,
tooltip,
onClick,
}: {
unlock: Unlock;
tier: number;
selected: boolean;
top: boolean;
tooltip?: string;
onClick: () => void;
}) {
const isTank = unlock.type === ModuleType.MODULE_TYPE_VEHICLE;
const { locale } = useLocale();
const button = (
<IconButton
size="4"
radius="small"
variant={selected ? "surface" : "soft"}
color={top ? "amber" : selected ? undefined : "gray"}
onClick={onClick}
style={{
position: "relative",
width: isTank ? 72 : undefined,
height: isTank ? 72 : undefined,
}}
>
<Text
size="1"
color="gray"
style={{
position: "absolute",
left: isTank ? 8 : 4,
top: isTank ? 8 : 4,
}}
>
{romanize(tier)}
</Text>
{unlock.cost!.value > 0 && (
<Flex
style={{
position: "absolute",
left: "50%",
bottom: 4,
transform: "translateX(-50%)",
}}
align="center"
gap={isTank ? "1" : undefined}
>
<Text size="1" color="gray">
{formatCompact(locale, unlock.cost!.value)}
</Text>
<img
alt={unlock.cost!.type}
src={alias("api", `/icons/currencies/${unlock.cost!.type}.webp`)}
style={{
width: 12,
height: 12,
objectFit: "contain",
}}
/>
</Flex>
)}
<img
alt={ModuleType[unlock.type]}
src={
isTank
? tankIcon(unlock.id)
: alias(
"api",
`/icons/modules/${moduleTypeTypeIconName[unlock.type]}.webp`,
)
}
style={{
width: isTank ? 64 : 32,
height: isTank ? 64 : 32,
objectFit: "contain",
}}
/>
</IconButton>
);
const node = isTank ? (
<LinkI18n
locale={locale}
href={`/tanks/${tankDefinitions.tanks[unlock.id].slug}`}
>
{button}
</LinkI18n>
) : (
button
);
if (!tooltip) return node;
return <Tooltip content={tooltip}>{node}</Tooltip>;
}
const tankDefinitions = await api.tanks();
export function Modules() {
const tank = Duel.use((state) => state.protagonist.tank);
const hasUpgrades =
tank.turrets.length > 1 ||
tank.turrets[0].guns.length > 1 ||
tank.engines.length > 1 ||
tank.tracks.length > 1;
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const engine = Duel.use((state) => state.protagonist.engine);
const track = Duel.use((state) => state.protagonist.track);
const turret0 = tank.turrets[0];
const gun0 = turret0.guns[0];
const engine0 = tank.engines[0];
const track0 = tank.tracks[0];
const topTurret = tank.turrets.at(-1)!;
const topGun = topTurret.guns.at(-1)!;
const topEngine = tank.engines.at(-1)!;
const topTrack = tank.tracks.at(-1)!;
const { strings, unwrap } = useLocale();
function setByUnlock(unlock: Unlock) {
Duel.mutate((draft) => {
if (unlock.type === ModuleType.MODULE_TYPE_TURRET) {
draft.protagonist.turret = draft.protagonist.tank.turrets.find(
(turret) => turret.id === unlock.id,
)!;
// Check if current gun exists in new turret
const gunInNewTurret = draft.protagonist.turret.guns.find(
(gun) => gun.id === draft.protagonist.gun.id,
);
if (gunInNewTurret) {
// Gun exists - get it FROM this turret
draft.protagonist.gun = gunInNewTurret;
draft.protagonist.shell = gunInNewTurret.shells[0];
} else {
// Gun doesn't exist - select top gun from new turret
draft.protagonist.gun = draft.protagonist.turret.guns.at(-1)!;
draft.protagonist.shell = draft.protagonist.gun.shells[0];
}
} else if (unlock.type === ModuleType.MODULE_TYPE_GUN) {
const gunInTurret = draft.protagonist.turret.guns.find(
(gun) => gun.id === unlock.id,
);
if (gunInTurret) {
draft.protagonist.gun = gunInTurret;
draft.protagonist.shell = gunInTurret.shells[0];
} else {
// TODO: warn somehow?
const suitableTurret = draft.protagonist.tank.turrets.find((turret) =>
turret.guns.some((gun) => gun.id === unlock.id),
)!;
const gunInSuitableTurret = suitableTurret.guns.find(
(gun) => gun.id === unlock.id,
)!;
draft.protagonist.turret = suitableTurret;
draft.protagonist.gun = gunInSuitableTurret;
draft.protagonist.shell = gunInSuitableTurret.shells[0];
}
} else if (unlock.type === ModuleType.MODULE_TYPE_ENGINE) {
draft.protagonist.engine = draft.protagonist.tank.engines.find(
(engine) => engine.id === unlock.id,
)!;
} else if (unlock.type === ModuleType.MODULE_TYPE_TRACKS) {
draft.protagonist.track = draft.protagonist.tank.tracks.find(
(track) => track.id === unlock.id,
)!;
}
});
}
function tree(type: ModuleType, unlocks: Unlock[]) {
return (
<Flex gap="2">
{unlocks.map((unlock, index) => {
const first = index === 0;
const last = index === unlocks.length - 1;
const central = !first && !last;
let module: ModuleDefinition | TankDefinition | undefined = undefined;
if (unlock.type === ModuleType.MODULE_TYPE_TRACKS) {
module = tank.tracks.find((track) => track.id === unlock.id)!;
} else if (unlock.type === ModuleType.MODULE_TYPE_ENGINE) {
module = tank.engines.find((engine) => engine.id === unlock.id)!;
} else if (unlock.type === ModuleType.MODULE_TYPE_TURRET) {
module = tank.turrets.find((turret) => turret.id === unlock.id)!;
} else if (unlock.type === ModuleType.MODULE_TYPE_GUN) {
module = tank.turrets
.find((turret) =>
turret.guns.some((gun) => gun.id === unlock.id),
)!
.guns.find((gun) => gun.id === unlock.id)!;
} else if (unlock.type === ModuleType.MODULE_TYPE_VEHICLE) {
module = tankDefinitions.tanks[unlock.id];
}
if (module === undefined) return null;
const tooltip = unwrap(module.name!);
return (
<Flex
key={unlock.id}
align="center"
direction="column"
gap="2"
position="relative"
>
{unlock.cost!.value > 0 && (
<>
{unlocks.length > 1 && (
<div
style={{
backgroundColor: "currentcolor",
width: `calc(${central ? 100 : 50}% + ${
central ? 8 : 4
}px)`,
height: 1,
position: "absolute",
right: first ? 0 : undefined,
left: last ? 0 : undefined,
transform: `translateX(${first ? 4 : last ? -4 : 0}px)`,
}}
/>
)}
<Flex
direction="column"
align="center"
style={{
marginBottom: -7,
}}
>
<div
style={{
backgroundColor: "currentcolor",
width: 1,
height: 8,
}}
/>
<ChevronDownIcon
style={{
transform: "translateY(-7px)",
}}
/>
</Flex>
</>
)}
<ModuleButton
unlock={unlock}
tier={extractModule(module).tier}
tooltip={tooltip}
selected={
(unlock.type === ModuleType.MODULE_TYPE_TURRET
? turret.id
: unlock.type === ModuleType.MODULE_TYPE_GUN
? gun.id
: unlock.type === ModuleType.MODULE_TYPE_ENGINE
? engine.id
: unlock.type === ModuleType.MODULE_TYPE_TRACKS
? track.id
: -1) === unlock.id
}
top={
unlock.type === ModuleType.MODULE_TYPE_TURRET
? extractModule(module).id == topTurret.id
: unlock.type === ModuleType.MODULE_TYPE_GUN
? extractModule(module).id === topGun.id
: unlock.type === ModuleType.MODULE_TYPE_ENGINE
? extractModule(module).id === topEngine.id
: unlock.type === ModuleType.MODULE_TYPE_TRACKS
? extractModule(module).id === topTrack.id
: false
}
onClick={() => setByUnlock(unlock)}
/>
{(extractModule(module) as ModuleWithUnlocks).unlocks &&
(extractModule(module) as ModuleWithUnlocks).unlocks!.length >
1 && (
<div
style={{
backgroundColor: "currentcolor",
width: 1,
height: 8,
marginBottom: -8,
}}
/>
)}
{(extractModule(module) as ModuleWithUnlocks).unlocks &&
tree(
type,
(extractModule(module) as ModuleWithUnlocks).unlocks!,
)}
</Flex>
);
})}
</Flex>
);
}
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Heading size="4">
{strings.website.tools.tankopedia.configuration.modules.title}
</Heading>
{hasUpgrades && (
<>
<Button
variant="ghost"
color="red"
onClick={() => {
Duel.mutate((draft) => {
draft.protagonist.turret = draft.protagonist.tank.turrets[0];
draft.protagonist.gun = draft.protagonist.turret.guns[0];
draft.protagonist.shell = draft.protagonist.gun.shells[0];
draft.protagonist.engine = draft.protagonist.tank.engines[0];
draft.protagonist.track = draft.protagonist.tank.tracks[0];
});
}}
>
{strings.website.tools.tankopedia.configuration.modules.stock}
</Button>
<Button
variant="ghost"
onClick={() => {
Duel.mutate((draft) => {
draft.protagonist.turret =
draft.protagonist.tank.turrets.at(-1)!;
draft.protagonist.gun = draft.protagonist.turret.guns.at(-1)!;
draft.protagonist.shell = draft.protagonist.gun.shells[0];
draft.protagonist.engine =
draft.protagonist.tank.engines.at(-1)!;
draft.protagonist.track =
draft.protagonist.tank.tracks.at(-1)!;
});
}}
>
{strings.website.tools.tankopedia.configuration.modules.upgrade}
</Button>
</>
)}
</Flex>
<Flex gap="2" wrap="wrap" gapY="6">
{tree(ModuleType.MODULE_TYPE_ENGINE, [
{
cost: { type: "xp", value: 0 },
id: engine0.id,
type: ModuleType.MODULE_TYPE_ENGINE,
},
])}
{tree(ModuleType.MODULE_TYPE_TRACKS, [
{
cost: { type: "xp", value: 0 },
id: track0.id,
type: ModuleType.MODULE_TYPE_TRACKS,
},
])}
{tree(ModuleType.MODULE_TYPE_TURRET, [
{
cost: { type: "xp", value: 0 },
id: turret0.id,
type: ModuleType.MODULE_TYPE_TURRET,
},
])}
{tree(ModuleType.MODULE_TYPE_GUN, [
{
cost: { type: "xp", value: 0 },
id: gun0.id,
type: ModuleType.MODULE_TYPE_GUN,
},
])}
</Flex>
</ConfigurationChildWrapper>
);
}
@@ -1,48 +0,0 @@
import { availableProvisions } from "@blitzkit/core";
import { Button, Flex, Heading } from "@radix-ui/themes";
import { api } from "../../../../api/dynamic";
import { useLocale } from "../../../../hooks/useLocale";
import { Duel } from "../../../../stores/duel";
import { ProvisionsManager } from "../../../ProvisionsManager";
import { ConfigurationChildWrapper } from "./ConfigurationChildWrapper";
const provisionDefinitions = await api.provisions();
export function Provisions() {
const { tank, gun } = Duel.use((state) => state.protagonist);
const provisions = Duel.use((state) => state.protagonist.provisions);
const provisionsList = availableProvisions(tank, gun, provisionDefinitions);
const { strings } = useLocale();
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Heading size="4">
{strings.website.tools.tankopedia.configuration.provisions.title}
</Heading>
<Button
variant="ghost"
color="red"
onClick={() => {
Duel.mutate((draft) => {
draft.protagonist.provisions = [];
});
}}
>
{strings.website.tools.tankopedia.configuration.provisions.clear}
</Button>
</Flex>
<ProvisionsManager
provisions={provisionsList.map((provision) => provision.id)}
selected={provisions}
disabled={tank.max_provisions === provisions.length}
onChange={(provisions) => {
Duel.mutate((draft) => {
draft.protagonist.provisions = provisions;
});
}}
/>
</ConfigurationChildWrapper>
);
}
@@ -1,82 +0,0 @@
import { MAX_SKILL_LEVEL } from "@blitzkit/core";
import { InfoCircledIcon } from "@radix-ui/react-icons";
import {
Button,
Flex,
Heading,
IconButton,
Popover,
Text,
} from "@radix-ui/themes";
import { useLocale } from "../../../../hooks/useLocale";
import { Tankopedia } from "../../../../stores/tankopedia";
import { CrewSkillManager } from "../../../CrewSkillManager";
import { ConfigurationChildWrapper } from "./ConfigurationChildWrapper";
export function Skills() {
const skillLevels = Tankopedia.use((state) => state.skills);
const { strings } = useLocale();
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Flex gap="2" align="center">
<Heading size="4">
{strings.website.tools.tankopedia.configuration.skills.title}
</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex direction="column" gap="2">
<Text>
{strings.website.tools.tankopedia.configuration.skills.info}
</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
<Button
variant="ghost"
color="red"
onClick={() => {
Tankopedia.mutate((draft) => {
Object.keys(draft.skills).forEach((skill) => {
draft.skills[skill] = 0;
});
});
}}
>
{strings.website.tools.tankopedia.configuration.skills.clear}
</Button>
<Button
variant="ghost"
onClick={() => {
Tankopedia.mutate((draft) => {
Object.keys(draft.skills).forEach((skill) => {
draft.skills[skill] = MAX_SKILL_LEVEL;
});
});
}}
>
{strings.website.tools.tankopedia.configuration.skills.maximize}
</Button>
</Flex>
<CrewSkillManager
skillLevels={skillLevels}
onChange={(skills) => {
Tankopedia.mutate((draft) => {
draft.skills = skills;
});
}}
/>
</ConfigurationChildWrapper>
);
}
@@ -17,7 +17,7 @@ import { CanvasControl } from "../CanvasControl";
import { InitialCameraAligner } from "../InitialCameraAligner";
import { Lighting, transitionEvent } from "../Lighting";
import { SceneProps } from "../SceneProps";
import { ShotDisplay } from "../ShotDisplay";
import { ShotCard } from "../ShotCard";
import { SmartCanvas } from "../SmartCanvas";
import { TankDynamicArmor } from "../TankDynamicArmor";
import { TankModel } from "../TankModel";
@@ -75,7 +75,7 @@ export function TankopediaSandbox({
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer
);
modelTransformEvent.dispatch({ pitch, yaw });
@@ -89,7 +89,7 @@ export function TankopediaSandbox({
0,
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasImprovedVerticalStabilizer
);
modelTransformEvent.dispatch({ pitch, yaw });
@@ -103,7 +103,7 @@ export function TankopediaSandbox({
0,
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasImprovedVerticalStabilizer
);
modelTransformEvent.dispatch({ pitch, yaw });
@@ -131,7 +131,7 @@ export function TankopediaSandbox({
modelTransformEvent.last!.yaw,
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasImprovedVerticalStabilizer
);
modelTransformEvent.dispatch({ pitch, yaw });
@@ -191,7 +191,7 @@ function Content({ thicknessRange }: { thicknessRange: ThicknessRange }) {
const { hasPbr } = useModel(tank.id);
const hideTankModelUnderArmor = TankopediaPersistent.use(
(state) => state.hideTankModelUnderArmor,
(state) => state.hideTankModelUnderArmor
);
return (
@@ -208,7 +208,7 @@ function Content({ thicknessRange }: { thicknessRange: ThicknessRange }) {
<TankStaticArmor thicknessRange={thicknessRange} />
)}
<ShotDisplay />
<ShotCard />
<ArmorPlateDisplay />
<CanvasAutoClear />
@@ -47,8 +47,6 @@ export function TankopediaSkinSwitcher() {
hasSkinAnimations(tank.id, loadedSkin) ||
hasSkinAnimations(tank.id, loadedAttachment);
console.log("hasAnimations", hasAnimations);
return (
<div className={styles["skin-switcher"]} data-visible={isVisible}>
{groups.map((group, index) => (
+11 -5
View File
@@ -23,10 +23,18 @@ export function Text({
}: TextProps) {
const scale = color === "gray" && lowContrast ? "white" : color;
const isBW = color === "black" || color === "white";
let power: string | number = isBW ? "a12" : 12;
let power = color === "gray" ? "12" : "11";
if (lowContrast) {
power = color === "gray" ? "a9" : isBW ? "a11" : 11;
power = "5";
if (color === "gray") {
power = "a9";
}
if (isBW) {
power = "a11";
}
}
return (
@@ -36,9 +44,7 @@ export function Text({
data-size={size}
data-weight={weight}
data-align={align === "left" ? undefined : align}
style={{
color: `var(--${scale}-${power})`,
}}
style={{ color: `var(--${scale}-${power})` }}
{...props}
/>
);
@@ -28,6 +28,7 @@ const { id, locale } = Astro.props;
<ColorScale color="jade" />
<ColorScale color="jade" alpha />
<ColorScale color="cyan" alpha />
<ColorScale color="green" />
<Page {id} {locale} client:only="react">
<Page slot="fallback" {id} {locale} skeleton />