From 0bdb66742b261941465f9e6ff81b52ab6e8e6295 Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Sun, 4 Oct 2026 13:27:34 +1300 Subject: [PATCH] Feature: Add Spall Liner toggle to Tankopedia model viewer ## Summary - Add a Spall Liner toggle button to the Tankopedia model viewer, placed alongside the existing Calibrated Shells / Enhanced Armor buttons. It's only shown for tanks that can actually equip Spall Liner. - The toggle reads/writes the same `Duel.protagonist.provisions` array as the Provisions tab, so both stay in sync automatically. - If a provision slot is free, clicking equips Spall Liner immediately. If all slots are full, a popover shows icons with a shuffle arrow so you can pick which active provision to swap out in one step. - Buttons use the same medium rounding as the consumable buttons on the tank page. - When Spall Liner is equipped, the Dynamic Armor 3D viewer's click-damage tooltip now applies the same 20% HE damage reduction Spall Liner grants in-game. - Refactored the hardcoded `-0.2` HE damage coefficient in `tankCharacteristics.ts` into a shared constant so the Compare tool and the 3D viewer use a single source of truth. ## Scope This only affects the single-tank Tankopedia page. The Firepower stats table (the viewed tank's own outgoing gun damage) is intentionally untouched, since a defensive damage reduction would be semantically backwards there. The Dynamic Armor viewer's click tooltip is the only place a "damage taken by this tank" number exists, so that's where the reduction is wired in. --- .../SpacedArmorSceneComponent/index.tsx | 11 ++ .../Options/components/SpallLinerSwitcher.tsx | 128 ++++++++++++++++++ .../HeroSection/components/Options/index.tsx | 3 + .../website/src/core/blitzkit/spallLiner.ts | 8 ++ .../src/core/blitzkit/tankCharacteristics.ts | 6 +- 5 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SpallLinerSwitcher.tsx create mode 100644 packages/website/src/core/blitzkit/spallLiner.ts diff --git a/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx b/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx index f2c770593..784836464 100644 --- a/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx +++ b/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx @@ -18,6 +18,10 @@ import { import { degToRad } from "three/src/math/MathUtils.js"; import { hasEquipment } from "../../../../core/blitzkit/hasEquipment"; import { jsxTree } from "../../../../core/blitzkit/jsxTree"; +import { + SPALL_LINER_HE_DAMAGE_DELTA, + SPALL_LINER_PROVISION_ID, +} from "../../../../core/blitzkit/spallLiner"; import { defaultEqualizer } from "../../../../core/blitzkit/tankToDuelMember"; import { discardClippingPlane } from "../../../../core/three/discardClippingPlane"; import { Duel } from "../../../../stores/duel"; @@ -384,6 +388,13 @@ export function SpacedArmorSceneComponent({ shot.damage *= antagonistEqualizer.damage; + const hasSpallLiner = Duel.state.protagonist.provisions.includes( + SPALL_LINER_PROVISION_ID, + ); + if (hasSpallLiner && shell.type === ShellType.SHELL_TYPE_HE) { + shot.damage *= 1 + SPALL_LINER_HE_DAMAGE_DELTA; + } + return shot; }, [camera], diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SpallLinerSwitcher.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SpallLinerSwitcher.tsx new file mode 100644 index 000000000..021f5cacf --- /dev/null +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SpallLinerSwitcher.tsx @@ -0,0 +1,128 @@ +import { alias, availableProvisions } from "@blitzkit/core"; +import { ShuffleIcon } from "@radix-ui/react-icons"; +import { Flex, IconButton, Popover } from "@radix-ui/themes"; +import { api } from "../../../../../../core/blitzkit/api"; +import { SPALL_LINER_PROVISION_ID } from "../../../../../../core/blitzkit/spallLiner"; +import { useLocale } from "../../../../../../hooks/useLocale"; +import { Duel } from "../../../../../../stores/duel"; +import { Tankopedia } from "../../../../../../stores/tankopedia"; + +const provisionDefinitions = await api.provisionDefinitions(); + +export function SpallLinerSwitcher() { + const { tank, gun, provisions } = Duel.use((state) => state.protagonist); + const { unwrap } = useLocale(); + const isSpallLinerActive = provisions.includes(SPALL_LINER_PROVISION_ID); + const hasSlotAvailable = provisions.length < tank.max_provisions; + + const canEquipSpallLiner = availableProvisions( + tank, + gun, + provisionDefinitions, + ).some((provision) => provision.id === SPALL_LINER_PROVISION_ID); + + if (!canEquipSpallLiner) return null; + + function equip(replacing?: number) { + Duel.mutate((draft) => { + if (replacing !== undefined) { + const provisions = draft.protagonist.provisions; + provisions[provisions.indexOf(replacing)] = SPALL_LINER_PROVISION_ID; + } else { + draft.protagonist.provisions.push(SPALL_LINER_PROVISION_ID); + } + }); + Tankopedia.mutate((draft) => { + draft.shot = undefined; + }); + } + + function unequip() { + Duel.mutate((draft) => { + draft.protagonist.provisions = draft.protagonist.provisions.filter( + (id) => id !== SPALL_LINER_PROVISION_ID, + ); + }); + Tankopedia.mutate((draft) => { + draft.shot = undefined; + }); + } + + const button = ( + { + if (isSpallLinerActive) { + unequip(); + } else if (hasSlotAvailable) { + equip(); + } + }} + > + {unwrap(provisionDefinitions.provisions[SPALL_LINER_PROVISION_ID].name!)} + + ); + + if (isSpallLinerActive || hasSlotAvailable) return button; + + return ( + + {button} + + + + + {unwrap( + + + + + + {provisions.map((id) => ( + + equip(id)} + > + {unwrap(provisionDefinitions.provisions[id].name!)} + + + ))} + + + + + ); +} diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx index 05fc67d92..5856c66d0 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx @@ -46,6 +46,7 @@ import { TankSearch } from "../../../../TankSearch"; import { CustomShellButton } from "./components/CustomShellButton"; import { DynamicArmorSwitcher } from "./components/DynamicArmorSwitcher"; import { QuickInputs } from "./components/QuickInputs"; +import { SpallLinerSwitcher } from "./components/SpallLinerSwitcher"; import { Thicknesses } from "./components/Thicknesses"; type OptionsProps = MaybeSkeletonComponentProps & { @@ -278,6 +279,8 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) { }} /> + + {!skeleton && ( diff --git a/packages/website/src/core/blitzkit/spallLiner.ts b/packages/website/src/core/blitzkit/spallLiner.ts new file mode 100644 index 000000000..80598d5c1 --- /dev/null +++ b/packages/website/src/core/blitzkit/spallLiner.ts @@ -0,0 +1,8 @@ +export const SPALL_LINER_PROVISION_ID = 101; + +/** + * Matches the in-game reduction Spall Liner grants against HE splash + * damage. Shared between the characteristics panel and the Dynamic + * Armor 3D viewer's damage readout so the figure isn't duplicated. + */ +export const SPALL_LINER_HE_DAMAGE_DELTA = -0.2; diff --git a/packages/website/src/core/blitzkit/tankCharacteristics.ts b/packages/website/src/core/blitzkit/tankCharacteristics.ts index 0cc72302e..51301190a 100644 --- a/packages/website/src/core/blitzkit/tankCharacteristics.ts +++ b/packages/website/src/core/blitzkit/tankCharacteristics.ts @@ -22,6 +22,7 @@ import { } from "@blitzkit/core"; import { coefficient } from "@blitzkit/core/src/blitzkit/coefficient"; import type { EquipmentMatrix } from "../../stores/duel"; +import { SPALL_LINER_HE_DAMAGE_DELTA } from "./spallLiner"; import { defaultEqualizer } from "./tankToDuelMember"; export type TankCharacteristics = ReturnType; @@ -170,7 +171,10 @@ export function tankCharacteristics( coefficient( [applyReactiveArmor && shell.type !== ShellType.SHELL_TYPE_HE, -0.27], [applyDynamicArmor, -0.1], - [applySpallLiner && shell.type === ShellType.SHELL_TYPE_HE, -0.2], + [ + applySpallLiner && shell.type === ShellType.SHELL_TYPE_HE, + SPALL_LINER_HE_DAMAGE_DELTA, + ], ); const assaultDamageCoefficient = gun.assault_ranges && gun.assault_ranges.types.includes(shell.type)