From 9e388154756d1ad3206a1b42b970137762950df6 Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Tue, 14 Jul 2026 10:31:14 +1200 Subject: [PATCH 1/4] Add Spall Liner toggle to Tankopedia model viewer --- packages/i18n/strings/en.json | 4 + .../SpacedArmorSceneComponent/index.tsx | 11 ++ .../Options/components/SpallLinerSwitcher.tsx | 103 ++++++++++++++++++ .../HeroSection/components/Options/index.tsx | 3 + .../website/src/core/blitzkit/spallLiner.ts | 8 ++ .../src/core/blitzkit/tankCharacteristics.ts | 6 +- 6 files changed, 134 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/i18n/strings/en.json b/packages/i18n/strings/en.json index 534e2fa8d..9ac4145ee 100644 --- a/packages/i18n/strings/en.json +++ b/packages/i18n/strings/en.json @@ -527,6 +527,10 @@ "label": "Edit", "reset": "Reset" } + }, + + "spall_liner": { + "swap_prompt": "Spall Liner will replace one of your active provisions. Choose which one to remove:" } }, 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..3b66d75dc --- /dev/null +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SpallLinerSwitcher.tsx @@ -0,0 +1,103 @@ +import { alias, availableProvisions } from "@blitzkit/core"; +import { Flex, IconButton, Popover, Text } 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"; +import { ProvisionButton } from "../../../../../ModuleButtons/ProvisionButton"; + +const provisionDefinitions = await api.provisionDefinitions(); + +export function SpallLinerSwitcher() { + const { tank, gun, provisions } = Duel.use((state) => state.protagonist); + const { strings, 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} + + + + + {strings.website.tools.tankopedia.sandbox.spall_liner.swap_prompt} + + + + {provisions.map((id) => ( + + equip(id)} + /> + + ))} + + + + + ); +} 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 d9d041098..f03bbf3e7 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) From 85b8c7057d040de7fa72d786c8fb83af3e76c1ef Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Sat, 26 Sep 2026 19:45:17 +1200 Subject: [PATCH 2/4] Redesign Spall Liner swap popover with icons Replace wordy swap prompt with compact icon row (spall liner icon, swap icon, current provisions) and drop unused ProvisionButton tooltip that flashed on popover open. --- packages/i18n/strings/en.json | 4 -- .../Options/components/SpallLinerSwitcher.tsx | 49 ++++++++++++++----- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json index 9ac4145ee..534e2fa8d 100644 --- a/packages/i18n/strings/en.json +++ b/packages/i18n/strings/en.json @@ -527,10 +527,6 @@ "label": "Edit", "reset": "Reset" } - }, - - "spall_liner": { - "swap_prompt": "Spall Liner will replace one of your active provisions. Choose which one to remove:" } }, 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 index 3b66d75dc..34ecffe18 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SpallLinerSwitcher.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SpallLinerSwitcher.tsx @@ -1,17 +1,17 @@ import { alias, availableProvisions } from "@blitzkit/core"; -import { Flex, IconButton, Popover, Text } from "@radix-ui/themes"; +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"; -import { ProvisionButton } from "../../../../../ModuleButtons/ProvisionButton"; const provisionDefinitions = await api.provisionDefinitions(); export function SpallLinerSwitcher() { const { tank, gun, provisions } = Duel.use((state) => state.protagonist); - const { strings, unwrap } = useLocale(); + const { unwrap } = useLocale(); const isSpallLinerActive = provisions.includes(SPALL_LINER_PROVISION_ID); const hasSlotAvailable = provisions.length < tank.max_provisions; @@ -80,19 +80,44 @@ export function SpallLinerSwitcher() { {button} - - - {strings.website.tools.tankopedia.sandbox.spall_liner.swap_prompt} - + + + {unwrap( + - + + + {provisions.map((id) => ( - equip(id)} - /> + > + {unwrap(provisionDefinitions.provisions[id].name!)} + ))} From 0bdb66742b261941465f9e6ff81b52ab6e8e6295 Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Sun, 4 Oct 2026 13:27:34 +1300 Subject: [PATCH 3/4] 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) From 9a659845f54030c0ae90a8c53200c17e0bccf1c6 Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:51:53 -0500 Subject: [PATCH 4/4] make spall icon not a button --- .../Options/components/SpallLinerSwitcher.tsx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) 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 index 021f5cacf..77653a3c9 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SpallLinerSwitcher.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SpallLinerSwitcher.tsx @@ -63,7 +63,9 @@ export function SpallLinerSwitcher() { }} > {unwrap(provisionDefinitions.provisions[SPALL_LINER_PROVISION_ID].name!)} - - {unwrap( - + {unwrap(