From 39d5b4512ab202516623f372e0ab24aabff6aa19 Mon Sep 17 00:00:00 2001 From: Mechanica Date: Fri, 14 Aug 2026 11:40:19 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D1=8C=D0=BA=D1=83=D0=BB?= =?UTF-8?q?=D1=8F=D1=82=D0=BE=D1=80=20is=20cooming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.tsx | 1 + src/modules/damage/DamagePage.tsx | 118 +++++++++++ src/modules/damage/components/AmmoPicker.tsx | 38 ++++ .../damage/components/WeaponPicker.tsx | 56 +++++ src/modules/damage/damageMath.test.ts | 158 ++++++++++++++ src/modules/damage/damageMath.ts | 199 ++++++++++++++++++ src/modules/damage/mobCatalogStore.ts | 68 ++++++ src/modules/damage/mobConfig.ts | 5 + src/modules/damage/mobTypes.ts | 37 ++++ src/modules/registry.tsx | 4 +- src/styles/damage.css | 34 +++ 11 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 src/modules/damage/DamagePage.tsx create mode 100644 src/modules/damage/components/AmmoPicker.tsx create mode 100644 src/modules/damage/components/WeaponPicker.tsx create mode 100644 src/modules/damage/damageMath.test.ts create mode 100644 src/modules/damage/damageMath.ts create mode 100644 src/modules/damage/mobCatalogStore.ts create mode 100644 src/modules/damage/mobConfig.ts create mode 100644 src/modules/damage/mobTypes.ts create mode 100644 src/styles/damage.css diff --git a/src/main.tsx b/src/main.tsx index 722f823..055a2b7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,7 @@ import "./styles/base.css"; import "./styles/home.css"; import "./styles/equipment.css"; import "./styles/chemistry.css"; +import "./styles/damage.css"; import "./styles/responsive.css"; createRoot(document.getElementById("root")!).render( diff --git a/src/modules/damage/DamagePage.tsx b/src/modules/damage/DamagePage.tsx new file mode 100644 index 0000000..2509dba --- /dev/null +++ b/src/modules/damage/DamagePage.tsx @@ -0,0 +1,118 @@ +import { useMemo, useState } from "react"; +import { useCatalog } from "../equipment/catalogStore"; +import { ItemSprite } from "../equipment/components/ItemSprite"; +import { capitalizeName, formatDamage, formatNumber, isMap } from "../equipment/format"; +import type { JsonMap } from "../equipment/types"; +import { AmmoPicker, ammoProjectiles } from "./components/AmmoPicker"; +import { WeaponPicker } from "./components/WeaponPicker"; + +export function DamagePage() { + const { catalog, error, loading, retry } = useCatalog(); + const [selectedWeaponId, setSelectedWeaponId] = useState(null); + const [selectedAmmoIndex, setSelectedAmmoIndex] = useState(0); + + const selectedWeapon = selectedWeaponId && catalog ? catalog.items[selectedWeaponId] : null; + const ammunition = useMemo(() => { + const raw = selectedWeapon?.weaponStats?.ammunition; + return Array.isArray(raw) ? raw.filter(isMap) : []; + }, [selectedWeapon]); + const selectedAmmo: JsonMap | undefined = ammunition[selectedAmmoIndex]; + const projectiles = useMemo(() => ammoProjectiles(selectedAmmo), [selectedAmmo]); + + const selectWeapon = (id: string) => { + setSelectedWeaponId(id); + setSelectedAmmoIndex(0); + }; + + return ( +
+
+
+

USCM // TTK CALCULATOR

+

Калькулятор урона

+

Оружие, боеприпасы, дистанция и броня цели — расчёт урона и времени до убийства.

+
+
+ STATUS{loading ? "SYNC" : error ? "ERROR" : "ONLINE"} + {catalog && BUILD {catalog.gameCommit.slice(0, 8).toUpperCase()}} +
+
+ + {loading && !catalog &&
DATABASE MESSAGEСинхронизация

Загружаю каталог снаряжения…

} + {error && !catalog && ( +
+ DATABASE MESSAGEОшибка загрузки

{error}

+ +
+ )} + + {catalog && ( +
+
+

Оружие

+ +
+ +
+ {!selectedWeapon &&
Выберите оружие слева.
} + {selectedWeapon && ( + <> +
+ +
+ {capitalizeName(selectedWeapon.name)} + {selectedWeapon.id} +
+
+ +
+
+
Скорострельность
+
+ {selectedWeapon.weaponStats?.shotsPerSecond != null + ? `${formatNumber(selectedWeapon.weaponStats.shotsPerSecond)} выстр./с` + : "—"} +
+
+ {selectedWeapon.weaponStats?.damageMultiplier != null && ( +
+
Множитель урона
+
{`×${formatNumber(selectedWeapon.weaponStats.damageMultiplier)}`}
+
+ )} +
+ + {ammunition.length > 0 ? ( + <> +

Боеприпас

+ + +
+ {projectiles.map((projectile, index) => ( +
+ {String(projectile.name ?? "Снаряд")} +
+
Урон
{formatDamage(projectile.effectiveDamage ?? projectile.damage) ?? "—"}
+ {projectile.armorPiercing != null && ( +
Бронепробитие
{formatNumber(projectile.armorPiercing)}
+ )} +
+
+ ))} +
+ + ) : ( +

У этого оружия нет вариантов боеприпасов в каталоге.

+ )} + +

+ Обвесы, цели и дистанция — в следующих срезах. +

+ + )} +
+
+ )} +
+ ); +} diff --git a/src/modules/damage/components/AmmoPicker.tsx b/src/modules/damage/components/AmmoPicker.tsx new file mode 100644 index 0000000..ac90220 --- /dev/null +++ b/src/modules/damage/components/AmmoPicker.tsx @@ -0,0 +1,38 @@ +import { isMap } from "../../equipment/format"; +import type { JsonMap } from "../../equipment/types"; + +export function AmmoPicker({ ammunition, selectedIndex, onSelect }: { + ammunition: JsonMap[]; + selectedIndex: number; + onSelect: (index: number) => void; +}) { + if (!ammunition.length) return null; + return ( +
+ {ammunition.map((entry, index) => { + const label = String( + entry.magazineName ?? entry.ammoName ?? entry.magazineId ?? entry.ammoId ?? `Боеприпас ${index + 1}`, + ); + const isAp = /ББ|Бронебойн/u.test(label); + return ( + + ); + })} +
+ ); +} + +export function ammoProjectiles(entry: JsonMap | undefined): JsonMap[] { + if (!entry || !Array.isArray(entry.projectiles)) return []; + return entry.projectiles.filter(isMap); +} diff --git a/src/modules/damage/components/WeaponPicker.tsx b/src/modules/damage/components/WeaponPicker.tsx new file mode 100644 index 0000000..648569a --- /dev/null +++ b/src/modules/damage/components/WeaponPicker.tsx @@ -0,0 +1,56 @@ +import { useMemo } from "react"; +import { ItemSprite } from "../../equipment/components/ItemSprite"; +import { capitalizeName } from "../../equipment/format"; +import type { Catalog, CatalogItem } from "../../equipment/types"; + +// Category "Оружие" already excludes turrets (Снаряжение), underbarrel +// modules (Обвесы) and the hidden *Empty duplicate prototypes (Скрытые) that +// exist only for map-spawned unloaded guns. These specific ids stay in that +// category but aren't handheld weapons a player picks for a TTK comparison. +const EXCLUDED_WEAPON_IDS = new Set([ + "RMCWeaponLauncherM85A1", // Гранатомет M79 + "WeaponLauncherM83", // Гранатомет M83 + "RMCWeaponFlamerSpec", // Огнеметная установка M240-T + "RMCWeaponFlamer", // Огнеметная установка M240A1 + "RMCWeaponTaser", // Тазер + "RMCWeaponPistolM82F", // Сигнальный пистолет M82-F + "RMCWeaponLauncherM5ATL", // M5-ATL + "RMCWeaponLauncherM6HBrute", // M6H-BRUTE + "STWeaponSharpRifle", // Винтовка P9 SHARP + "RMCWeaponRevolverM44Marksman", // Боевой револьвер M44 (Марксманский) +]); + +export function WeaponPicker({ catalog, selectedId, onSelect }: { + catalog: Catalog; + selectedId: string | null; + onSelect: (id: string) => void; +}) { + const weapons = useMemo(() => ( + catalog.publicCatalog.itemIds + .map((id) => catalog.items[id]) + .filter((item): item is CatalogItem => ( + Boolean(item?.weaponStats) + && item.category === "Оружие" + && !EXCLUDED_WEAPON_IDS.has(item.id) + )) + .sort((a, b) => a.name.localeCompare(b.name, "ru")) + ), [catalog]); + + return ( +
+ {weapons.map((item) => ( + + ))} +
+ ); +} diff --git a/src/modules/damage/damageMath.test.ts b/src/modules/damage/damageMath.test.ts new file mode 100644 index 0000000..1355ccc --- /dev/null +++ b/src/modules/damage/damageMath.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { + ammoNeeded, + applyArmorMitigation, + computeHitDamage, + falloffMultiplier, + hitsToKill, + timeToKillSeconds, +} from "./damageMath"; +import type { MarineArmor, XenoTargetArmor } from "./damageMath"; + +// Verified against BulletRifle10x24mm / WeaponRifleM4SPR data pulled from the +// real equipment-catalog.json: effectiveDamage.Piercing = 56, weapon +// falloffMultiplier = 0, thresholds [{range:24, falloff:9999, ignoreModifiers: +// true}, {range:7, falloff:4}]. +const AR10_THRESHOLDS = [ + { range: 24, falloff: 9999, ignoreModifiers: true }, + { range: 7, falloff: 4 }, +]; + +describe("falloffMultiplier", () => { + it("is 1 when no threshold has been crossed", () => { + expect(falloffMultiplier(56, 5, AR10_THRESHOLDS, 0)).toBe(1); + }); + + it("stays 1 past the ramping threshold when the weapon's falloff multiplier is 0", () => { + // This weapon zeroes out normal ramping falloff, relying only on the + // ignoreModifiers threshold as a hard cutoff. + expect(falloffMultiplier(56, 15, AR10_THRESHOLDS, 0)).toBe(1); + }); + + it("clamps to the 5% floor past the hard ignoreModifiers cutoff", () => { + expect(falloffMultiplier(56, 30, AR10_THRESHOLDS, 0)).toBeCloseTo(0.05, 10); + }); + + it("applies normal ramping falloff when the weapon's multiplier is nonzero", () => { + const thresholds = [{ range: 10, falloff: 5 }]; + // distance 15: pastEffectiveRange 5, extraMult 1 -> (100 - 5*5*1)/100 = 0.75 + expect(falloffMultiplier(100, 15, thresholds, 1)).toBeCloseTo(0.75, 10); + }); + + it("returns 1 for non-positive input damage", () => { + expect(falloffMultiplier(0, 100, AR10_THRESHOLDS, 1)).toBe(1); + }); +}); + +const HELMET_ARMOR: MarineArmor = { kind: "marine", bullet: 20, melee: 20, bio: 20 }; +const WARRIOR_ARMOR: XenoTargetArmor = { kind: "xeno", xenoArmor: 20, immuneToArmorPiercing: false }; + +describe("applyArmorMitigation", () => { + it("matches the verified two-stage Resist formula for a marine target", () => { + // armor after piercing: 20 - 5 = 15; resist = 1.1^(15/5) = 1.331 + // 56 / 1.331 = 42.0736...; total (42.07) is above armor*2 (30), stage 2 doesn't fire. + const result = applyArmorMitigation({ Piercing: 56 }, 5, "bullet", HELMET_ARMOR); + expect(result.Piercing).toBeCloseTo(56 / 1.331, 6); + }); + + it("matches the same Resist formula for a xeno target's innate armor", () => { + const result = applyArmorMitigation({ Piercing: 56 }, 5, "bullet", WARRIOR_ARMOR); + expect(result.Piercing).toBeCloseTo(56 / 1.331, 6); + }); + + it("mitigates marine Heat damage through the bio stat but leaves xeno Heat untouched", () => { + // This is the easiest spot to silently get wrong: xenos only ever resist + // the Brute group, so Burn-type damage (Heat/Shock/Cold/Caustic) passes + // straight through their armor, while marines resist it via `bio`. + const marineResult = applyArmorMitigation( + { Piercing: 50, Heat: 10 }, + 0, + "bullet", + HELMET_ARMOR, + ); + const xenoResult = applyArmorMitigation( + { Piercing: 50, Heat: 10 }, + 0, + "bullet", + WARRIOR_ARMOR, + ); + + expect(marineResult.Heat).toBeCloseTo(10 / 1.4641, 6); + expect(xenoResult.Heat).toBe(10); + }); + + it("ignores armor piercing for a xeno target that is immune to it", () => { + const immuneTarget: XenoTargetArmor = { kind: "xeno", xenoArmor: 20, immuneToArmorPiercing: true }; + const withoutPiercing = applyArmorMitigation({ Piercing: 56 }, 0, "bullet", immuneTarget); + const withPiercing = applyArmorMitigation({ Piercing: 56 }, 5, "bullet", immuneTarget); + expect(withPiercing.Piercing).toBeCloseTo(withoutPiercing.Piercing, 10); + }); + + it("uses melee armor instead of bullet armor for melee weapons", () => { + const lopsided: MarineArmor = { kind: "marine", bullet: 0, melee: 20, bio: 0 }; + const bulletHit = applyArmorMitigation({ Piercing: 56 }, 0, "bullet", lopsided); + const meleeHit = applyArmorMitigation({ Piercing: 56 }, 0, "melee", lopsided); + expect(bulletHit.Piercing).toBe(56); + // Stage 1: 56 / 1.1^4 = 38.2488. That total is below armor*2 (40), so the + // stage-2 clamp also fires: (38.2488*4 - 20) / 4 = 38.2488 - 5 = 33.2488. + expect(meleeHit.Piercing).toBeCloseTo(56 / 1.4641 - 5, 6); + }); + + it("returns damage unchanged when armor is zero or negative after piercing", () => { + const noArmor: MarineArmor = { kind: "marine", bullet: 5, melee: 0, bio: 0 }; + const result = applyArmorMitigation({ Piercing: 56 }, 10, "bullet", noArmor); + expect(result.Piercing).toBe(56); + }); +}); + +describe("computeHitDamage", () => { + it("combines falloff and armor in one call", () => { + const result = computeHitDamage({ + effectiveDamage: { Piercing: 56 }, + distance: 5, + falloffThresholds: AR10_THRESHOLDS, + weaponFalloffMultiplier: 0, + armorPiercing: 5, + weaponCategory: "bullet", + target: HELMET_ARMOR, + }); + expect(result.preArmorTotal).toBe(56); + expect(result.totalDamage).toBeCloseTo(56 / 1.331, 6); + }); +}); + +describe("hitsToKill", () => { + it("rounds up to a whole number of hits", () => { + expect(hitsToKill(30, { critical: 150, dead: 200 }, "dead")).toBe(7); + }); + + it("is Infinity when the target has no critical stage", () => { + expect(hitsToKill(30, { critical: null, dead: 35 }, "critical")).toBe(Infinity); + }); + + it("is Infinity for zero or negative per-hit damage", () => { + expect(hitsToKill(0, { critical: 150, dead: 200 }, "dead")).toBe(Infinity); + }); +}); + +describe("timeToKillSeconds", () => { + it("divides hits by the fire rate", () => { + expect(timeToKillSeconds(7, 2.86)).toBeCloseTo(2.4476, 3); + }); + + it("is Infinity when hits is already Infinity", () => { + expect(timeToKillSeconds(Infinity, 2.86)).toBe(Infinity); + }); +}); + +describe("ammoNeeded", () => { + it("rounds up to whole magazines when capacity is known", () => { + expect(ammoNeeded(7, 25)).toEqual({ shots: 7, magazines: 1 }); + expect(ammoNeeded(30, 25)).toEqual({ shots: 30, magazines: 2 }); + }); + + it("has no magazine count when capacity is unknown", () => { + expect(ammoNeeded(7, null)).toEqual({ shots: 7, magazines: null }); + expect(ammoNeeded(7, undefined)).toEqual({ shots: 7, magazines: null }); + }); +}); diff --git a/src/modules/damage/damageMath.ts b/src/modules/damage/damageMath.ts new file mode 100644 index 0000000..1405913 --- /dev/null +++ b/src/modules/damage/damageMath.ts @@ -0,0 +1,199 @@ +import type { MobThresholdPair } from "./mobTypes"; + +export type DamageTypeMap = Record; + +export type DamageFalloffThreshold = { + range: number; + falloff: number; + ignoreModifiers?: boolean; +}; + +export type WeaponCategory = "bullet" | "melee"; + +export type MarineArmor = { kind: "marine"; bullet: number; melee: number; bio: number }; +export type XenoTargetArmor = { + kind: "xeno"; + xenoArmor: number; + immuneToArmorPiercing: boolean; +}; +export type ArmorTarget = MarineArmor | XenoTargetArmor; + +// RMCArmorModifierComponent default; no prototype in the game data overrides it. +export const ARMOR_MODIFIER = 4; +// MobThresholdsComponent-independent floor: a falloff-heavy hit can never drop +// below this fraction of the pre-falloff damage, no matter how far past range. +export const MIN_REMAINING_DAMAGE_MULT = 0.05; + +export const BRUTE_DAMAGE_TYPES = ["Blunt", "Slash", "Piercing"] as const; +export const BURN_DAMAGE_TYPES = ["Heat", "Shock", "Cold", "Caustic"] as const; + +function sumDamage(damage: DamageTypeMap): number { + return Object.values(damage).reduce((sum, value) => sum + value, 0); +} + +export function falloffMultiplier( + originalTotalDamage: number, + distance: number, + thresholds: DamageFalloffThreshold[], + weaponFalloffMultiplier: number, +): number { + if (originalTotalDamage <= 0) return 1; + + const minDamage = originalTotalDamage * MIN_REMAINING_DAMAGE_MULT; + let currentTotal = originalTotalDamage; + let combined = 1; + + for (const threshold of thresholds) { + const pastEffectiveRange = distance - threshold.range; + if (pastEffectiveRange <= 0) continue; + if (currentTotal <= minDamage) break; + + const extraMult = threshold.ignoreModifiers ? 1 : weaponFalloffMultiplier; + const minMult = Math.min(minDamage / currentTotal, 1); + const rawMult = (currentTotal - pastEffectiveRange * threshold.falloff * extraMult) / currentTotal; + const mult = Math.min(Math.max(rawMult, minMult), 1); + + combined *= mult; + currentTotal *= mult; + } + + return combined; +} + +export function applyRangeFalloff( + damage: DamageTypeMap, + distance: number, + thresholds: DamageFalloffThreshold[], + weaponFalloffMultiplier: number, +): DamageTypeMap { + const mult = falloffMultiplier(sumDamage(damage), distance, thresholds, weaponFalloffMultiplier); + const result: DamageTypeMap = {}; + for (const [type, value] of Object.entries(damage)) { + result[type] = value * mult; + } + return result; +} + +// One CMArmorSystem.Resist() call. The stage-2 "weak hit" clamp compares against +// the running total across the WHOLE damage map (not just this group), so when +// marine targets run this twice (Brute then Burn) call order matters. +export function resistGroup( + damage: DamageTypeMap, + armorValue: number, + groupTypes: readonly string[], + armorModifier: number, +): DamageTypeMap { + const armor = Math.max(armorValue, 0); + if (armor <= 0) return { ...damage }; + + const resist = Math.pow(1.1, armor / 5); + const groupSet = new Set(groupTypes); + const result: DamageTypeMap = { ...damage }; + + for (const [type, amount] of Object.entries(result)) { + if (groupSet.has(type) && amount > 0) { + result[type] = amount / resist; + } + } + + const newTotal = sumDamage(result); + if (newTotal !== 0 && newTotal < armor * 2) { + const damageWithArmor = Math.max(0, newTotal * armorModifier - armor); + for (const [type, amount] of Object.entries(result)) { + if (groupSet.has(type) && amount > 0) { + result[type] = (amount * damageWithArmor) / (newTotal * armorModifier); + } + } + } + + return result; +} + +export function applyArmorMitigation( + damage: DamageTypeMap, + armorPiercing: number, + weaponCategory: WeaponCategory, + target: ArmorTarget, +): DamageTypeMap { + if (target.kind === "xeno") { + // Verified engine quirk: immuneToArmorPiercing only ever takes effect for + // xeno targets — a marine's worn-armor ImmuneToAP flag is never consulted + // through this code path, so it is intentionally not modeled for marines. + const piercing = target.immuneToArmorPiercing ? 0 : armorPiercing; + const armor = Math.max(target.xenoArmor - piercing, 0); + // Xenos only ever resist the Brute group; Burn-type damage (e.g. incendiary + // Heat) passes through xeno armor completely unmitigated. + return resistGroup(damage, armor, BRUTE_DAMAGE_TYPES, ARMOR_MODIFIER); + } + + const bruteSource = weaponCategory === "melee" ? target.melee : target.bullet; + const bruteArmor = Math.max(bruteSource - armorPiercing, 0); + const afterBrute = resistGroup(damage, bruteArmor, BRUTE_DAMAGE_TYPES, ARMOR_MODIFIER); + + const bioArmor = Math.max(target.bio - armorPiercing, 0); + return resistGroup(afterBrute, bioArmor, BURN_DAMAGE_TYPES, ARMOR_MODIFIER); +} + +export type HitDamageInput = { + effectiveDamage: DamageTypeMap; + distance: number; + falloffThresholds: DamageFalloffThreshold[]; + weaponFalloffMultiplier: number; + armorPiercing: number; + weaponCategory: WeaponCategory; + target: ArmorTarget; +}; + +export type HitDamageResult = { + perTypeDamage: DamageTypeMap; + totalDamage: number; + preArmorTotal: number; +}; + +export function computeHitDamage(input: HitDamageInput): HitDamageResult { + const afterFalloff = applyRangeFalloff( + input.effectiveDamage, + input.distance, + input.falloffThresholds, + input.weaponFalloffMultiplier, + ); + const preArmorTotal = sumDamage(afterFalloff); + const afterArmor = applyArmorMitigation( + afterFalloff, + input.armorPiercing, + input.weaponCategory, + input.target, + ); + return { + perTypeDamage: afterArmor, + totalDamage: sumDamage(afterArmor), + preArmorTotal, + }; +} + +export type KillCondition = "critical" | "dead"; + +export function hitsToKill( + perHitDamage: number, + thresholds: MobThresholdPair, + killAt: KillCondition, +): number { + const target = killAt === "critical" ? thresholds.critical : thresholds.dead; + if (target == null || perHitDamage <= 0) return Infinity; + return Math.ceil(target / perHitDamage); +} + +export function timeToKillSeconds(hits: number, shotsPerSecond: number): number { + if (!Number.isFinite(hits) || shotsPerSecond <= 0) return Infinity; + return hits / shotsPerSecond; +} + +export type AmmoNeeded = { shots: number; magazines: number | null }; + +export function ammoNeeded(shots: number, magazineCapacity: number | null | undefined): AmmoNeeded { + const magazines = + Number.isFinite(shots) && magazineCapacity != null && magazineCapacity > 0 + ? Math.ceil(shots / magazineCapacity) + : null; + return { shots, magazines }; +} diff --git a/src/modules/damage/mobCatalogStore.ts b/src/modules/damage/mobCatalogStore.ts new file mode 100644 index 0000000..7479cea --- /dev/null +++ b/src/modules/damage/mobCatalogStore.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from "react"; +import { MOBS_CATALOG_URL } from "./mobConfig"; +import type { MobCatalog } from "./mobTypes"; + +let catalogCache: MobCatalog | null = null; +let inflight: Promise | null = null; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function validateMobCatalog(value: unknown): MobCatalog { + if (!isRecord(value)) throw new Error("Каталог мобов имеет неверный формат"); + const catalog = value as Partial; + if (catalog.schemaVersion !== 1) throw new Error("Приложению требуется mob catalog schema 1"); + if (!isRecord(catalog.marine) || !isRecord(catalog.marine.thresholds) || !isRecord(catalog.xenoCastes)) { + throw new Error("В каталоге мобов отсутствуют обязательные блоки"); + } + return catalog as MobCatalog; +} + +function loadMobCatalog(force = false): Promise { + if (catalogCache && !force) return Promise.resolve(catalogCache); + if (inflight && !force) return inflight; + inflight = fetch(MOBS_CATALOG_URL, { cache: force ? "reload" : "default" }) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; + }) + .then(validateMobCatalog) + .then((catalog) => { + catalogCache = catalog; + return catalog; + }) + .finally(() => { + inflight = null; + }); + return inflight; +} + +export function useMobCatalog() { + const [mobCatalog, setMobCatalog] = useState(catalogCache); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(!catalogCache); + const [requestKey, setRequestKey] = useState(0); + + useEffect(() => { + let active = true; + loadMobCatalog(requestKey > 0) + .then((result) => { + if (active) setMobCatalog(result); + }) + .catch((reason: unknown) => { + if (active) setError(reason instanceof Error ? reason.message : "Неизвестная ошибка"); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { active = false; }; + }, [requestKey]); + + const retry = useCallback(() => { + setLoading(true); + setError(null); + setRequestKey((value) => value + 1); + }, []); + return { mobCatalog, error, loading, retry }; +} diff --git a/src/modules/damage/mobConfig.ts b/src/modules/damage/mobConfig.ts new file mode 100644 index 0000000..ea29cc2 --- /dev/null +++ b/src/modules/damage/mobConfig.ts @@ -0,0 +1,5 @@ +export const MOBS_DATA_ROOT = + import.meta.env.VITE_MOBS_DATA_ROOT + ?? "https://raw.githubusercontent.com/DeferW/ssmc-wiki-data/main/data/mobs/"; + +export const MOBS_CATALOG_URL = new URL("catalog.json", MOBS_DATA_ROOT).toString(); diff --git a/src/modules/damage/mobTypes.ts b/src/modules/damage/mobTypes.ts new file mode 100644 index 0000000..ea282b3 --- /dev/null +++ b/src/modules/damage/mobTypes.ts @@ -0,0 +1,37 @@ +export type MobThresholdPair = { + critical: number | null; + dead: number; +}; + +export type XenoArmorStats = { + xenoArmor: number; + frontalArmor: number; + sideArmor: number; + explosionArmor: number; + immuneToArmorPiercing: boolean; +}; + +export type XenoCaste = { + id: string; + name: string; + origin: string; + sourceFile: string; + parents: string[]; + thresholds: MobThresholdPair; + maturedThresholds: MobThresholdPair | null; + armor: XenoArmorStats; + sprite: string | null; +}; + +export type MobCatalog = { + schemaVersion: number; + source: string; + gameCommit: string; + locale: string; + marine: { + sourcePrototypeId: string; + thresholds: MobThresholdPair; + }; + xenoCastes: Record; + counts: { xenoCastes: number }; +}; diff --git a/src/modules/registry.tsx b/src/modules/registry.tsx index 3f51540..5f56864 100644 --- a/src/modules/registry.tsx +++ b/src/modules/registry.tsx @@ -1,6 +1,7 @@ import type { ComponentType } from "react"; import { EquipmentPage } from "./equipment/EquipmentPage"; import { ChemistryPage } from "./chemistry/ChemistryPage"; +import { DamagePage } from "./damage/DamagePage"; export type ModuleDefinition = { id: string; @@ -40,7 +41,8 @@ export const modules: ModuleDefinition[] = [ shortTitle: "Урон", summary: "Расчёт урона с учётом дистанции, брони и бронепробития.", code: "DMG-03", - status: "planned", + status: "active", + Component: DamagePage, }, { id: "compare", diff --git a/src/styles/damage.css b/src/styles/damage.css new file mode 100644 index 0000000..0f091d3 --- /dev/null +++ b/src/styles/damage.css @@ -0,0 +1,34 @@ +.damage-page { width: min(1680px, calc(100% - clamp(28px, 5vw, 88px))); margin: 0 auto; padding: 48px 0 90px; } +.damage-hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 32px; margin-bottom: 26px; } +.damage-hero h1 { margin: 10px 0 12px; color: var(--text-strong); font-size: clamp(2.2rem, 5vw, 4.6rem); line-height: .92; letter-spacing: -.045em; text-transform: uppercase; } +.damage-hero > div:first-child > p:last-child { max-width: 760px; margin: 0; color: var(--muted); line-height: 1.6; } + +.damage-layout { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: clamp(16px, 2vw, 28px); align-items: start; } +.damage-weapon-column h2 { margin: 0 0 12px; color: var(--text); font-size: .86rem; text-transform: uppercase; } +.damage-detail-column { position: sticky; top: 82px; min-height: 200px; padding: 20px; border: 1px solid var(--line); background: var(--surface); } +.damage-detail-column h3 { margin: 18px 0 10px; color: var(--text); font-size: .78rem; text-transform: uppercase; } + +.weapon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; max-height: calc(100vh - 220px); overflow: auto; padding-right: 4px; } +.weapon-card { display: flex; height: 168px; flex-direction: column; align-items: flex-start; overflow: hidden; padding: 10px; border: 1px solid var(--line); background: linear-gradient(160deg, var(--surface-raised), var(--surface)); color: inherit; text-align: left; cursor: pointer; } +.weapon-card:hover, .weapon-card.is-selected { border-color: var(--line-strong); box-shadow: inset 0 0 24px rgba(86, 144, 105, .055), 0 0 0 1px rgba(114, 216, 149, .04); } +.weapon-card.is-selected { box-shadow: inset 3px 0 0 var(--accent), inset 0 0 24px rgba(86, 144, 105, .055); } +.weapon-card strong { display: -webkit-box; margin-top: 8px; overflow: hidden; color: var(--text); font-size: .72rem; line-height: 1.3; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.weapon-card .sprite-frame, .weapon-card .sprite-placeholder { width: 100%; height: 88px; margin-top: 0; } +.weapon-card .sprite-frame img { width: 70px; height: 70px; } + +.damage-weapon-summary { display: flex; align-items: center; gap: 14px; padding-bottom: 16px; border-bottom: 1px solid var(--line); } +.damage-weapon-summary .sprite-frame, .damage-weapon-summary .sprite-placeholder { flex: 0 0 64px; width: 64px; height: 64px; margin: 0; } +.damage-weapon-summary .sprite-frame img { width: 52px; height: 52px; } +.damage-weapon-summary div { display: grid; gap: 4px; min-width: 0; } +.damage-weapon-summary strong { color: var(--text-strong); font-size: 1rem; } +.damage-weapon-summary small { color: var(--muted); font-size: .65rem; } + +.ammo-picker { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; } +.ammo-chip { display: flex; flex-direction: column; gap: 3px; padding: 8px 10px; border: 1px solid var(--line); background: var(--surface-raised); color: var(--text); font-size: .68rem; text-align: left; cursor: pointer; } +.ammo-chip:hover { border-color: var(--line-strong); } +.ammo-chip.is-selected { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); color: var(--text-strong); } +.ammo-chip.is-ap { box-shadow: inset 2px 0 0 var(--warning-line); } +.ammo-chip.is-ap.is-selected { box-shadow: inset 2px 0 0 var(--warning), inset 0 0 0 1px var(--accent); } +.ammo-chip small { color: var(--muted); font-size: .6rem; } + +.damage-todo-note { margin-top: 18px; padding-top: 14px; border-top: 1px dashed var(--line); }