From fbdfa2c0634c0de5e2aa1917b25bc1919db4a7f5 Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Thu, 8 Oct 2026 18:00:57 +1300 Subject: [PATCH] Feature: Penetration chance in the armor shot card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows the chance a shot penetrates in the dynamic armor shot card, right-aligned on the shot's result line. - Penetration varies by ±5% (the same randomisation the armor highlighter shader uses), so the chance is the share of that range where the shot's final outcome is a penetration - Re-runs the existing shot calculation at different penetration values, so spaced armor, HEAT gap losses and ricochets are taken into account - For ricochets the chance is shown on the ricocheted shot's line; HE splashes don't count as penetrations - Adds the `shot_card.stats.penetration_chance` string Fixes #342 --- .../Armor/components/ShotDisplayCard.tsx | 45 ++++++++++++------- .../SpacedArmorSceneComponent/index.tsx | 43 ++++++++++++++++++ packages/website/src/stores/tankopedia.ts | 1 + 3 files changed, 74 insertions(+), 15 deletions(-) diff --git a/packages/website/src/components/Armor/components/ShotDisplayCard.tsx b/packages/website/src/components/Armor/components/ShotDisplayCard.tsx index e27c1de5c..6720458f3 100644 --- a/packages/website/src/components/Armor/components/ShotDisplayCard.tsx +++ b/packages/website/src/components/Armor/components/ShotDisplayCard.tsx @@ -128,19 +128,30 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) { ? 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 && ( + + {literals(strings.common.units.percentage, { + value: (shot.penetrationChance * 100).toFixed(0), + })} + + ); return ( - - {literals( - strings.website.tools.tankopedia.sandbox.dynamic.shot_card.status[ - shot.in.status - ], - { damage: Math.round(shot.damage).toLocaleString(locale) } - )} - + + + {literals( + strings.website.tools.tankopedia.sandbox.dynamic.shot_card + .status[shot.in.status], + { damage: Math.round(shot.damage).toLocaleString(locale) } + )} + + + {!hasOutLayers && penetrationChance} + - - {literals( - strings.website.tools.tankopedia.sandbox.dynamic.shot_card - .status[shot.out.status], - { damage: Math.round(shot.damage).toLocaleString(locale) } - )} - + + + {literals( + strings.website.tools.tankopedia.sandbox.dynamic.shot_card + .status[shot.out.status], + { damage: Math.round(shot.damage).toLocaleString(locale) } + )} + + + {penetrationChance} + {shot.out.layers.map((layer, index) => { diff --git a/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx b/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx index 784836464..57149cbe4 100644 --- a/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx +++ b/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx @@ -63,6 +63,36 @@ export type ArmorUserData = { } ); +const PENETRATION_RANDOMIZATION = 0.05; + +function resolvePenetrationChance(shoot: (scale: number) => Shot | null) { + function penetrates(scale: number) { + const shot = shoot(scale); + + return (shot?.out ?? shot?.in)?.status === "penetration"; + } + + let low = 1 - PENETRATION_RANDOMIZATION; + let high = 1 + PENETRATION_RANDOMIZATION; + + if (penetrates(low)) return 1; + if (!penetrates(high)) return 0; + + for (let iteration = 0; iteration < 16; iteration++) { + const middle = (low + high) / 2; + + if (penetrates(middle)) { + high = middle; + } else { + low = middle; + } + } + + return ( + (1 + PENETRATION_RANDOMIZATION - high) / (2 * PENETRATION_RANDOMIZATION) + ); +} + const omitMaterial = new MeshBasicMaterial({ colorWrite: false, depthWrite: true, @@ -89,6 +119,7 @@ export function SpacedArmorSceneComponent({ intersections: Intersection[], allowRicochet: boolean, remainingPenetrationInput?: number, + penetrationScale = 1, ) => { const { customShell } = Tankopedia.state; const shell = customShell ?? Duel.state.antagonist.shell; @@ -125,6 +156,7 @@ export function SpacedArmorSceneComponent({ Duel.state.protagonist.equipmentMatrix, ); const penetration = + penetrationScale * shell.penetration!.near * resolvePenetrationCoefficient( hasCalibratedShells, @@ -367,6 +399,7 @@ export function SpacedArmorSceneComponent({ ricochetIntersections, false, remainingPenetration, + penetrationScale, ); shot.in.status = "ricochet"; @@ -422,6 +455,16 @@ export function SpacedArmorSceneComponent({ const shot = shoot(event.point, event.intersections, true)!; + shot.penetrationChance = resolvePenetrationChance((scale) => + shoot( + event.point, + event.intersections, + true, + undefined, + scale, + ), + ); + Tankopedia.mutate((draft) => { draft.shot = shot; }); diff --git a/packages/website/src/stores/tankopedia.ts b/packages/website/src/stores/tankopedia.ts index 023ab0e87..d6e374a0a 100644 --- a/packages/website/src/stores/tankopedia.ts +++ b/packages/website/src/stores/tankopedia.ts @@ -43,6 +43,7 @@ export type Shot = { containsGaps: boolean; damage: number; splashRadius?: number; + penetrationChance?: number; in: { surfaceNormal: Vector3;