From 0e89ee7bb7e1d27f6d7c06b83f9531de0311e911 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:58:50 +0200 Subject: [PATCH] Comp Analyzer (#2759) Co-authored-by: Claude Opus 4.5 --- app/components/layout/nav-items.ts | 5 + app/features/build-analyzer/analyzer-types.ts | 34 ++ app/features/build-analyzer/core/stats.ts | 2 +- .../build-analyzer/core/weapon-params.ts | 326 ++++++++++++++++ .../comp-analyzer/comp-analyzer-constants.ts | 78 ++++ .../comp-analyzer/comp-analyzer-hooks.ts | 74 ++++ .../comp-analyzer/comp-analyzer-types.ts | 30 ++ .../components/DamageComboBar.module.css | 304 +++++++++++++++ .../components/DamageComboBar.tsx | 361 ++++++++++++++++++ .../components/RangeVisualization.module.css | 112 ++++++ .../components/RangeVisualization.tsx | 355 +++++++++++++++++ .../SelectedWeapons.browser.test.tsx | 150 ++++++++ .../components/SelectedWeapons.module.css | 122 ++++++ .../components/SelectedWeapons.tsx | 93 +++++ .../components/WeaponCategories.module.css | 52 +++ .../components/WeaponCategories.tsx | 126 ++++++ .../components/WeaponGrid.browser.test.tsx | 135 +++++++ .../components/WeaponGrid.module.css | 104 +++++ .../comp-analyzer/components/WeaponGrid.tsx | 204 ++++++++++ .../core/damage-combinations.test.ts | 311 +++++++++++++++ .../comp-analyzer/core/damage-combinations.ts | 316 +++++++++++++++ .../comp-analyzer/core/weapon-range.ts | 192 ++++++++++ .../routes/comp-analyzer.all-ranges.tsx | 36 ++ .../comp-analyzer/routes/comp-analyzer.tsx | 95 +++++ app/features/info/routes/contributions.tsx | 1 + app/features/links/links.json | 5 + app/routes.ts | 6 + app/styles/vars.css | 2 + app/utils/urls.ts | 1 + e2e/comp-analyzer.spec.ts | 232 +++++++++++ locales/da/analyzer.json | 32 +- locales/da/common.json | 1 + locales/da/contributions.json | 1 + locales/de/analyzer.json | 32 +- locales/de/common.json | 1 + locales/de/contributions.json | 1 + locales/en/analyzer.json | 32 +- locales/en/common.json | 1 + locales/en/contributions.json | 1 + locales/es-ES/analyzer.json | 33 +- locales/es-ES/common.json | 1 + locales/es-ES/contributions.json | 1 + locales/es-US/analyzer.json | 33 +- locales/es-US/common.json | 1 + locales/es-US/contributions.json | 1 + locales/fr-CA/analyzer.json | 33 +- locales/fr-CA/common.json | 1 + locales/fr-CA/contributions.json | 1 + locales/fr-EU/analyzer.json | 33 +- locales/fr-EU/common.json | 1 + locales/fr-EU/contributions.json | 1 + locales/he/analyzer.json | 33 +- locales/he/common.json | 1 + locales/he/contributions.json | 1 + locales/it/analyzer.json | 33 +- locales/it/common.json | 1 + locales/it/contributions.json | 1 + locales/ja/analyzer.json | 30 +- locales/ja/common.json | 1 + locales/ja/contributions.json | 1 + locales/ko/analyzer.json | 30 +- locales/ko/common.json | 1 + locales/ko/contributions.json | 1 + locales/nl/analyzer.json | 32 +- locales/nl/common.json | 1 + locales/nl/contributions.json | 1 + locales/pl/analyzer.json | 34 +- locales/pl/common.json | 1 + locales/pl/contributions.json | 1 + locales/pt-BR/analyzer.json | 33 +- locales/pt-BR/common.json | 1 + locales/pt-BR/contributions.json | 1 + locales/ru/analyzer.json | 34 +- locales/ru/common.json | 1 + locales/ru/contributions.json | 1 + locales/zh/analyzer.json | 30 +- locales/zh/common.json | 1 + locales/zh/contributions.json | 1 + .../img/layout/comp-analyzer.avif | Bin 0 -> 5193 bytes .../img/layout/comp-analyzer.png | Bin 0 -> 54777 bytes scripts/create-analyzer-json.ts | 149 ++++++++ 81 files changed, 4545 insertions(+), 17 deletions(-) create mode 100644 app/features/comp-analyzer/comp-analyzer-constants.ts create mode 100644 app/features/comp-analyzer/comp-analyzer-hooks.ts create mode 100644 app/features/comp-analyzer/comp-analyzer-types.ts create mode 100644 app/features/comp-analyzer/components/DamageComboBar.module.css create mode 100644 app/features/comp-analyzer/components/DamageComboBar.tsx create mode 100644 app/features/comp-analyzer/components/RangeVisualization.module.css create mode 100644 app/features/comp-analyzer/components/RangeVisualization.tsx create mode 100644 app/features/comp-analyzer/components/SelectedWeapons.browser.test.tsx create mode 100644 app/features/comp-analyzer/components/SelectedWeapons.module.css create mode 100644 app/features/comp-analyzer/components/SelectedWeapons.tsx create mode 100644 app/features/comp-analyzer/components/WeaponCategories.module.css create mode 100644 app/features/comp-analyzer/components/WeaponCategories.tsx create mode 100644 app/features/comp-analyzer/components/WeaponGrid.browser.test.tsx create mode 100644 app/features/comp-analyzer/components/WeaponGrid.module.css create mode 100644 app/features/comp-analyzer/components/WeaponGrid.tsx create mode 100644 app/features/comp-analyzer/core/damage-combinations.test.ts create mode 100644 app/features/comp-analyzer/core/damage-combinations.ts create mode 100644 app/features/comp-analyzer/core/weapon-range.ts create mode 100644 app/features/comp-analyzer/routes/comp-analyzer.all-ranges.tsx create mode 100644 app/features/comp-analyzer/routes/comp-analyzer.tsx create mode 100644 e2e/comp-analyzer.spec.ts create mode 100644 public/static-assets/img/layout/comp-analyzer.avif create mode 100644 public/static-assets/img/layout/comp-analyzer.png diff --git a/app/components/layout/nav-items.ts b/app/components/layout/nav-items.ts index 93e59b5af..e217b9add 100644 --- a/app/components/layout/nav-items.ts +++ b/app/components/layout/nav-items.ts @@ -21,6 +21,11 @@ export const navItems = [ url: "analyzer", prefetch: true, }, + { + name: "comp-analyzer", + url: "comp-analyzer", + prefetch: true, + }, { name: "builds", url: "builds", diff --git a/app/features/build-analyzer/analyzer-types.ts b/app/features/build-analyzer/analyzer-types.ts index 4bdad60e1..20acdf4eb 100644 --- a/app/features/build-analyzer/analyzer-types.ts +++ b/app/features/build-analyzer/analyzer-types.ts @@ -100,6 +100,40 @@ export interface BaseWeaponStats { //InkConsumeMidCharge_ChargeParam?: number; // SpeedInkConsumeMax_WeaponRollParam?: number; // SpeedInkConsumeMin_WeaponRollParam?: number; + + // Range parameters for shooters/blasters/sloshers/splatlings/dualies + /** Initial bullet velocity */ + Range_SpawnSpeed?: number; + /** Velocity cap after straight phase */ + Range_GoStraightStateEndMaxSpeed?: number; + /** Frames in straight phase */ + Range_GoStraightToBrakeStateFrame?: number; + /** Gravity constant (typically 0.016) */ + Range_FreeGravity?: number; + /** Air resistance during free phase (rollers only, typically 0.1) */ + Range_FreeAirResist?: number; + /** Velocity multiplier (typically 2.0) */ + Range_ZRate?: number; + /** Air resistance during brake phase (typically 0.36) */ + Range_BrakeAirResist?: number; + /** Gravity during brake phase (typically 0.07) */ + Range_BrakeGravity?: number; + /** Frames in brake phase (typically 4) */ + Range_BrakeToFreeStateFrame?: number; + /** Max frames before projectile disappears (Splatanas only) */ + Range_BurstFrame?: number; + + // Range parameters for chargers (direct distance values) + /** Charger full charge range */ + DistanceFullCharge?: number; + /** Charger max charge range */ + DistanceMaxCharge?: number; + /** Charger min charge range */ + DistanceMinCharge?: number; + + // Blaster specific + /** Blaster explosion radius */ + BlastRadius?: number; } export interface WeaponKit { diff --git a/app/features/build-analyzer/core/stats.ts b/app/features/build-analyzer/core/stats.ts index a70a12c34..9e31953d9 100644 --- a/app/features/build-analyzer/core/stats.ts +++ b/app/features/build-analyzer/core/stats.ts @@ -727,7 +727,7 @@ function subWeaponIdToEffectKey( } } -function subWeaponDamageValue({ +export function subWeaponDamageValue({ baseValue, subWeaponId, abilityPoints, diff --git a/app/features/build-analyzer/core/weapon-params.ts b/app/features/build-analyzer/core/weapon-params.ts index d0b08f508..6d071c694 100644 --- a/app/features/build-analyzer/core/weapon-params.ts +++ b/app/features/build-analyzer/core/weapon-params.ts @@ -1,6 +1,11 @@ export const weaponParams = { baseWeaponStats: { "0": { + Range_SpawnSpeed: 2.06, + Range_GoStraightStateEndMaxSpeed: 1.835, + Range_GoStraightToBrakeStateFrame: 2, + Range_FreeGravity: 0.016, + Range_ZRate: 2, WeaponSpeedType: "Fast", MoveSpeed: 0.08, DamageParam_ValueMax: 380, @@ -11,6 +16,11 @@ export const weaponParams = { InkConsume: 0.008, }, "10": { + Range_SpawnSpeed: 2.266, + Range_GoStraightStateEndMaxSpeed: 1.9513, + Range_GoStraightToBrakeStateFrame: 3, + Range_FreeGravity: 0.016, + Range_ZRate: 2, WeaponSpeedType: "Fast", MoveSpeed: 0.076, DamageParam_ValueMax: 280, @@ -21,6 +31,11 @@ export const weaponParams = { InkConsume: 0.0043, }, "20": { + Range_SpawnSpeed: 4.0376, + Range_GoStraightStateEndMaxSpeed: 1.6289, + Range_GoStraightToBrakeStateFrame: 2, + Range_FreeGravity: 0.016, + Range_ZRate: 2, WeaponSpeedType: "Fast", MoveSpeed: 0.072, DamageParam_ValueMax: 280, @@ -30,6 +45,11 @@ export const weaponParams = { InkConsume: 0.008, }, "30": { + Range_SpawnSpeed: 2.266, + Range_GoStraightStateEndMaxSpeed: 1.9513, + Range_GoStraightToBrakeStateFrame: 3, + Range_FreeGravity: 0.016, + Range_ZRate: 2, WeaponSpeedType: "Fast", MoveSpeed: 0.072, DamageParam_ValueMax: 240, @@ -40,6 +60,11 @@ export const weaponParams = { InkConsume: 0.005, }, "40": { + Range_SpawnSpeed: 2.266, + Range_GoStraightStateEndMaxSpeed: 1.493, + Range_GoStraightToBrakeStateFrame: 4, + Range_FreeGravity: 0.016, + Range_ZRate: 2, MoveSpeed: 0.072, DamageParam_ValueMax: 360, DamageParam_ValueMin: 180, @@ -48,6 +73,11 @@ export const weaponParams = { InkConsume: 0.0092, }, "50": { + Range_SpawnSpeed: 3.06, + Range_GoStraightStateEndMaxSpeed: 1.667, + Range_GoStraightToBrakeStateFrame: 3, + Range_FreeGravity: 0.016, + Range_ZRate: 2, MoveSpeed: 0.06, DamageParam_ValueMax: 520, DamageParam_ValueMin: 300, @@ -56,6 +86,11 @@ export const weaponParams = { InkConsume: 0.015, }, "60": { + Range_SpawnSpeed: 2.2, + Range_GoStraightStateEndMaxSpeed: 1.4495, + Range_GoStraightToBrakeStateFrame: 4, + Range_FreeGravity: 0.016, + Range_ZRate: 2, WeaponSpeedType: "Fast", MoveSpeed: 0.088, DamageParam_ValueMax: 300, @@ -71,6 +106,11 @@ export const weaponParams = { Mid: 0.7, }, }, + Range_SpawnSpeed: 3.833333, + Range_GoStraightStateEndMaxSpeed: 2.2698, + Range_GoStraightToBrakeStateFrame: 3, + Range_FreeGravity: 0.016, + Range_ZRate: 2, MoveSpeed: 0.055, DamageParam_ValueMax: 450, DamageParam_ValueMin: 225, @@ -79,6 +119,11 @@ export const weaponParams = { InkConsume: 0.02, }, "80": { + Range_SpawnSpeed: 2.45, + Range_GoStraightStateEndMaxSpeed: 2.377, + Range_GoStraightToBrakeStateFrame: 5, + Range_FreeGravity: 0.016, + Range_ZRate: 2, MoveSpeed: 0.04, DamageParam_ValueMax: 620, DamageParam_ValueMin: 350, @@ -87,6 +132,11 @@ export const weaponParams = { InkConsume: 0.023, }, "90": { + Range_SpawnSpeed: 3.36, + Range_GoStraightStateEndMaxSpeed: 2.232, + Range_GoStraightToBrakeStateFrame: 5, + Range_FreeGravity: 0.016, + Range_ZRate: 2, MoveSpeed: 0.06, DamageParam_ValueMax: 320, DamageParam_ValueMin: 160, @@ -95,6 +145,11 @@ export const weaponParams = { InkConsume: 0.016, }, "100": { + Range_SpawnSpeed: 2.2, + Range_GoStraightStateEndMaxSpeed: 2.2698, + Range_GoStraightToBrakeStateFrame: 5, + Range_FreeGravity: 0.016, + Range_ZRate: 2, MoveSpeed: 0.079, DamageParam_ValueMax: 240, DamageParam_ValueMin: 120, @@ -112,6 +167,12 @@ export const weaponParams = { Mid: 0.625, }, }, + Range_SpawnSpeed: 0.87, + Range_GoStraightStateEndMaxSpeed: 0.8484, + Range_GoStraightToBrakeStateFrame: 7, + Range_FreeGravity: 0.016, + Range_ZRate: 2, + BlastRadius: 3.8, WeaponSpeedType: "Fast", MoveSpeed: 0.05, DamageParam_ValueDirect: 1250, @@ -136,6 +197,12 @@ export const weaponParams = { Mid: 0.5, }, }, + Range_SpawnSpeed: 0.945, + Range_GoStraightStateEndMaxSpeed: 0.9131, + Range_GoStraightToBrakeStateFrame: 9, + Range_FreeGravity: 0.016, + Range_ZRate: 2, + BlastRadius: 3.3, MoveSpeed: 0.045, DamageParam_ValueDirect: 1250, BlastParam_DistanceDamage: [ @@ -159,6 +226,12 @@ export const weaponParams = { Mid: 0.5, }, }, + Range_SpawnSpeed: 1.08, + Range_GoStraightStateEndMaxSpeed: 1.0073, + Range_GoStraightToBrakeStateFrame: 11, + Range_FreeGravity: 0.016, + Range_ZRate: 2, + BlastRadius: 3.3, MoveSpeed: 0.04, DamageParam_ValueDirect: 1250, BlastParam_DistanceDamage: [ @@ -182,6 +255,12 @@ export const weaponParams = { Mid: 0.5, }, }, + Range_SpawnSpeed: 0.935, + Range_GoStraightStateEndMaxSpeed: 0.92715, + Range_GoStraightToBrakeStateFrame: 8, + Range_FreeGravity: 0.016, + Range_ZRate: 2, + BlastRadius: 4, WeaponSpeedType: "Fast", MoveSpeed: 0.068, DamageParam_ValueDirect: 600, @@ -206,6 +285,12 @@ export const weaponParams = { Mid: 0.5, }, }, + Range_SpawnSpeed: 1.2, + Range_GoStraightStateEndMaxSpeed: 1.13305, + Range_GoStraightToBrakeStateFrame: 11, + Range_FreeGravity: 0.016, + Range_ZRate: 2, + BlastRadius: 3.3, MoveSpeed: 0.055, DamageParam_ValueDirect: 850, BlastParam_DistanceDamage: [ @@ -229,6 +314,12 @@ export const weaponParams = { Mid: 0.5, }, }, + Range_SpawnSpeed: 1.4, + Range_GoStraightStateEndMaxSpeed: 1.3426, + Range_GoStraightToBrakeStateFrame: 11, + Range_FreeGravity: 0.016, + Range_ZRate: 2, + BlastRadius: 3.3, MoveSpeed: 0.05, DamageParam_ValueDirect: 850, BlastParam_DistanceDamage: [ @@ -252,6 +343,12 @@ export const weaponParams = { Mid: 0.625, }, }, + Range_SpawnSpeed: 1.7, + Range_GoStraightStateEndMaxSpeed: 1.65, + Range_GoStraightToBrakeStateFrame: 7, + Range_FreeGravity: 0.016, + Range_ZRate: 2, + BlastRadius: 2, MoveSpeed: 0.04, DamageParam_ValueDirect: 1250, BlastParam_DistanceDamage: [ @@ -270,6 +367,10 @@ export const weaponParams = { InkConsume: 0.095, }, "300": { + Range_SpawnSpeed: 2.75, + Range_GoStraightStateEndMaxSpeed: 1.568, + Range_GoStraightToBrakeStateFrame: 4, + Range_ZRate: 2, TripleShotSpanFrame: 8, MoveSpeed: 0.08, DamageParam_ValueMax: 310, @@ -286,6 +387,10 @@ export const weaponParams = { Mid: 0.7, }, }, + Range_SpawnSpeed: 2.875, + Range_GoStraightStateEndMaxSpeed: 2.2698, + Range_GoStraightToBrakeStateFrame: 4, + Range_ZRate: 2, TripleShotSpanFrame: 20, MoveSpeed: 0.06, DamageParam_ValueMax: 440, @@ -296,6 +401,11 @@ export const weaponParams = { InkConsume: 0.0208, }, "400": { + Range_SpawnSpeed: 3.05, + Range_GoStraightStateEndMaxSpeed: 2.8303, + Range_GoStraightToBrakeStateFrame: 4, + Range_FreeGravity: 0.016, + Range_ZRate: 2, MoveSpeed: 0.066, DamageParam_ValueMax: 380, DamageParam_ValueMin: 190, @@ -308,6 +418,11 @@ export const weaponParams = { InkConsume: 0.024, }, "1000": { + Range_SpawnSpeed: 1.5166, + Range_GoStraightToBrakeStateFrame: 6, + Range_FreeGravity: 0.04, + Range_FreeAirResist: 0.1, + Range_ZRate: 2, WeaponSpeedType: "Fast", BodyParam_Damage: 700, VerticalSwingUnitGroupParam_DamageParam_DamageMinValue: 350, @@ -317,6 +432,11 @@ export const weaponParams = { InkConsume_WeaponSwingParam: 0.0396, }, "1010": { + Range_SpawnSpeed: 1.8338, + Range_GoStraightToBrakeStateFrame: 6, + Range_FreeGravity: 0.04, + Range_FreeAirResist: 0.1, + Range_ZRate: 2, BodyParam_Damage: 1250, VerticalSwingUnitGroupParam_DamageParam_DamageMinValue: 400, VerticalSwingUnitGroupParam_DamageParam_DamageMaxValue: 1500, @@ -331,6 +451,11 @@ export const weaponParams = { Mid: 0.7, }, }, + Range_SpawnSpeed: 2.0289, + Range_GoStraightToBrakeStateFrame: 9, + Range_FreeGravity: 0.04, + Range_FreeAirResist: 0.1, + Range_ZRate: 2, WeaponSpeedType: "Slow", BodyParam_Damage: 1250, VerticalSwingUnitGroupParam_DamageParam_DamageMinValue: 400, @@ -340,6 +465,11 @@ export const weaponParams = { InkConsume_WeaponSwingParam: 0.18, }, "1030": { + Range_SpawnSpeed: 1.7026, + Range_GoStraightToBrakeStateFrame: 9, + Range_FreeGravity: 0.04, + Range_FreeAirResist: 0.1, + Range_ZRate: 2, BodyParam_Damage: 1250, VerticalSwingUnitGroupParam_DamageParam_DamageMinValue: 400, VerticalSwingUnitGroupParam_DamageParam_DamageMaxValue: 1500, @@ -349,6 +479,11 @@ export const weaponParams = { InkConsume_WeaponWideSwingParam: 0.08, }, "1040": { + Range_SpawnSpeed: 2.2749, + Range_GoStraightToBrakeStateFrame: 6, + Range_FreeGravity: 0.04, + Range_FreeAirResist: 0.1, + Range_ZRate: 2, BodyParam_Damage: 700, VerticalSwingUnitGroupParam_DamageParam_DamageMinValue: 400, VerticalSwingUnitGroupParam_DamageParam_DamageMaxValue: 1200, @@ -357,6 +492,9 @@ export const weaponParams = { InkConsume_WeaponSwingParam: 0.09, }, "1100": { + Range_SpawnSpeed: 0.72, + Range_GoStraightToBrakeStateFrame: 7, + Range_ZRate: 2, WeaponSpeedType: "Fast", BodyParam_Damage: 200, SwingUnitGroupParam_DamageParam_DamageMinValue: 150, @@ -364,18 +502,27 @@ export const weaponParams = { InkConsume_WeaponSwingParam: 0.02, }, "1110": { + Range_SpawnSpeed: 0.84, + Range_GoStraightToBrakeStateFrame: 8, + Range_ZRate: 2, BodyParam_Damage: 250, SwingUnitGroupParam_DamageParam_DamageMinValue: 200, SwingUnitGroupParam_DamageParam_DamageMaxValue: 400, InkConsume_WeaponSwingParam: 0.03, }, "1120": { + Range_SpawnSpeed: 1.05, + Range_GoStraightToBrakeStateFrame: 9, + Range_ZRate: 2, BodyParam_Damage: 300, SwingUnitGroupParam_DamageParam_DamageMinValue: 310, SwingUnitGroupParam_DamageParam_DamageMaxValue: 600, InkConsume_WeaponSwingParam: 0.048, }, "2000": { + DistanceFullCharge: 16.765, + DistanceMaxCharge: 16.765, + DistanceMinCharge: 9.261, MoveSpeedFullCharge: 0.03, DamageParam_ValueFullCharge: 1400, DamageParam_ValueMaxCharge: 800, @@ -386,6 +533,9 @@ export const weaponParams = { InkConsumeMinCharge: 0.018667, }, "2010": { + DistanceFullCharge: 24.037, + DistanceMaxCharge: 24.037, + DistanceMinCharge: 9.033, MoveSpeedFullCharge: 0.02, DamageParam_ValueFullCharge: 1600, DamageParam_ValueMaxCharge: 800, @@ -395,6 +545,9 @@ export const weaponParams = { InkConsumeMinCharge: 0.0225, }, "2020": { + DistanceFullCharge: 26.037, + DistanceMaxCharge: 24.037, + DistanceMinCharge: 9.033, MoveSpeedFullCharge: 0.02, DamageParam_ValueFullCharge: 1600, DamageParam_ValueMaxCharge: 800, @@ -409,6 +562,9 @@ export const weaponParams = { Mid: 0.7, }, }, + DistanceFullCharge: 29.05, + DistanceMaxCharge: 29.05, + DistanceMinCharge: 9.045, WeaponSpeedType: "Slow", MoveSpeedFullCharge: 0.015, DamageParam_ValueFullCharge: 1800, @@ -426,6 +582,9 @@ export const weaponParams = { Mid: 0.7, }, }, + DistanceFullCharge: 31.05, + DistanceMaxCharge: 29.05, + DistanceMinCharge: 9.045, WeaponSpeedType: "Slow", MoveSpeedFullCharge: 0.015, DamageParam_ValueFullCharge: 1800, @@ -436,6 +595,9 @@ export const weaponParams = { InkConsumeMinCharge: 0.0225, }, "2050": { + DistanceFullCharge: 19.564, + DistanceMaxCharge: 19.564, + DistanceMinCharge: 19.564, WeaponSpeedType: "Fast", MoveSpeedFullCharge: 0.06, DamageParam_ValueFullCharge: 850, @@ -446,6 +608,9 @@ export const weaponParams = { InkConsumeMinCharge: 0.028, }, "2060": { + DistanceFullCharge: 19.804, + DistanceMaxCharge: 18.804, + DistanceMinCharge: 12.602, MoveSpeedFullCharge: 0.03, DamageParam_ValueFullCharge: 1800, DamageParam_ValueMaxCharge: 1300, @@ -456,6 +621,9 @@ export const weaponParams = { InkConsumeMinCharge: 0.02, }, "2070": { + DistanceFullCharge: 26.037, + DistanceMaxCharge: 24.037, + DistanceMinCharge: 9.033, MoveSpeedFullCharge: 0.068, DamageParam_ValueFullCharge: 680, DamageParam_ValueMaxCharge: 680, @@ -466,6 +634,14 @@ export const weaponParams = { InkConsumeMinCharge: 0.0197, }, "3000": { + Range_SpawnSpeed: 1.22, + Range_GoStraightStateEndMaxSpeed: 10, + Range_GoStraightToBrakeStateFrame: 2, + Range_FreeGravity: 0.05, + Range_BrakeAirResist: 0.1, + Range_BrakeGravity: 0.04, + Range_BrakeToFreeStateFrame: 1, + Range_ZRate: 2, MoveSpeed: 0.04, DamageParam_ValueDirectMax: 700, DamageParam_ValueDirectMin: 500, @@ -473,6 +649,14 @@ export const weaponParams = { InkConsumeSlosher: 0.076, }, "3010": { + Range_SpawnSpeed: 1.2229, + Range_GoStraightStateEndMaxSpeed: 10, + Range_GoStraightToBrakeStateFrame: 3, + Range_FreeGravity: 0.05, + Range_BrakeAirResist: 0.1, + Range_BrakeGravity: 0.04, + Range_BrakeToFreeStateFrame: 1, + Range_ZRate: 2, WeaponSpeedType: "Fast", MoveSpeed: 0.066, DamageParam_ValueDirectMax: 620, @@ -481,6 +665,13 @@ export const weaponParams = { InkConsumeSlosher: 0.06, }, "3020": { + Range_SpawnSpeed: 1.4737, + Range_GoStraightStateEndMaxSpeed: 10, + Range_GoStraightToBrakeStateFrame: 5, + Range_FreeGravity: 0.06, + Range_BrakeAirResist: 0.1, + Range_BrakeGravity: 0.04, + Range_BrakeToFreeStateFrame: 1, MoveSpeed: 0.07, DamageParam_ValueDirectMax: 760, DamageParam_ValueDirectMin: 520, @@ -489,12 +680,28 @@ export const weaponParams = { InkConsumeSlosher: 0.092, }, "3030": { + Range_SpawnSpeed: 0.53982, + Range_GoStraightStateEndMaxSpeed: 10, + Range_GoStraightToBrakeStateFrame: 3, + Range_FreeGravity: 0.015, + Range_BrakeAirResist: 0.01, + Range_BrakeGravity: 0.01, + Range_BrakeToFreeStateFrame: 1, + Range_ZRate: 0.5, MoveSpeed: 0.05, DamageParam_ValueDirect: 300, InkRecoverStop: 40, InkConsumeSlosher: 0.08, }, "3040": { + Range_SpawnSpeed: 1.4105, + Range_GoStraightStateEndMaxSpeed: 10, + Range_GoStraightToBrakeStateFrame: 8, + Range_FreeGravity: 0.05, + Range_BrakeAirResist: 0.1, + Range_BrakeGravity: 0.04, + Range_BrakeToFreeStateFrame: 1, + Range_ZRate: 1, WeaponSpeedType: "Slow", MoveSpeed: 0.045, DamageParam_ValueDirect: 550, @@ -508,6 +715,14 @@ export const weaponParams = { InkConsumeSlosher: 0.117, }, "3050": { + Range_SpawnSpeed: 1.85, + Range_GoStraightStateEndMaxSpeed: 10, + Range_GoStraightToBrakeStateFrame: 2, + Range_FreeGravity: 0.05, + Range_BrakeAirResist: 0.1, + Range_BrakeGravity: 0.04, + Range_BrakeToFreeStateFrame: 1, + Range_ZRate: 2, MoveSpeed: 0.04, DamageParam_ValueDirectMax: 480, DamageParam_ValueDirectMin: 350, @@ -524,6 +739,9 @@ export const weaponParams = { Mid: 1.2, }, }, + Range_SpawnSpeed: 1.5, + Range_GoStraightStateEndMaxSpeed: 1.105, + Range_GoStraightToBrakeStateFrame: 8, MoveSpeed: 0.086, MoveSpeed_Charge: 0.072, DamageParam_ValueMax: 320, @@ -541,6 +759,9 @@ export const weaponParams = { Mid: 1.175, }, }, + Range_SpawnSpeed: 2.1, + Range_GoStraightStateEndMaxSpeed: 1.5105, + Range_GoStraightToBrakeStateFrame: 8, MoveSpeed: 0.07, MoveSpeed_Charge: 0.062, DamageParam_ValueMax: 300, @@ -563,6 +784,9 @@ export const weaponParams = { Mid: 1.175, }, }, + Range_SpawnSpeed: 2.4, + Range_GoStraightStateEndMaxSpeed: 1.94, + Range_GoStraightToBrakeStateFrame: 8, WeaponSpeedType: "Slow", MoveSpeed: 0.06, MoveSpeed_Charge: 0.044, @@ -582,6 +806,9 @@ export const weaponParams = { Mid: 1.125, }, }, + Range_SpawnSpeed: 1.1, + Range_GoStraightStateEndMaxSpeed: 1.027, + Range_GoStraightToBrakeStateFrame: 8, MoveSpeed: 0.086, MoveSpeed_Charge: 0.086, MoveSpeedVariable: 0.05, @@ -604,6 +831,9 @@ export const weaponParams = { Mid: 1.15, }, }, + Range_SpawnSpeed: 4.4333, + Range_GoStraightStateEndMaxSpeed: 1.8215, + Range_GoStraightToBrakeStateFrame: 3, MoveSpeed: 0.07, MoveSpeed_Charge: 0.04, DamageParam_ValueMax: 320, @@ -622,6 +852,9 @@ export const weaponParams = { Mid: 1.2, }, }, + Range_SpawnSpeed: 1.8, + Range_GoStraightStateEndMaxSpeed: 1.105, + Range_GoStraightToBrakeStateFrame: 8, MoveSpeed: 0.086, MoveSpeed_Charge: 0.072, DamageParam_ValueMax: 260, @@ -632,6 +865,11 @@ export const weaponParams = { InkConsumeFullChargeSplatling: 0.2, }, "5000": { + Range_SpawnSpeed: 1.8334, + Range_GoStraightStateEndMaxSpeed: 1.81641, + Range_GoStraightToBrakeStateFrame: 3, + Range_FreeGravity: 0.016, + Range_ZRate: 2, WeaponSpeedType: "Fast", MoveSpeed: 0.084, DamageParam_ValueMax: 360, @@ -642,6 +880,11 @@ export const weaponParams = { InkConsume_SideStepParam: 0.05, }, "5010": { + Range_SpawnSpeed: 2.37, + Range_GoStraightStateEndMaxSpeed: 2.3425, + Range_GoStraightToBrakeStateFrame: 3, + Range_FreeGravity: 0.016, + Range_ZRate: 2, MoveSpeed: 0.08, DamageParam_ValueMax: 300, DamageParam_ValueMin: 150, @@ -651,6 +894,11 @@ export const weaponParams = { InkConsume_SideStepParam: 0.07, }, "5020": { + Range_SpawnSpeed: 2.85, + Range_GoStraightStateEndMaxSpeed: 2.8115, + Range_GoStraightToBrakeStateFrame: 3, + Range_FreeGravity: 0.016, + Range_ZRate: 2, MoveSpeed: 0.06, DamageParam_ValueMax: 360, DamageParam_ValueMin: 180, @@ -662,6 +910,11 @@ export const weaponParams = { InkConsume_SideStepParam: 0.08, }, "5030": { + Range_SpawnSpeed: 2.3, + Range_GoStraightStateEndMaxSpeed: 2.2698, + Range_GoStraightToBrakeStateFrame: 5, + Range_FreeGravity: 0.016, + Range_ZRate: 2, MoveSpeed: 0.072, DamageParam_ValueMax: 280, DamageParam_ValueMin: 140, @@ -671,6 +924,11 @@ export const weaponParams = { InkConsume_SideStepParam: 0.08, }, "5040": { + Range_SpawnSpeed: 2.21, + Range_GoStraightStateEndMaxSpeed: 2.205, + Range_GoStraightToBrakeStateFrame: 4, + Range_FreeGravity: 0.016, + Range_ZRate: 2, MoveSpeed: 0.072, DamageParam_ValueMax: 280, DamageParam_ValueMin: 140, @@ -680,6 +938,11 @@ export const weaponParams = { InkConsume_SideStepParam: 0.03, }, "5050": { + Range_SpawnSpeed: 3.72, + Range_GoStraightStateEndMaxSpeed: 2.325, + Range_GoStraightToBrakeStateFrame: 4, + Range_FreeGravity: 0.016, + Range_ZRate: 2, MoveSpeed: 0.07, DamageParam_ValueMax: 255, DamageParam_ValueMin: 128, @@ -691,6 +954,10 @@ export const weaponParams = { InkConsume_SideStepParam: 0.1, }, "6000": { + Range_SpawnSpeed: 2.6, + Range_GoStraightStateEndMaxSpeed: 1.652, + Range_GoStraightToBrakeStateFrame: 3, + Range_ZRate: 2, MoveSpeed: 0.065, DamageParam_ValueMax: 810, CanopyHP: 5000, @@ -704,6 +971,10 @@ export const weaponParams = { Mid: 0.7, }, }, + Range_SpawnSpeed: 2.4, + Range_GoStraightStateEndMaxSpeed: 2.3135, + Range_GoStraightToBrakeStateFrame: 4, + Range_ZRate: 2, WeaponSpeedType: "Slow", MoveSpeed: 0.05, DamageParam_ValueMax: 1190, @@ -712,6 +983,10 @@ export const weaponParams = { InkConsume_WeaponShelterShotgunParam: 0.11, }, "6020": { + Range_SpawnSpeed: 2, + Range_GoStraightStateEndMaxSpeed: 1.9085, + Range_GoStraightToBrakeStateFrame: 4, + Range_ZRate: 2, WeaponSpeedType: "Fast", MoveSpeed: 0.072, DamageParam_ValueMax: 400, @@ -719,6 +994,10 @@ export const weaponParams = { InkConsume_WeaponShelterShotgunParam: 0.04, }, "6030": { + Range_SpawnSpeed: 3, + Range_GoStraightStateEndMaxSpeed: 2, + Range_GoStraightToBrakeStateFrame: 3, + Range_ZRate: 2, MoveSpeed: 0.058, DamageParam_ValueMax: 900, CanopyHP: 1500, @@ -726,6 +1005,14 @@ export const weaponParams = { InkConsume_WeaponShelterShotgunParam: 0.06, }, "7010": { + Range_SpawnSpeed: 3.85, + Range_GoStraightStateEndMaxSpeed: 10, + Range_GoStraightToBrakeStateFrame: 4, + Range_FreeGravity: 0.15, + Range_BrakeAirResist: 0.1, + Range_BrakeGravity: 0.04, + Range_BrakeToFreeStateFrame: 1, + BlastRadius: 2, MoveSpeedFullCharge: 0.068, DamageParam_ValueMax: 350, DamageParam_ValueMin: 300, @@ -739,6 +1026,13 @@ export const weaponParams = { InkConsumeFullCharge_ChargeParam: 0.085, }, "7020": { + Range_SpawnSpeed: 2.55, + Range_GoStraightStateEndMaxSpeed: 10, + Range_GoStraightToBrakeStateFrame: 4, + Range_FreeGravity: 0.15, + Range_BrakeAirResist: 0.1, + Range_BrakeGravity: 0.04, + Range_BrakeToFreeStateFrame: 1, WeaponSpeedType: "Fast", MoveSpeedFullCharge: 0.084, DamageParam_ValueMax: 450, @@ -748,6 +1042,14 @@ export const weaponParams = { InkConsumeFullCharge_ChargeParam: 0.065, }, "7030": { + Range_SpawnSpeed: 3.5, + Range_GoStraightStateEndMaxSpeed: 10, + Range_GoStraightToBrakeStateFrame: 4, + Range_FreeGravity: 0.15, + Range_BrakeAirResist: 0.1, + Range_BrakeGravity: 0.04, + Range_BrakeToFreeStateFrame: 1, + BlastRadius: 2, WeaponSpeedType: "Slow", MoveSpeedFullCharge: 0.04, DamageParam_ValueMax: 350, @@ -762,6 +1064,14 @@ export const weaponParams = { InkConsumeFullCharge_ChargeParam: 0.09, }, "8000": { + Range_SpawnSpeed: 0.8, + Range_GoStraightStateEndMaxSpeed: 10, + Range_GoStraightToBrakeStateFrame: 22, + Range_FreeGravity: 0.02, + Range_BrakeAirResist: 0, + Range_BrakeGravity: 0.0027, + Range_BrakeToFreeStateFrame: 0, + Range_BurstFrame: 26, DamageParam_SplatanaVerticalDirect: 1400, DamageParam_SplatanaVertical: 700, DamageParam_SplatanaHorizontalDirect: 550, @@ -770,6 +1080,14 @@ export const weaponParams = { InkConsumeFullCharge_ChargeParam: 0.117, }, "8010": { + Range_SpawnSpeed: 1.2, + Range_GoStraightStateEndMaxSpeed: 10, + Range_GoStraightToBrakeStateFrame: 10, + Range_FreeGravity: 0.1, + Range_BrakeAirResist: 0, + Range_BrakeGravity: 0.02, + Range_BrakeToFreeStateFrame: 0, + Range_BurstFrame: 14, WeaponSpeedType: "Fast", DamageParam_SplatanaVerticalDirect: 1200, DamageParam_SplatanaVertical: 600, @@ -779,6 +1097,14 @@ export const weaponParams = { InkConsumeFullCharge_ChargeParam: 0.06, }, "8020": { + Range_SpawnSpeed: 0.7, + Range_GoStraightStateEndMaxSpeed: 10, + Range_GoStraightToBrakeStateFrame: 21, + Range_FreeGravity: 0.1, + Range_BrakeAirResist: 0, + Range_BrakeGravity: 0.02, + Range_BrakeToFreeStateFrame: 0, + Range_BurstFrame: 21, DamageParam_SplatanaVerticalDirect: 1600, DamageParam_SplatanaVertical: 800, DamageParam_SplatanaHorizontalDirect: 700, diff --git a/app/features/comp-analyzer/comp-analyzer-constants.ts b/app/features/comp-analyzer/comp-analyzer-constants.ts new file mode 100644 index 000000000..1f85a86d7 --- /dev/null +++ b/app/features/comp-analyzer/comp-analyzer-constants.ts @@ -0,0 +1,78 @@ +import type { + SpecialWeaponId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; + +export const MAX_WEAPONS = 4; + +export const MAX_DAMAGE_TYPES_PER_COMBO = 3; + +export const MAX_REPEATS_PER_DAMAGE_TYPE = 2; + +export const MAX_COMBOS_DISPLAYED = 50; + +export const COMBO_DAMAGE_THRESHOLD = 80; + +export const SUB_WEAPON_CATEGORIES: Record = { + 0: "LETHAL", // Splat Bomb + 1: "LETHAL", // Suction Bomb + 2: "CHIP", // Burst Bomb + 3: "TURF", // Sprinkler + 4: "AREA_DENIAL", // Splash Wall + 5: "CHIP", // Fizzy Bomb + 6: "MOBILITY", // Curling Bomb + 7: "SEEKING", // Autobomb + 8: "MOBILITY", // Squid Beakon + 9: "TRACKING", // Point Sensor + 10: "AREA_DENIAL", // Ink Mine + 11: "AREA_DENIAL", // Toxic Mist + 12: "TRACKING", // Angle Shooter + 13: "SEEKING", // Torpedo +}; + +export const SPECIAL_WEAPON_CATEGORIES: Record< + SpecialWeaponId, + SpecialWeaponCategory +> = { + 1: "RANGED_BURST", // Trizooka + 2: "TEAM_SHIELD", // Big Bubbler + 3: "MELEE", // Zipcaster + 4: "GLOBAL_TRACKING", // Tenta Missiles + 5: "AREA_CONTROL", // Ink Storm + 6: "RANGED_BURST", // Booyah Bomb + 7: "AREA_CONTROL", // Wave Breaker + 8: "TEAM_SHIELD", // Ink Vac + 9: "GLOBAL_TRACKING", // Killer Wail 5.1 + 10: "RANGED_BURST", // Inkjet + 11: "RANGED_BURST", // Ultra Stamp + 12: "RANGED_BURST", // Crab Tank + 13: "MELEE", // Reefslider + 14: "AREA_CONTROL", // Triple Inkstrike + 15: "TEAM_BUFF", // Tacticooler + 16: "AREA_CONTROL", // Super Chump + 17: "MELEE", // Kraken Royale + 18: "MELEE", // Triple Splashdown + 19: "AREA_CONTROL", // Splattercolor Screen +}; + +export const SUB_CATEGORY_ORDER = [ + "LETHAL", + "CHIP", + "SEEKING", + "AREA_DENIAL", + "MOBILITY", + "TRACKING", + "TURF", +] as const; + +export const SPECIAL_CATEGORY_ORDER = [ + "MELEE", + "RANGED_BURST", + "GLOBAL_TRACKING", + "AREA_CONTROL", + "TEAM_SHIELD", + "TEAM_BUFF", +] as const; + +export type SubWeaponCategory = (typeof SUB_CATEGORY_ORDER)[number]; +export type SpecialWeaponCategory = (typeof SPECIAL_CATEGORY_ORDER)[number]; diff --git a/app/features/comp-analyzer/comp-analyzer-hooks.ts b/app/features/comp-analyzer/comp-analyzer-hooks.ts new file mode 100644 index 000000000..8fe01712e --- /dev/null +++ b/app/features/comp-analyzer/comp-analyzer-hooks.ts @@ -0,0 +1,74 @@ +import { MAX_AP } from "~/features/build-analyzer/analyzer-constants"; +import { useSearchParamStateEncoder } from "~/hooks/useSearchParamState"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; +import { MAX_WEAPONS } from "./comp-analyzer-constants"; +import type { CategorizationType } from "./comp-analyzer-types"; + +const CATEGORIZATION_TYPES: CategorizationType[] = [ + "category", + "sub", + "special", +]; + +export function useCategorization() { + return useSearchParamStateEncoder({ + defaultValue: "category", + name: "categorization", + revive: (value) => { + if (CATEGORIZATION_TYPES.includes(value as CategorizationType)) { + return value as CategorizationType; + } + return "category"; + }, + encode: (val) => val, + }); +} + +export function useSelectedWeapons() { + return useSearchParamStateEncoder({ + defaultValue: [], + name: "weapons", + revive: (value) => { + const ids = value + .split(",") + .map(Number) + .filter((id): id is MainWeaponId => + mainWeaponIds.includes(id as MainWeaponId), + ); + + return ids.slice(0, MAX_WEAPONS); + }, + encode: (val) => val.join(","), + }); +} + +export function useTargetSubDefenseAp() { + return useSearchParamStateEncoder({ + defaultValue: 0, + name: "subDef", + revive: (value) => { + const num = Number(value); + if (Number.isNaN(num) || num < 0 || num > MAX_AP) { + return 0; + } + return num; + }, + encode: (val) => String(val), + }); +} + +export function useTargetResAp() { + return useSearchParamStateEncoder({ + defaultValue: 0, + name: "res", + revive: (value) => { + const num = Number(value); + if (Number.isNaN(num) || num < 0 || num > MAX_AP) { + return 0; + } + return num; + }, + encode: (val) => String(val), + }); +} diff --git a/app/features/comp-analyzer/comp-analyzer-types.ts b/app/features/comp-analyzer/comp-analyzer-types.ts new file mode 100644 index 000000000..cfc231b6b --- /dev/null +++ b/app/features/comp-analyzer/comp-analyzer-types.ts @@ -0,0 +1,30 @@ +import type { DamageType } from "~/features/build-analyzer/analyzer-types"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; + +export type CategorizationType = "category" | "sub" | "special"; + +export interface DamageSegment { + weaponSlot: number; + weaponId: MainWeaponId; + damageType: DamageType; + damageValue: number; + isSubWeapon: boolean; + isSpecialWeapon: boolean; + count: number; +} + +export interface DamageCombo { + segments: DamageSegment[]; + totalDamage: number; + hitCount: number; +} + +export interface WeaponDamageSource { + weaponSlot: number; + weaponId: MainWeaponId; + damages: Array<{ + type: DamageType; + value: number; + weaponType: "MAIN" | "SUB" | "SPECIAL"; + }>; +} diff --git a/app/features/comp-analyzer/components/DamageComboBar.module.css b/app/features/comp-analyzer/components/DamageComboBar.module.css new file mode 100644 index 000000000..3d0c0cbbe --- /dev/null +++ b/app/features/comp-analyzer/components/DamageComboBar.module.css @@ -0,0 +1,304 @@ +.container { + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--rounded-sm); + background: var(--bg); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--s-2) var(--s-3); + background: var(--bg-lighter); + border: none; + border-radius: var(--rounded-sm); + cursor: pointer; + font-size: var(--fonts-sm); + font-weight: var(--semi-bold); + color: var(--text); + width: 100%; + text-align: left; +} + +.header:hover { + background: var(--bg-lightest); +} + +.headerTitle { + flex: 1; +} + +.collapseIcon { + font-size: var(--fonts-lg); + font-weight: var(--bold); + color: var(--text-lighter); + width: 20px; + text-align: center; +} + +.content { + display: flex; + flex-direction: column; + gap: var(--s-3); + padding: var(--s-3); +} + +.comboRow { + display: flex; + align-items: center; + gap: var(--s-4); +} + +.barSection { + position: relative; + display: flex; + flex: 1; + background-color: var(--bg-lightest); + border-radius: var(--rounded-xs); + overflow: hidden; +} + +.segmentWrapper { + display: flex; + flex-direction: column; + min-width: fit-content; +} + +.segment { + display: flex; + align-items: center; + justify-content: center; + gap: var(--s-1); + height: 36px; + padding: 0 var(--s-2); + color: var(--black-text); + font-weight: var(--semi-bold); +} + +.damageTypeLabel { + font-size: var(--fonts-xxxs); + color: var(--text-lighter); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: var(--s-0-5) var(--s-0-5); + background: none; + border: 1px solid transparent; + border-radius: var(--rounded-xs); + cursor: pointer; +} + +.damageTypeLabel:hover { + text-decoration: line-through; +} + +.damageValue { + font-size: var(--fonts-sm); + font-weight: var(--bold); +} + +.segment[data-slot-color="yellow"] { + background-color: #f5d742; +} + +.segment[data-slot-color="pink"] { + background-color: #f5b8d0; +} + +.segment[data-slot-color="green"] { + background-color: #90e8a8; +} + +.segment[data-slot-color="blue"] { + background-color: #8cd4f5; +} + +.weaponIcon { + min-width: 24px; + min-height: 24px; +} + +.subSpecialWeaponIcon { + min-width: 18px; + min-height: 18px; +} + +.thresholdLine { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background-image: repeating-linear-gradient( + to bottom, + rgb(80 80 100) 0, + rgb(80 80 100) 5px, + transparent 5px, + transparent 10px + ); + pointer-events: none; + z-index: 1; +} + +.totalSection { + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 60px; + flex-shrink: 0; +} + +.totalDamage { + font-size: var(--fonts-xl); + font-weight: var(--bold); +} + +.hitCount { + font-size: var(--fonts-sm); + color: var(--text-lighter); +} + +.filteredItemsRow { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); + padding-bottom: var(--s-2); + border-bottom: 1px solid var(--border); + margin-bottom: var(--s-1); +} + +.filteredItem { + display: flex; + align-items: center; + gap: var(--s-1); + padding: var(--s-1) var(--s-2); + background: var(--bg-lighter); + border: 1px solid var(--border); + border-radius: var(--rounded-sm); + font-size: var(--fonts-xs); + color: var(--text-lighter); + cursor: pointer; + transition: background-color 0.15s ease; +} + +.filteredItem:hover { + background: var(--bg-lightest); +} + +.slidersContainer { + display: grid; + grid-template-columns: auto auto 1fr auto; + gap: var(--s-2) var(--s-2); + align-items: center; + padding-bottom: var(--s-3); + border-bottom: 1px solid var(--border); + margin-bottom: var(--s-1); +} + +@media (max-width: 640px) { + .slidersContainer { + grid-template-columns: auto 1fr auto; + } + + /* Row 1: icon 1 + label 1 */ + .slidersContainer > :nth-child(1) { + grid-row: 1; + grid-column: 1; + } + + .slidersContainer > :nth-child(2) { + grid-row: 1; + grid-column: 2 / -1; + } + + /* Row 2: slider 1 + value 1 */ + .slidersContainer > :nth-child(3) { + grid-row: 2; + grid-column: 1 / 3; + } + + .slidersContainer > :nth-child(4) { + grid-row: 2; + grid-column: 3; + } + + /* Row 3: icon 2 + label 2 */ + .slidersContainer > :nth-child(5) { + grid-row: 3; + grid-column: 1; + } + + .slidersContainer > :nth-child(6) { + grid-row: 3; + grid-column: 2 / -1; + } + + /* Row 4: slider 2 + value 2 */ + .slidersContainer > :nth-child(7) { + grid-row: 4; + grid-column: 1 / 3; + } + + .slidersContainer > :nth-child(8) { + grid-row: 4; + grid-column: 3; + } +} + +.resSliderLabel { + font-size: var(--fonts-sm); + color: var(--text-lighter); + white-space: nowrap; +} + +.resSlider { + min-width: 100%; + cursor: pointer; +} + +.resSliderValue { + font-size: var(--fonts-sm); + font-weight: var(--semi-bold); +} + +.inkTime { + font-size: var(--fonts-sm); + color: var(--theme-secondary); + font-weight: var(--semi-bold); +} + +.inkTimeSegment { + display: flex; + align-items: center; + justify-content: center; + gap: var(--s-1); + height: 36px; + padding: 0 var(--s-2); + background: repeating-linear-gradient( + -45deg, + var(--bg-lighter) 0, + var(--bg-lighter) 4px, + var(--bg-lightest) 4px, + var(--bg-lightest) 8px + ); + color: var(--text); + font-weight: var(--semi-bold); + min-width: fit-content; +} + +.inkTimeDamage { + font-size: var(--fonts-sm); + font-weight: var(--bold); + color: var(--text); +} + +.inkTimeLabel { + font-size: var(--fonts-xxs); + color: var(--theme-secondary); + text-align: center; + white-space: nowrap; + padding: var(--s-0-5) var(--s-0-5); + font-weight: var(--semi-bold); +} diff --git a/app/features/comp-analyzer/components/DamageComboBar.tsx b/app/features/comp-analyzer/components/DamageComboBar.tsx new file mode 100644 index 000000000..ac928c1b5 --- /dev/null +++ b/app/features/comp-analyzer/components/DamageComboBar.tsx @@ -0,0 +1,361 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Image, WeaponImage } from "~/components/Image"; +import { MAX_AP } from "~/features/build-analyzer/analyzer-constants"; +import { mainWeaponParams } from "~/features/build-analyzer/core/utils"; +import type { + MainWeaponId, + SpecialWeaponId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; +import { + abilityImageUrl, + specialWeaponImageUrl, + subWeaponImageUrl, +} from "~/utils/urls"; +import { useTargetResAp, useTargetSubDefenseAp } from "../comp-analyzer-hooks"; +import type { DamageCombo, DamageSegment } from "../comp-analyzer-types"; +import { + calculateDamageCombos, + calculateInkTimeToKill, + type ExcludedDamageKey, +} from "../core/damage-combinations"; +import styles from "./DamageComboBar.module.css"; + +function filterKeyToString(key: ExcludedDamageKey): string { + return `${key.weaponId}-${key.weaponType}-${key.damageType}`; +} + +function weaponTypeFromSegment( + segment: DamageSegment, +): "main" | "sub" | "special" { + if (segment.isSubWeapon) return "sub"; + if (segment.isSpecialWeapon) return "special"; + return "main"; +} + +const SLOT_COLORS = ["yellow", "pink", "green", "blue"] as const; +const LETHAL_DAMAGE = 100; + +interface DamageComboBarProps { + combo: DamageCombo; + inkTimeFrames: number | null; + onToggleFilter: (key: ExcludedDamageKey) => void; +} + +function DamageComboBar({ + combo, + inkTimeFrames, + onToggleFilter, +}: DamageComboBarProps) { + const { t } = useTranslation(["analyzer", "weapons"]); + + const thresholdPosition = (LETHAL_DAMAGE / combo.totalDamage) * 100; + const inkDamage = inkTimeFrames ? LETHAL_DAMAGE - combo.totalDamage : 0; + const totalWithInk = inkTimeFrames + ? LETHAL_DAMAGE + : Math.max(combo.totalDamage, LETHAL_DAMAGE); + + return ( +
+
+ {inkTimeFrames ? ( + + ) : null} + {combo.segments.map((segment, index) => ( + + ))} + {thresholdPosition < 100 && !inkTimeFrames ? ( +
+ ) : null} +
+
+ + {combo.totalDamage.toFixed(1)} + + + {t("analyzer:comp.hits", { count: combo.hitCount })} + +
+
+ ); +} + +interface SegmentBarProps { + segment: DamageSegment; + totalDamage: number; + damageTypeLabel: string; + onToggleFilter: (key: ExcludedDamageKey) => void; +} + +function SegmentBar({ + segment, + totalDamage, + damageTypeLabel, + onToggleFilter, +}: SegmentBarProps) { + const segmentDamage = segment.damageValue * segment.count; + const widthPercent = (segmentDamage / totalDamage) * 100; + const slotColor = SLOT_COLORS[segment.weaponSlot] ?? "yellow"; + const params = mainWeaponParams(segment.weaponId); + + const handleFilterClick = () => { + onToggleFilter({ + weaponId: segment.weaponId, + weaponType: weaponTypeFromSegment(segment), + damageType: segment.damageType, + }); + }; + + return ( +
+
+ + {segment.damageValue} +
+ +
+ ); +} + +interface InkTimeSegmentProps { + inkDamage: number; + totalDamage: number; + inkTimeFrames: number; +} + +function InkTimeSegment({ + inkDamage, + totalDamage, + inkTimeFrames, +}: InkTimeSegmentProps) { + const widthPercent = (inkDamage / totalDamage) * 100; + + return ( +
+
+ + {inkDamage.toFixed(1)} +
+
{inkTimeFrames}f
+
+ ); +} + +interface WeaponIconProps { + weaponId: MainWeaponId; + isSubWeapon: boolean; + isSpecialWeapon: boolean; + subWeaponId: SubWeaponId; + specialWeaponId: SpecialWeaponId; +} + +function WeaponIcon({ + weaponId, + isSubWeapon, + isSpecialWeapon, + subWeaponId, + specialWeaponId, +}: WeaponIconProps) { + if (isSubWeapon) { + return ( + + ); + } + + if (isSpecialWeapon) { + return ( + + ); + } + + return ( + + ); +} + +interface FilteredItemProps { + filterKey: ExcludedDamageKey; + onRestore: (key: ExcludedDamageKey) => void; +} + +function FilteredItem({ filterKey, onRestore }: FilteredItemProps) { + const { t } = useTranslation(["analyzer", "weapons"]); + const params = mainWeaponParams(filterKey.weaponId); + + return ( + + ); +} + +interface DamageComboListProps { + weaponIds: MainWeaponId[]; +} + +export function DamageComboList({ weaponIds }: DamageComboListProps) { + const { t } = useTranslation(["analyzer"]); + const [excludedKeys, setExcludedKeys] = useState([]); + const [targetResAp, setTargetResAp] = useTargetResAp(); + const [targetSubDefenseAp, setTargetSubDefenseAp] = useTargetSubDefenseAp(); + const [isCollapsed, setIsCollapsed] = useState(false); + + const combos = calculateDamageCombos( + weaponIds, + excludedKeys, + targetSubDefenseAp, + ); + + if (weaponIds.length < 2) { + return null; + } + + const handleToggleFilter = (key: ExcludedDamageKey) => { + const keyString = filterKeyToString(key); + const exists = excludedKeys.some((k) => filterKeyToString(k) === keyString); + + if (exists) { + setExcludedKeys( + excludedKeys.filter((k) => filterKeyToString(k) !== keyString), + ); + } else { + setExcludedKeys([...excludedKeys, key]); + } + }; + + return ( +
+ + {isCollapsed ? null : ( +
+
+ + + setTargetSubDefenseAp(Number(e.target.value))} + className={styles.resSlider} + /> + + {targetSubDefenseAp} AP + + + + setTargetResAp(Number(e.target.value))} + className={styles.resSlider} + /> + {targetResAp} AP +
+ {excludedKeys.length > 0 ? ( +
+ {excludedKeys.map((key) => ( + + ))} +
+ ) : null} + {combos.map((combo, index) => { + const inkTimeFrames = calculateInkTimeToKill( + combo.totalDamage, + targetResAp, + ); + if (combo.totalDamage < 100 && inkTimeFrames === null) { + return null; + } + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/app/features/comp-analyzer/components/RangeVisualization.module.css b/app/features/comp-analyzer/components/RangeVisualization.module.css new file mode 100644 index 000000000..4296e75e0 --- /dev/null +++ b/app/features/comp-analyzer/components/RangeVisualization.module.css @@ -0,0 +1,112 @@ +.container { + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--rounded-sm); + background: var(--bg); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--s-2) var(--s-3); + background: var(--bg-lighter); + border: none; + border-radius: var(--rounded-sm); + cursor: pointer; + font-size: var(--fonts-sm); + font-weight: var(--semi-bold); + color: var(--text); + width: 100%; + text-align: left; +} + +.header:hover { + background: var(--bg-lightest); +} + +.headerTitle { + flex: 1; +} + +.collapseIcon { + font-size: var(--fonts-lg); + font-weight: var(--bold); + color: var(--text-lighter); + width: 20px; + text-align: center; +} + +.content { + padding: var(--s-3); +} + +.benchmarkLegend { + display: flex; + gap: var(--s-4); + margin-bottom: var(--s-3); + padding-bottom: var(--s-2); + border-bottom: 1px solid var(--border); +} + +.legendItem { + display: flex; + align-items: center; + gap: var(--s-1); + font-size: var(--fonts-xs); + color: var(--text-lighter); +} + +.legendLine { + width: 16px; + height: 2px; + background-image: repeating-linear-gradient( + to right, + rgb(100 100 120) 0, + rgb(100 100 120) 4px, + transparent 4px, + transparent 8px + ); +} + +.legendLabel { + white-space: nowrap; +} + +.chartContainer { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.weaponLegend { + display: flex; + flex-wrap: wrap; + gap: var(--s-3); +} + +.weaponLegendItem { + display: flex; + align-items: center; + gap: var(--s-1); +} + +.weaponLegendColor { + width: 12px; + height: 3px; + border-radius: 2px; +} + +.weaponLegendRange { + font-size: var(--fonts-xs); + color: var(--text-lighter); + font-weight: var(--semi-bold); +} + +.chart { + width: 100%; + max-width: 100%; + height: auto; + min-height: 150px; +} diff --git a/app/features/comp-analyzer/components/RangeVisualization.tsx b/app/features/comp-analyzer/components/RangeVisualization.tsx new file mode 100644 index 000000000..a74b1c4dc --- /dev/null +++ b/app/features/comp-analyzer/components/RangeVisualization.tsx @@ -0,0 +1,355 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { WeaponImage } from "~/components/Image"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { + BENCHMARK_WEAPON_IDS, + getBenchmarkTrajectories, + getWeaponsWithRange, + type TrajectoryPoint, + type WeaponWithRange, +} from "../core/weapon-range"; +import styles from "./RangeVisualization.module.css"; + +const SLOT_COLORS = ["#f5d742", "#f5b8d0", "#90e8a8", "#8cd4f5"] as const; + +const BENCHMARK_COLORS = [ + "rgba(100, 100, 120, 0.5)", + "rgba(100, 100, 120, 0.5)", +] as const; + +interface RangeVisualizationProps { + weaponIds: MainWeaponId[]; +} + +export function RangeVisualization({ weaponIds }: RangeVisualizationProps) { + const { t } = useTranslation(["analyzer", "weapons"]); + const [isCollapsed, setIsCollapsed] = useState(false); + + const weaponsWithRange = getWeaponsWithRange(weaponIds); + const benchmarkTrajectories = getBenchmarkTrajectories(); + + if (weaponsWithRange.length === 0) { + return null; + } + + const maxRange = 32; + + const allYValues: number[] = []; + for (const weapon of weaponsWithRange) { + if (weapon.trajectory) { + for (const point of weapon.trajectory) { + if (point.y >= 0) { + allYValues.push(point.y); + } + } + } + } + + const minY = 0; + const maxY = Math.max(...allYValues, 2); + + return ( +
+ + {!isCollapsed ? ( +
+
+ {BENCHMARK_WEAPON_IDS.map((weaponId) => ( +
+ + + {t(`weapons:MAIN_${weaponId}`)} + +
+ ))} +
+ +
+ ) : null} +
+ ); +} + +interface TrajectoryChartProps { + weapons: WeaponWithRange[]; + benchmarkTrajectories: Array<{ + id: MainWeaponId; + range: number; + trajectory?: TrajectoryPoint[]; + }>; + maxRange: number; + minY: number; + maxY: number; + weaponIds: MainWeaponId[]; +} + +function TrajectoryChart({ + weapons, + benchmarkTrajectories, + maxRange, + minY, + maxY, + weaponIds, +}: TrajectoryChartProps) { + const chartWidth = 600; + const chartHeight = 200; + const padding = { top: 20, right: 60, bottom: 30, left: 50 }; + const innerWidth = chartWidth - padding.left - padding.right; + const innerHeight = chartHeight - padding.top - padding.bottom; + + const xScale = (z: number) => (z / maxRange) * innerWidth; + const yScale = (y: number) => + innerHeight - ((y - minY) / (maxY - minY)) * innerHeight; + + // Returns the point where trajectory hits y=0 (ground level). + // This differs from lastPoint which may be below ground or mid-air. + // Used for blast radius positioning since explosions happen at ground impact. + const getGroundIntersection = ( + trajectory: TrajectoryPoint[], + ): TrajectoryPoint | null => { + for (let i = 0; i < trajectory.length; i++) { + const point = trajectory[i]; + if (point.y < 0) { + const prevPoint = trajectory[i - 1]; + if (prevPoint && prevPoint.y >= 0) { + const t = prevPoint.y / (prevPoint.y - point.y); + const groundZ = prevPoint.z + t * (point.z - prevPoint.z); + return { z: groundZ, y: 0 }; + } + break; + } + } + const lastPoint = trajectory[trajectory.length - 1]; + return lastPoint && lastPoint.y >= 0 ? lastPoint : null; + }; + + const trajectoryToPath = (trajectory: TrajectoryPoint[]): string => { + if (trajectory.length === 0) return ""; + + const clampedPoints: TrajectoryPoint[] = []; + for (const point of trajectory) { + if (point.y >= 0) { + clampedPoints.push(point); + } else { + const prevPoint = clampedPoints[clampedPoints.length - 1]; + if (prevPoint && prevPoint.y > 0) { + const t = prevPoint.y / (prevPoint.y - point.y); + const groundZ = prevPoint.z + t * (point.z - prevPoint.z); + clampedPoints.push({ z: groundZ, y: 0 }); + } + break; + } + } + + if (clampedPoints.length === 0) return ""; + + const points = clampedPoints.map((p) => `${xScale(p.z)},${yScale(p.y)}`); + return `M ${points.join(" L ")}`; + }; + + const groundY = yScale(0); + + const xTicks = []; + const xStep = Math.ceil(maxRange / 5); + for (let x = 0; x <= maxRange; x += xStep) { + xTicks.push(x); + } + + const yTicks = []; + const yStep = Math.ceil((maxY - minY) / 4); + for (let y = Math.ceil(minY); y <= maxY; y += yStep) { + yTicks.push(y); + } + + return ( +
+
+ {weapons.map((weapon, index) => { + const slotIndex = weaponIds.indexOf(weapon.weaponId); + const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length]; + return ( +
+ + + + {weapon.range.toFixed(1)} + +
+ ); + })} +
+ + + {/* Grid lines */} + {xTicks.map((tick) => ( + + ))} + {yTicks.map((tick) => ( + + ))} + + {/* Ground line (y=0) */} + + + {/* Benchmark vertical lines */} + {benchmarkTrajectories.map((benchmark, index) => { + const x = xScale(benchmark.range); + return ( + + ); + })} + + {/* Weapon trajectories */} + {weapons.map((weapon, index) => { + if (!weapon.trajectory) return null; + const slotIndex = weaponIds.indexOf(weapon.weaponId); + const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length]; + return ( + + ); + })} + + {/* Blast radius circles */} + {weapons.map((weapon, index) => { + if (!weapon.blastRadius || !weapon.trajectory) return null; + const groundPoint = getGroundIntersection(weapon.trajectory); + if (!groundPoint) return null; + const slotIndex = weaponIds.indexOf(weapon.weaponId); + const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length]; + const radiusPixels = xScale(weapon.blastRadius); + return ( + + ); + })} + + {/* X axis labels */} + {xTicks.map((tick) => ( + + {tick} + + ))} + + {/* Y axis labels */} + {yTicks.map((tick) => ( + + {tick} + + ))} + + {/* Axis labels */} + + z + + + y + + + +
+ ); +} diff --git a/app/features/comp-analyzer/components/SelectedWeapons.browser.test.tsx b/app/features/comp-analyzer/components/SelectedWeapons.browser.test.tsx new file mode 100644 index 000000000..1a94d9335 --- /dev/null +++ b/app/features/comp-analyzer/components/SelectedWeapons.browser.test.tsx @@ -0,0 +1,150 @@ +import type { ComponentProps } from "react"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { describe, expect, test, vi } from "vitest"; +import { render } from "vitest-browser-react"; +import { MAX_WEAPONS } from "../comp-analyzer-constants"; +import { SelectedWeapons } from "./SelectedWeapons"; + +const defaultProps: ComponentProps = { + selectedWeaponIds: [], + onRemove: vi.fn(), +}; + +function renderSelectedWeapons( + props: Partial> = {}, +) { + const router = createMemoryRouter( + [ + { + path: "/", + element: , + }, + ], + { initialEntries: ["/"] }, + ); + + return render(); +} + +describe("SelectedWeapons", () => { + describe("empty state", () => { + test("renders empty slots when no weapons selected", async () => { + const screen = await renderSelectedWeapons({ selectedWeaponIds: [] }); + + const emptySlots = screen.container.querySelectorAll( + '[class*="weaponNameEmpty"]', + ); + expect(emptySlots.length).toBe(MAX_WEAPONS); + }); + + test("renders correct number of slots", async () => { + const screen = await renderSelectedWeapons(); + + const rows = screen.container.querySelectorAll( + '[class*="selectedWeaponRow"]', + ); + expect(rows.length).toBe(MAX_WEAPONS); + }); + }); + + describe("selected weapons", () => { + test("renders selected weapon with name", async () => { + const screen = await renderSelectedWeapons({ + selectedWeaponIds: [0], + }); + + await expect.element(screen.getByText("Sploosh-o-matic")).toBeVisible(); + }); + + test("renders multiple selected weapons", async () => { + const screen = await renderSelectedWeapons({ + selectedWeaponIds: [0, 10], + }); + + await expect.element(screen.getByText("Sploosh-o-matic")).toBeVisible(); + await expect.element(screen.getByText("Splattershot Jr.")).toBeVisible(); + }); + + test("renders sub and special icons for selected weapon", async () => { + const screen = await renderSelectedWeapons({ + selectedWeaponIds: [0], + }); + + const subSpecialContainers = screen.container.querySelectorAll( + '[class*="subSpecialContainer"]', + ); + const filledContainers = Array.from(subSpecialContainers).filter( + (container) => !container.className.includes("Spacer"), + ); + expect(filledContainers.length).toBeGreaterThan(0); + + const kitIcons = screen.container.querySelectorAll('[class*="kitIcon"]'); + expect(kitIcons.length).toBeGreaterThan(0); + }); + + test("shows empty slots for remaining positions", async () => { + const screen = await renderSelectedWeapons({ + selectedWeaponIds: [0], + }); + + const emptySlots = screen.container.querySelectorAll( + '[class*="weaponNameEmpty"]', + ); + expect(emptySlots.length).toBe(MAX_WEAPONS - 1); + }); + }); + + describe("remove functionality", () => { + test("renders remove button for selected weapons", async () => { + const screen = await renderSelectedWeapons({ + selectedWeaponIds: [0], + }); + + const removeButton = screen.getByRole("button", { + name: "Remove weapon", + }); + await expect.element(removeButton).toBeVisible(); + }); + + test("calls onRemove with correct index when remove button clicked", async () => { + const onRemove = vi.fn(); + const screen = await renderSelectedWeapons({ + selectedWeaponIds: [0, 10], + onRemove, + }); + + const removeButtons = screen.container.querySelectorAll( + '[class*="removeButton"]', + ); + (removeButtons[0] as HTMLElement).click(); + + expect(onRemove).toHaveBeenCalledWith(0); + }); + + test("calls onRemove with second index when second remove button clicked", async () => { + const onRemove = vi.fn(); + const screen = await renderSelectedWeapons({ + selectedWeaponIds: [0, 10], + onRemove, + }); + + const removeButtons = screen.container.querySelectorAll( + '[class*="removeButton"]', + ); + (removeButtons[1] as HTMLElement).click(); + + expect(onRemove).toHaveBeenCalledWith(1); + }); + + test("does not render remove button for empty slots", async () => { + const screen = await renderSelectedWeapons({ + selectedWeaponIds: [], + }); + + const removeButtons = screen.container.querySelectorAll( + '[class*="removeButton"]', + ); + expect(removeButtons.length).toBe(0); + }); + }); +}); diff --git a/app/features/comp-analyzer/components/SelectedWeapons.module.css b/app/features/comp-analyzer/components/SelectedWeapons.module.css new file mode 100644 index 000000000..2b1efe1d6 --- /dev/null +++ b/app/features/comp-analyzer/components/SelectedWeapons.module.css @@ -0,0 +1,122 @@ +.selectedWeapons { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.selectedWeaponRow { + display: flex; + align-items: center; + gap: var(--s-2); +} + +.weaponImageContainer { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + background-color: var(--bg-lightest); + border: 2px solid var(--border); + border-radius: 50%; + flex-shrink: 0; +} + +.weaponImageContainerEmpty { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + background-color: var(--bg-lighter); + border: 2px dashed var(--border); + border-radius: 50%; + flex-shrink: 0; + opacity: 0.6; +} + +.weaponNamePill { + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-2) var(--s-3); + padding-left: var(--s-5); + background-color: var(--bg-lightest); + border: 2px solid var(--border); + border-radius: var(--rounded-full); + flex: 1; + min-width: 0; +} + +.weaponName { + flex: 1; + font-weight: var(--semi-bold); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.weaponNamePillEmpty { + display: flex; + align-items: center; + padding: var(--s-2) var(--s-3); + padding-left: var(--s-5); + background-color: var(--bg-lighter); + border: 2px dashed var(--border); + border-radius: var(--rounded-full); + flex: 1; + min-width: 0; + opacity: 0.6; +} + +.weaponNameEmpty { + flex: 1; + font-weight: var(--semi-bold); + font-style: italic; + color: var(--text-lighter); +} + +.removeButton { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + background: none; + border: none; + cursor: pointer; + font-size: 18px; + font-weight: bold; + line-height: 1; + color: var(--text-lighter); + flex-shrink: 0; +} + +.removeButton:hover { + color: var(--theme-error); +} + +.subSpecialContainer { + display: flex; + gap: var(--s-2); + flex-shrink: 0; +} + +.subSpecialContainerSpacer { + width: 89px; +} + +.kitIcon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background-color: var(--bg-lightest); + border: 2px solid var(--border); + border-radius: 50%; +} diff --git a/app/features/comp-analyzer/components/SelectedWeapons.tsx b/app/features/comp-analyzer/components/SelectedWeapons.tsx new file mode 100644 index 000000000..e511d53a8 --- /dev/null +++ b/app/features/comp-analyzer/components/SelectedWeapons.tsx @@ -0,0 +1,93 @@ +import { useTranslation } from "react-i18next"; +import { Image, WeaponImage } from "~/components/Image"; +import { mainWeaponParams } from "~/features/build-analyzer/core/utils"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { + abilityImageUrl, + specialWeaponImageUrl, + subWeaponImageUrl, +} from "~/utils/urls"; +import { MAX_WEAPONS } from "../comp-analyzer-constants"; +import styles from "./SelectedWeapons.module.css"; + +interface SelectedWeaponsProps { + selectedWeaponIds: MainWeaponId[]; + onRemove: (index: number) => void; +} + +export function SelectedWeapons({ + selectedWeaponIds, + onRemove, +}: SelectedWeaponsProps) { + const { t } = useTranslation(["weapons", "analyzer"]); + + const slots = Array.from({ length: MAX_WEAPONS }, (_, i) => { + return selectedWeaponIds[i] ?? null; + }); + + return ( +
+ {slots.map((weaponId, index) => { + if (weaponId === null) { + return ( +
+
+ +
+
+ + {t("analyzer:comp.pickWeapon")} + +
+
+
+ ); + } + + const params = mainWeaponParams(weaponId); + + return ( +
+
+ +
+
+ + {t(`weapons:MAIN_${weaponId}`)} + + +
+
+
+ {t(`weapons:SUB_${params.subWeaponId}`)} +
+
+ {t(`weapons:SPECIAL_${params.specialWeaponId}`)} +
+
+
+ ); + })} +
+ ); +} diff --git a/app/features/comp-analyzer/components/WeaponCategories.module.css b/app/features/comp-analyzer/components/WeaponCategories.module.css new file mode 100644 index 000000000..62429d5ce --- /dev/null +++ b/app/features/comp-analyzer/components/WeaponCategories.module.css @@ -0,0 +1,52 @@ +.container { + display: flex; + flex-wrap: wrap; + gap: var(--s-4); + max-width: 100%; +} + +.categorySection { + display: flex; + flex-direction: column; + gap: var(--s-1); + margin: 0 auto; + max-width: 100%; +} + +.categoryLabel { + font-size: var(--fonts-sm); + font-weight: var(--semi-bold); + color: var(--text-lighter); +} + +.categoryPill { + display: flex; + align-items: center; + background-color: var(--bg-lightest); + border: 2px solid var(--border); + border-radius: var(--rounded-full); + overflow-x: auto; + overflow-y: hidden; + max-width: 100%; + width: fit-content; +} + +.categoryItem { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-1); + padding: var(--s-1) var(--s-4); + border-left: 1.5px solid var(--border); + flex-shrink: 0; +} + +.categoryItem[data-first="true"] { + border-left: none; +} + +.categoryName { + font-size: var(--fonts-xxs); + font-weight: var(--semi-bold); + white-space: nowrap; +} diff --git a/app/features/comp-analyzer/components/WeaponCategories.tsx b/app/features/comp-analyzer/components/WeaponCategories.tsx new file mode 100644 index 000000000..9951266cc --- /dev/null +++ b/app/features/comp-analyzer/components/WeaponCategories.tsx @@ -0,0 +1,126 @@ +import { useTranslation } from "react-i18next"; +import { Image } from "~/components/Image"; +import { mainWeaponParams } from "~/features/build-analyzer/core/utils"; +import type { + MainWeaponId, + SpecialWeaponId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; +import { specialWeaponImageUrl, subWeaponImageUrl } from "~/utils/urls"; +import { + SPECIAL_CATEGORY_ORDER, + SPECIAL_WEAPON_CATEGORIES, + type SpecialWeaponCategory, + SUB_CATEGORY_ORDER, + SUB_WEAPON_CATEGORIES, + type SubWeaponCategory, +} from "../comp-analyzer-constants"; +import styles from "./WeaponCategories.module.css"; + +interface WeaponCategoriesProps { + selectedWeaponIds: MainWeaponId[]; +} + +interface SubWeaponItem { + subId: SubWeaponId; + category: SubWeaponCategory; + weaponIndex: number; +} + +interface SpecialWeaponItem { + specialId: SpecialWeaponId; + category: SpecialWeaponCategory; + weaponIndex: number; +} + +export function WeaponCategories({ selectedWeaponIds }: WeaponCategoriesProps) { + const { t } = useTranslation(["weapons", "analyzer"]); + + if (selectedWeaponIds.length === 0) { + return null; + } + + const subItems: SubWeaponItem[] = []; + const specialItems: SpecialWeaponItem[] = []; + + for (const [index, weaponId] of selectedWeaponIds.entries()) { + const params = mainWeaponParams(weaponId); + + subItems.push({ + subId: params.subWeaponId, + category: SUB_WEAPON_CATEGORIES[params.subWeaponId], + weaponIndex: index, + }); + + specialItems.push({ + specialId: params.specialWeaponId, + category: SPECIAL_WEAPON_CATEGORIES[params.specialWeaponId], + weaponIndex: index, + }); + } + + const sortedSubItems = [...subItems].sort((a, b) => { + const aIndex = SUB_CATEGORY_ORDER.indexOf(a.category); + const bIndex = SUB_CATEGORY_ORDER.indexOf(b.category); + if (aIndex !== bIndex) return aIndex - bIndex; + return a.weaponIndex - b.weaponIndex; + }); + + const sortedSpecialItems = [...specialItems].sort((a, b) => { + const aIndex = SPECIAL_CATEGORY_ORDER.indexOf(a.category); + const bIndex = SPECIAL_CATEGORY_ORDER.indexOf(b.category); + if (aIndex !== bIndex) return aIndex - bIndex; + return a.weaponIndex - b.weaponIndex; + }); + + return ( +
+
+
+ {t("analyzer:comp.subRoles")} +
+
+ {sortedSubItems.map((item, index) => ( +
+ {t(`weapons:SUB_${item.subId}`)} + + {t(`analyzer:comp.subCategory.${item.category}`)} + +
+ ))} +
+
+
+
+ {t("analyzer:comp.specialRoles")} +
+
+ {sortedSpecialItems.map((item, index) => ( +
+ {t(`weapons:SPECIAL_${item.specialId}`)} + + {t(`analyzer:comp.specialCategory.${item.category}`)} + +
+ ))} +
+
+
+ ); +} diff --git a/app/features/comp-analyzer/components/WeaponGrid.browser.test.tsx b/app/features/comp-analyzer/components/WeaponGrid.browser.test.tsx new file mode 100644 index 000000000..09222dd09 --- /dev/null +++ b/app/features/comp-analyzer/components/WeaponGrid.browser.test.tsx @@ -0,0 +1,135 @@ +import type { ComponentProps } from "react"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { describe, expect, test, vi } from "vitest"; +import { render } from "vitest-browser-react"; +import { WeaponGrid } from "./WeaponGrid"; + +const defaultProps: ComponentProps = { + selectedWeaponIds: [], + onWeaponClick: vi.fn(), + categorization: "category", + onCategorizationChange: vi.fn(), + isCollapsed: false, + onToggleCollapse: vi.fn(), +}; + +function renderWeaponGrid( + props: Partial> = {}, +) { + const router = createMemoryRouter( + [ + { + path: "/", + element: , + }, + ], + { initialEntries: ["/"] }, + ); + + return render(); +} + +describe("WeaponGrid", () => { + describe("collapse toggle", () => { + test("renders with collapse toggle visible", async () => { + const screen = await renderWeaponGrid(); + + await expect + .element(screen.getByText("Hide weapon selector")) + .toBeVisible(); + }); + + test("shows 'Show weapon selector' when collapsed", async () => { + const screen = await renderWeaponGrid({ isCollapsed: true }); + + await expect + .element(screen.getByText("Show weapon selector")) + .toBeVisible(); + }); + + test("calls onToggleCollapse when toggle button clicked", async () => { + const onToggleCollapse = vi.fn(); + const screen = await renderWeaponGrid({ onToggleCollapse }); + + await screen.getByText("Hide weapon selector").click(); + + expect(onToggleCollapse).toHaveBeenCalledTimes(1); + }); + + test("hides weapon grid content when collapsed", async () => { + const screen = await renderWeaponGrid({ isCollapsed: true }); + + await expect + .element(screen.getByText("Show weapon selector")) + .toBeVisible(); + const groupByLabel = screen.container.querySelector( + '[class*="categorizationToggle"]', + ); + expect(groupByLabel).toBeNull(); + }); + }); + + describe("categorization", () => { + test("shows categorization radio buttons when expanded", async () => { + const screen = await renderWeaponGrid(); + + await expect.element(screen.getByText("Weapon type")).toBeVisible(); + await expect.element(screen.getByText("Sub weapon")).toBeVisible(); + await expect.element(screen.getByText("Special weapon")).toBeVisible(); + }); + + test("calls onCategorizationChange when radio button clicked", async () => { + const onCategorizationChange = vi.fn(); + const screen = await renderWeaponGrid({ onCategorizationChange }); + + await screen.getByText("Sub weapon").click(); + + expect(onCategorizationChange).toHaveBeenCalledWith("sub"); + }); + + test("shows correct radio button as checked", async () => { + const screen = await renderWeaponGrid({ categorization: "special" }); + + const specialRadio = screen.getByRole("radio", { + name: "Special weapon", + }); + await expect.element(specialRadio).toBeChecked(); + }); + }); + + describe("weapon selection", () => { + test("calls onWeaponClick when weapon button clicked", async () => { + const onWeaponClick = vi.fn(); + const screen = await renderWeaponGrid({ onWeaponClick }); + + const weaponButtons = screen.container.querySelectorAll("button[title]"); + const firstWeaponButton = weaponButtons[1] as HTMLElement | undefined; + if (firstWeaponButton) { + firstWeaponButton.click(); + expect(onWeaponClick).toHaveBeenCalledTimes(1); + } + }); + + test("disables unselected weapons when max is reached", async () => { + const screen = await renderWeaponGrid({ + selectedWeaponIds: [0, 10, 20, 30], + }); + + const disabledButtons = screen.container.querySelectorAll( + "button[title][disabled]", + ); + expect(disabledButtons.length).toBeGreaterThan(0); + }); + + test("shows selected weapons with selected style", async () => { + const screen = await renderWeaponGrid({ + selectedWeaponIds: [0], + }); + + const selectedButtons = screen.container.querySelectorAll( + '[class*="weaponButtonSelected"]', + ); + expect(selectedButtons.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/app/features/comp-analyzer/components/WeaponGrid.module.css b/app/features/comp-analyzer/components/WeaponGrid.module.css new file mode 100644 index 000000000..8b0c853a0 --- /dev/null +++ b/app/features/comp-analyzer/components/WeaponGrid.module.css @@ -0,0 +1,104 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--s-4); +} + +.collapseToggle { + display: flex; + align-items: center; + gap: var(--s-2); + background-color: transparent; + border: none; + cursor: pointer; + font-weight: var(--semi-bold); + color: var(--text); +} + +.collapseArrow { + transition: transform 0.2s; +} + +.collapseArrowCollapsed { + transform: rotate(-90deg); +} + +.categorizationToggle { + display: flex; + gap: var(--s-2); + flex-wrap: wrap; +} + +.weaponGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--s-4); +} + +@media (max-width: 900px) { + .weaponGrid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 600px) { + .weaponGrid { + grid-template-columns: 1fr; + } +} + +.categorySection { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.categoryHeader { + display: flex; + align-items: center; + gap: var(--s-2); + padding-bottom: var(--s-1); + border-bottom: 3px solid var(--border); +} + +.categoryName { + font-weight: var(--semi-bold); + font-size: var(--fonts-sm); + text-transform: capitalize; +} + +.categoryWeapons { + display: flex; + flex-wrap: wrap; + gap: var(--s-1); +} + +.weaponButton { + display: flex; + align-items: center; + justify-content: center; + padding: var(--s-1); + background-color: var(--bg-lighter); + border: 2px solid transparent; + border-radius: var(--rounded); + cursor: pointer; + transition: border-color 0.1s; +} + +.weaponButton:hover { + border-color: var(--theme); +} + +.weaponButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.weaponButton:disabled:hover { + border-color: transparent; +} + +.weaponButtonSelected { + background-color: var(--bg-lightest); + border: 2px solid var(--border); +} diff --git a/app/features/comp-analyzer/components/WeaponGrid.tsx b/app/features/comp-analyzer/components/WeaponGrid.tsx new file mode 100644 index 000000000..c7fd3d3ac --- /dev/null +++ b/app/features/comp-analyzer/components/WeaponGrid.tsx @@ -0,0 +1,204 @@ +import { useTranslation } from "react-i18next"; +import { Image, WeaponImage } from "~/components/Image"; +import { Label } from "~/components/Label"; +import { mainWeaponParams } from "~/features/build-analyzer/core/utils"; +import type { + MainWeaponId, + SpecialWeaponId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; +import { + mainWeaponIds, + specialWeaponIds, + subWeaponIds, + weaponCategories, +} from "~/modules/in-game-lists/weapon-ids"; +import { specialWeaponImageUrl, subWeaponImageUrl } from "~/utils/urls"; +import { MAX_WEAPONS } from "../comp-analyzer-constants"; +import type { CategorizationType } from "../comp-analyzer-types"; +import styles from "./WeaponGrid.module.css"; + +interface WeaponGridProps { + selectedWeaponIds: MainWeaponId[]; + onWeaponClick: (weaponId: MainWeaponId) => void; + categorization: CategorizationType; + onCategorizationChange: (categorization: CategorizationType) => void; + isCollapsed: boolean; + onToggleCollapse: () => void; +} + +export function WeaponGrid({ + selectedWeaponIds, + onWeaponClick, + categorization, + onCategorizationChange, + isCollapsed, + onToggleCollapse, +}: WeaponGridProps) { + const { t } = useTranslation(["weapons", "analyzer"]); + const isMaxSelected = selectedWeaponIds.length >= MAX_WEAPONS; + + const groupedWeapons = groupWeaponsByType(categorization); + + return ( +
+ + + {!isCollapsed ? ( + <> +
+ +
+ + + +
+
+ +
+ {groupedWeapons.map((group) => ( +
+
+ {group.iconPath ? ( + + ) : null} + + {group.name.startsWith("SUB_") || + group.name.startsWith("SPECIAL_") + ? t(`weapons:${group.name}` as "SUB_0") + : group.name} + +
+
+ {group.weaponIds.map((weaponId) => { + const isSelected = selectedWeaponIds.includes(weaponId); + const isDisabled = !isSelected && isMaxSelected; + + return ( + + ); + })} +
+
+ ))} +
+ + ) : null} +
+ ); +} + +interface WeaponGroup { + key: string; + name: string; + iconPath: string | null; + weaponIds: MainWeaponId[]; +} + +function groupWeaponsByType(categorization: CategorizationType): WeaponGroup[] { + if (categorization === "category") { + return weaponCategories.map((category) => ({ + key: category.name, + name: category.name.toLowerCase(), + iconPath: `/static-assets/img/weapon-categories/${category.name}`, + weaponIds: [...category.weaponIds] as MainWeaponId[], + })); + } + + if (categorization === "sub") { + return subWeaponIds + .map((subId) => { + const weaponsWithSub = mainWeaponIds.filter((weaponId) => { + const params = mainWeaponParams(weaponId); + return params.subWeaponId === subId; + }); + + return { + key: `sub-${subId}`, + name: `SUB_${subId}`, + iconPath: subWeaponImageUrl(subId as SubWeaponId), + weaponIds: weaponsWithSub, + }; + }) + .filter((group) => group.weaponIds.length > 0); + } + + return specialWeaponIds + .map((specialId) => { + const weaponsWithSpecial = mainWeaponIds.filter((weaponId) => { + const params = mainWeaponParams(weaponId); + return params.specialWeaponId === specialId; + }); + + return { + key: `special-${specialId}`, + name: `SPECIAL_${specialId}`, + iconPath: specialWeaponImageUrl(specialId as SpecialWeaponId), + weaponIds: weaponsWithSpecial, + }; + }) + .filter((group) => group.weaponIds.length > 0); +} diff --git a/app/features/comp-analyzer/core/damage-combinations.test.ts b/app/features/comp-analyzer/core/damage-combinations.test.ts new file mode 100644 index 000000000..229e5af70 --- /dev/null +++ b/app/features/comp-analyzer/core/damage-combinations.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, test } from "vitest"; +import { + COMBO_DAMAGE_THRESHOLD, + MAX_COMBOS_DISPLAYED, + MAX_DAMAGE_TYPES_PER_COMBO, + MAX_REPEATS_PER_DAMAGE_TYPE, +} from "../comp-analyzer-constants"; +import { + calculateDamageCombos, + calculateInkTimeToKill, + extractDamageSources, +} from "./damage-combinations"; + +const SPLATTERSHOT_ID = 40; +const SPLAT_ROLLER_ID = 1010; +const SPLAT_CHARGER_ID = 2010; +const AEROSPRAY_MG_ID = 30; +const SPLATTERSHOT_JR_ID = 10; + +describe("extractDamageSources", () => { + test("extracts main weapon damages", () => { + const sources = extractDamageSources([SPLATTERSHOT_ID]); + + expect(sources.length).toBe(1); + expect(sources[0].weaponId).toBe(SPLATTERSHOT_ID); + expect(sources[0].weaponSlot).toBe(0); + + const mainDamages = sources[0].damages.filter( + (d) => d.weaponType === "MAIN", + ); + expect(mainDamages.length).toBeGreaterThan(0); + }); + + test("extracts sub weapon damages", () => { + const sources = extractDamageSources([SPLATTERSHOT_ID]); + + const subDamages = sources[0].damages.filter((d) => d.weaponType === "SUB"); + expect(subDamages.length).toBeGreaterThan(0); + }); + + test("extracts special weapon damages when applicable", () => { + const sources = extractDamageSources([SPLATTERSHOT_ID]); + + const specialDamages = sources[0].damages.filter( + (d) => d.weaponType === "SPECIAL", + ); + expect(specialDamages.length).toBeGreaterThan(0); + }); + + test("assigns correct weapon slots for multiple weapons", () => { + const sources = extractDamageSources([ + SPLATTERSHOT_ID, + SPLAT_ROLLER_ID, + SPLAT_CHARGER_ID, + ]); + + expect(sources.length).toBe(3); + expect(sources[0].weaponSlot).toBe(0); + expect(sources[1].weaponSlot).toBe(1); + expect(sources[2].weaponSlot).toBe(2); + }); +}); + +describe("calculateDamageCombos - basic combination generation", () => { + test("generates combos involving 2+ weapons", () => { + const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLAT_ROLLER_ID]); + + expect(combos.length).toBeGreaterThan(0); + + for (const combo of combos) { + const uniqueSlots = new Set(combo.segments.map((s) => s.weaponSlot)); + expect(uniqueSlots.size).toBeGreaterThanOrEqual(2); + } + }); + + test("calculates correct total damage", () => { + const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLAT_ROLLER_ID]); + + for (const combo of combos) { + const calculatedTotal = combo.segments.reduce( + (sum, segment) => sum + segment.damageValue * segment.count, + 0, + ); + expect(combo.totalDamage).toBeCloseTo(calculatedTotal, 1); + } + }); + + test("calculates correct hit count", () => { + const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLAT_ROLLER_ID]); + + for (const combo of combos) { + const calculatedHitCount = combo.segments.reduce( + (sum, segment) => sum + segment.count, + 0, + ); + expect(combo.hitCount).toBe(calculatedHitCount); + } + }); +}); + +describe("calculateDamageCombos - constraint enforcement", () => { + test("respects max damage types per combo constraint", () => { + const combos = calculateDamageCombos([ + SPLATTERSHOT_ID, + SPLAT_ROLLER_ID, + SPLAT_CHARGER_ID, + AEROSPRAY_MG_ID, + ]); + + for (const combo of combos) { + const uniqueTypes = new Set(combo.segments.map((s) => s.damageType)); + expect(uniqueTypes.size).toBeLessThanOrEqual(MAX_DAMAGE_TYPES_PER_COMBO); + } + }); + + test("respects max repeats per damage type constraint", () => { + const combos = calculateDamageCombos([ + SPLATTERSHOT_ID, + SPLAT_ROLLER_ID, + SPLAT_CHARGER_ID, + AEROSPRAY_MG_ID, + ]); + + for (const combo of combos) { + const typeToCount = new Map(); + for (const segment of combo.segments) { + const current = typeToCount.get(segment.damageType) ?? 0; + typeToCount.set(segment.damageType, current + segment.count); + } + + for (const [type, count] of typeToCount) { + expect( + count, + `Type ${type} has ${count} uses, max is ${MAX_REPEATS_PER_DAMAGE_TYPE}`, + ).toBeLessThanOrEqual(MAX_REPEATS_PER_DAMAGE_TYPE); + } + } + }); + + test("requires 2+ weapon slots in each combo", () => { + const combos = calculateDamageCombos([ + SPLATTERSHOT_ID, + SPLAT_ROLLER_ID, + SPLAT_CHARGER_ID, + ]); + + for (const combo of combos) { + const uniqueSlots = new Set(combo.segments.map((s) => s.weaponSlot)); + expect(uniqueSlots.size).toBeGreaterThanOrEqual(2); + } + }); +}); + +describe("calculateDamageCombos - threshold filtering", () => { + test("excludes combos below damage threshold", () => { + const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLAT_ROLLER_ID]); + + for (const combo of combos) { + expect(combo.totalDamage).toBeGreaterThanOrEqual(COMBO_DAMAGE_THRESHOLD); + } + }); + + test("includes combos at or above damage threshold", () => { + const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLAT_ROLLER_ID]); + + const hasValidCombos = combos.some( + (c) => c.totalDamage >= COMBO_DAMAGE_THRESHOLD, + ); + expect(hasValidCombos).toBe(true); + }); +}); + +describe("calculateDamageCombos - sorting", () => { + test("sorts results by totalDamage closest to 100 (lethal threshold)", () => { + const combos = calculateDamageCombos([ + SPLATTERSHOT_ID, + SPLAT_ROLLER_ID, + SPLAT_CHARGER_ID, + ]); + + if (combos.length < 2) { + return; + } + + for (let i = 0; i < combos.length - 1; i++) { + const currentDistTo100 = Math.abs(combos[i].totalDamage - 100); + const nextDistTo100 = Math.abs(combos[i + 1].totalDamage - 100); + expect(currentDistTo100).toBeLessThanOrEqual(nextDistTo100); + } + }); +}); + +describe("calculateDamageCombos - result cap", () => { + test("returns at most MAX_COMBOS_DISPLAYED results", () => { + const combos = calculateDamageCombos([ + SPLATTERSHOT_ID, + SPLAT_ROLLER_ID, + SPLAT_CHARGER_ID, + AEROSPRAY_MG_ID, + ]); + + expect(combos.length).toBeLessThanOrEqual(MAX_COMBOS_DISPLAYED); + }); +}); + +describe("calculateDamageCombos - edge cases", () => { + test("returns empty array for empty weapon selection", () => { + const combos = calculateDamageCombos([]); + + expect(combos).toEqual([]); + }); + + test("returns empty array for single weapon (cannot make cross-weapon combos)", () => { + const combos = calculateDamageCombos([SPLATTERSHOT_ID]); + + expect(combos).toEqual([]); + }); + + test("same sub weapon on multiple weapons creates valid combos", () => { + const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLATTERSHOT_JR_ID]); + + expect(combos.length).toBeGreaterThanOrEqual(0); + + for (const combo of combos) { + const uniqueSlots = new Set(combo.segments.map((s) => s.weaponSlot)); + expect(uniqueSlots.size).toBeGreaterThanOrEqual(2); + } + }); + + test("all segments have valid weapon slot assignments", () => { + const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLAT_ROLLER_ID]); + + for (const combo of combos) { + for (const segment of combo.segments) { + expect(segment.weaponSlot).toBeGreaterThanOrEqual(0); + expect(segment.weaponSlot).toBeLessThan(4); + } + } + }); + + test("all segments have count of 1 or 2", () => { + const combos = calculateDamageCombos([ + SPLATTERSHOT_ID, + SPLAT_ROLLER_ID, + SPLAT_CHARGER_ID, + ]); + + for (const combo of combos) { + for (const segment of combo.segments) { + expect(segment.count).toBeGreaterThanOrEqual(1); + expect(segment.count).toBeLessThanOrEqual(2); + } + } + }); +}); + +describe("calculateInkTimeToKill", () => { + test("returns null for combos that are already lethal (>= 100 damage)", () => { + const result = calculateInkTimeToKill(100, 0); + expect(result).toBeNull(); + }); + + test("returns null for combos above 100 damage", () => { + const result = calculateInkTimeToKill(150, 0); + expect(result).toBeNull(); + }); + + test("calculates short ink time for 99 damage combo with 0 RES", () => { + const result = calculateInkTimeToKill(99, 0); + + expect(result).not.toBeNull(); + expect(result).toBeGreaterThan(0); + expect(result).toBeLessThan(30); + }); + + test("calculates longer ink time for 80 damage combo with 0 RES", () => { + const result = calculateInkTimeToKill(80, 0); + + expect(result).not.toBeNull(); + expect(result).toBeGreaterThan(60); + }); + + test("returns null when remaining damage exceeds ink damage limit with high RES", () => { + const result = calculateInkTimeToKill(60, 57); + + expect(result).toBeNull(); + }); + + test("higher RES increases ink time", () => { + const resultNoRes = calculateInkTimeToKill(90, 0); + const resultMaxRes = calculateInkTimeToKill(90, 57); + + expect(resultNoRes).not.toBeNull(); + expect(resultMaxRes).not.toBeNull(); + expect(resultMaxRes!).toBeGreaterThan(resultNoRes!); + }); + + test("handles boundary case at 0 AP", () => { + const result = calculateInkTimeToKill(80, 0); + + expect(result).not.toBeNull(); + expect(result).toBeGreaterThan(0); + }); + + test("handles boundary case at 57 AP", () => { + const result = calculateInkTimeToKill(90, 57); + + expect(result).not.toBeNull(); + expect(result).toBeGreaterThan(0); + }); +}); diff --git a/app/features/comp-analyzer/core/damage-combinations.ts b/app/features/comp-analyzer/core/damage-combinations.ts new file mode 100644 index 000000000..ab0dc2f1e --- /dev/null +++ b/app/features/comp-analyzer/core/damage-combinations.ts @@ -0,0 +1,316 @@ +import { damageTypeToWeaponType } from "~/features/build-analyzer/analyzer-constants"; +import type { + AbilityPoints, + DamageType, +} from "~/features/build-analyzer/analyzer-types"; +import { + buildStats, + subWeaponDamageValue, +} from "~/features/build-analyzer/core/stats"; +import { weaponParams } from "~/features/build-analyzer/core/utils"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { + COMBO_DAMAGE_THRESHOLD, + MAX_COMBOS_DISPLAYED, + MAX_DAMAGE_TYPES_PER_COMBO, + MAX_REPEATS_PER_DAMAGE_TYPE, +} from "../comp-analyzer-constants"; +import type { + DamageCombo, + DamageSegment, + WeaponDamageSource, +} from "../comp-analyzer-types"; + +interface DamageOption { + weaponSlot: number; + weaponId: MainWeaponId; + damageType: DamageType; + damageValue: number; + weaponType: "MAIN" | "SUB" | "SPECIAL"; +} + +export function extractDamageSources( + weaponIds: MainWeaponId[], + targetSubDefenseAp = 0, +): WeaponDamageSource[] { + return weaponIds.map((weaponId, slot) => { + const stats = buildStats({ weaponSplId: weaponId, hasTacticooler: false }); + const damages: WeaponDamageSource["damages"] = []; + + for (const damage of stats.stats.damages) { + const weaponType = damageTypeToWeaponType[damage.type]; + if (weaponType === "MAIN") { + damages.push({ + type: damage.type, + value: damage.value, + weaponType: "MAIN", + }); + } + } + + for (const subDamage of stats.stats.subWeaponDefenseDamages) { + if (subDamage.subWeaponId === stats.weapon.subWeaponSplId) { + const params = weaponParams(); + const reducedValue = + targetSubDefenseAp === 0 + ? subDamage.baseValue + : subWeaponDamageValue({ + baseValue: subDamage.baseValue, + subWeaponId: subDamage.subWeaponId, + abilityPoints: targetSubDefenseAp, + params: params.subWeapons[subDamage.subWeaponId], + }); + damages.push({ + type: subDamage.type, + value: reducedValue, + weaponType: "SUB", + }); + } + } + + for (const specialDamage of stats.stats.specialWeaponDamages) { + damages.push({ + type: specialDamage.type, + value: specialDamage.value, + weaponType: "SPECIAL", + }); + } + + return { + weaponSlot: slot, + weaponId, + damages, + }; + }); +} + +export interface ExcludedDamageKey { + weaponId: MainWeaponId; + weaponType: "main" | "sub" | "special"; + damageType: DamageType; +} + +export function calculateDamageCombos( + weaponIds: MainWeaponId[], + excludedKeys: ExcludedDamageKey[] = [], + targetSubDefenseAp = 0, +): DamageCombo[] { + if (weaponIds.length < 2) { + return []; + } + + const excludedSet = new Set( + excludedKeys.map((k) => `${k.weaponId}-${k.weaponType}-${k.damageType}`), + ); + + const sources = extractDamageSources(weaponIds, targetSubDefenseAp); + const damageOptions = flattenToOptions(sources, excludedSet); + const combos = generateCombinations(damageOptions); + const filtered = filterAndSortCombos(combos); + + return filtered; +} + +function flattenToOptions( + sources: WeaponDamageSource[], + excludedSet: Set, +): DamageOption[] { + const options: DamageOption[] = []; + + for (const source of sources) { + for (const damage of source.damages) { + const weaponType = damage.weaponType.toLowerCase() as + | "main" + | "sub" + | "special"; + const key = `${source.weaponId}-${weaponType}-${damage.type}`; + + if (excludedSet.has(key)) { + continue; + } + + options.push({ + weaponSlot: source.weaponSlot, + weaponId: source.weaponId, + damageType: damage.type, + damageValue: damage.value, + weaponType: damage.weaponType, + }); + } + } + + return options; +} + +interface PartialCombo { + segments: DamageSegment[]; + totalDamage: number; + hitCount: number; + usedSlots: Set; + typeCountMap: Map; + slotDamageType: Map; +} + +function generateCombinations(options: DamageOption[]): DamageCombo[] { + const results: DamageCombo[] = []; + + const initialState: PartialCombo = { + segments: [], + totalDamage: 0, + hitCount: 0, + usedSlots: new Set(), + typeCountMap: new Map(), + slotDamageType: new Map(), + }; + + backtrack(options, 0, initialState, results); + + return results; +} + +function backtrack( + options: DamageOption[], + startIndex: number, + current: PartialCombo, + results: DamageCombo[], +): void { + if ( + current.usedSlots.size >= 2 && + current.totalDamage >= COMBO_DAMAGE_THRESHOLD + ) { + results.push({ + segments: [...current.segments], + totalDamage: current.totalDamage, + hitCount: current.hitCount, + }); + } + + if ( + current.typeCountMap.size >= MAX_DAMAGE_TYPES_PER_COMBO && + current.totalDamage >= COMBO_DAMAGE_THRESHOLD + ) { + return; + } + + for (let i = startIndex; i < options.length; i++) { + const option = options[i]; + const currentTypeCount = current.typeCountMap.get(option.damageType) ?? 0; + + if ( + current.typeCountMap.size >= MAX_DAMAGE_TYPES_PER_COMBO && + !current.typeCountMap.has(option.damageType) + ) { + continue; + } + + if (currentTypeCount >= MAX_REPEATS_PER_DAMAGE_TYPE) { + continue; + } + + const existingTypeForSlot = current.slotDamageType.get(option.weaponSlot); + if (existingTypeForSlot && existingTypeForSlot !== option.damageType) { + continue; + } + + for ( + let count = 1; + count <= Math.min(2, MAX_REPEATS_PER_DAMAGE_TYPE - currentTypeCount); + count++ + ) { + const segment: DamageSegment = { + weaponSlot: option.weaponSlot, + weaponId: option.weaponId, + damageType: option.damageType, + damageValue: option.damageValue, + isSubWeapon: option.weaponType === "SUB", + isSpecialWeapon: option.weaponType === "SPECIAL", + count, + }; + + const newUsedSlots = new Set(current.usedSlots); + newUsedSlots.add(option.weaponSlot); + + const newTypeCountMap = new Map(current.typeCountMap); + newTypeCountMap.set(option.damageType, currentTypeCount + count); + + const newSlotDamageType = new Map(current.slotDamageType); + newSlotDamageType.set(option.weaponSlot, option.damageType); + + const newState: PartialCombo = { + segments: [...current.segments, segment], + totalDamage: current.totalDamage + option.damageValue * count, + hitCount: current.hitCount + count, + usedSlots: newUsedSlots, + typeCountMap: newTypeCountMap, + slotDamageType: newSlotDamageType, + }; + + backtrack(options, i + 1, newState, results); + } + } +} + +function filterAndSortCombos(combos: DamageCombo[]): DamageCombo[] { + const filtered = combos.filter( + (combo) => combo.totalDamage >= COMBO_DAMAGE_THRESHOLD, + ); + + filtered.sort((a, b) => { + const aDistTo100 = Math.abs(a.totalDamage - 100); + const bDistTo100 = Math.abs(b.totalDamage - 100); + if (aDistTo100 !== bDistTo100) { + return aDistTo100 - bDistTo100; + } + return a.hitCount - b.hitCount; + }); + + return filtered.slice(0, MAX_COMBOS_DISPLAYED); +} + +const SPLASH_O_MATIC_ID = 20; + +/** + * Calculates the number of frames needed for enemy ink damage to finish off + * a target after dealing a certain amount of combo damage. + * + * @param comboDamage - The damage dealt by the combo + * @param targetResAp - The target's Ink Resistance Up ability points (0-57) + * @returns The number of frames needed, or null if the combo is already lethal + * or ink damage cannot finish the kill (remaining damage exceeds ink damage limit) + */ +export function calculateInkTimeToKill( + comboDamage: number, + targetResAp: number, +): number | null { + if (comboDamage >= 100) { + return null; + } + + const remainingDamage = 100 - comboDamage; + + const abilityPoints: AbilityPoints = new Map([["RES", targetResAp]]); + const stats = buildStats({ + weaponSplId: SPLASH_O_MATIC_ID, + abilityPoints, + hasTacticooler: false, + }); + + const damagePerSecond = stats.stats.damageTakenInEnemyInkPerSecond.value; + const damageLimit = stats.stats.enemyInkDamageLimit.value; + const gracePeriodFrames = + stats.stats.framesBeforeTakingDamageInEnemyInk.value; + + if (remainingDamage > damageLimit) { + return null; + } + + const damageFrames = remainingDamage / (damagePerSecond / 60); + const totalFrames = gracePeriodFrames + damageFrames; + + return Math.ceil(totalFrames); +} + +// TBD: Advanced filtering options +// - Distance range filtering +// - Damage type include/exclude +// - Hit count constraints diff --git a/app/features/comp-analyzer/core/weapon-range.ts b/app/features/comp-analyzer/core/weapon-range.ts new file mode 100644 index 000000000..6faa1ebdc --- /dev/null +++ b/app/features/comp-analyzer/core/weapon-range.ts @@ -0,0 +1,192 @@ +import { mainWeaponParams } from "~/features/build-analyzer/core/utils"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { weaponCategories } from "~/modules/in-game-lists/weapon-ids"; + +interface TrajectoryParams { + spawnSpeed: number; + goStraightStateEndMaxSpeed: number; + goStraightToBrakeStateFrame: number; + freeGravity: number; + freeAirResist: number; + brakeAirResist: number; + brakeGravity: number; + brakeToFreeFrame: number; + burstFrame?: number; +} + +export interface TrajectoryPoint { + z: number; + y: number; +} + +const DEFAULT_BRAKE_AIR_RESIST = 0.36; +const DEFAULT_BRAKE_GRAVITY = 0.07; +const DEFAULT_BRAKE_TO_FREE_FRAME = 4; +const DEFAULT_FREE_GRAVITY = 0.016; +const DEFAULT_FREE_AIR_RESIST = 0; + +function getWeaponCategoryName(weaponId: MainWeaponId): string | undefined { + for (const category of weaponCategories) { + if (category.weaponIds.some((id) => id === weaponId)) { + return category.name; + } + } + return undefined; +} + +const PLAYER_HEIGHT = 1.0; + +function simulateTrajectoryPoints(params: TrajectoryParams): TrajectoryPoint[] { + const { + spawnSpeed, + goStraightStateEndMaxSpeed, + goStraightToBrakeStateFrame, + freeGravity, + freeAirResist, + brakeAirResist, + brakeGravity, + brakeToFreeFrame, + burstFrame, + } = params; + + const maxFrames = burstFrame ?? 300; + const points: TrajectoryPoint[] = []; + let z = 0; + let y = PLAYER_HEIGHT; + let vz = spawnSpeed; + let vy = 0; + let frame = 0; + + points.push({ z, y }); + + while (frame < goStraightToBrakeStateFrame && frame < maxFrames) { + z += vz; + points.push({ z, y }); + frame++; + } + + vz = Math.min(vz, goStraightStateEndMaxSpeed); + + for (let i = 0; i < brakeToFreeFrame && frame < maxFrames; i++) { + vz *= 1 - brakeAirResist; + vy -= brakeGravity; + z += vz; + y += vy; + points.push({ z, y }); + frame++; + + if (y < 0) { + return points; + } + } + + while (frame < maxFrames && y >= 0) { + vz *= 1 - freeAirResist; + vy -= freeGravity; + z += vz; + y += vy; + points.push({ z, y }); + frame++; + } + + return points; +} + +export interface WeaponRangeResult { + range: number; + blastRadius?: number; + rangeType: "calculated" | "direct" | "unsupported"; + trajectory?: TrajectoryPoint[]; +} + +function getWeaponRange(weaponId: MainWeaponId): WeaponRangeResult { + const category = getWeaponCategoryName(weaponId); + + if (!category) { + return { range: 0, rangeType: "unsupported" }; + } + + const params = mainWeaponParams(weaponId); + + if (category === "CHARGERS" && params.DistanceFullCharge !== undefined) { + const range = params.DistanceFullCharge; + return { + range, + rangeType: "direct", + trajectory: [ + { z: 0, y: PLAYER_HEIGHT }, + { z: range, y: PLAYER_HEIGHT }, + ], + }; + } + + if (params.Range_SpawnSpeed === undefined) { + return { range: 0, rangeType: "unsupported" }; + } + + const trajectoryParams: TrajectoryParams = { + spawnSpeed: params.Range_SpawnSpeed, + goStraightStateEndMaxSpeed: + params.Range_GoStraightStateEndMaxSpeed ?? params.Range_SpawnSpeed, + goStraightToBrakeStateFrame: params.Range_GoStraightToBrakeStateFrame ?? 4, + freeGravity: params.Range_FreeGravity ?? DEFAULT_FREE_GRAVITY, + freeAirResist: params.Range_FreeAirResist ?? DEFAULT_FREE_AIR_RESIST, + brakeAirResist: params.Range_BrakeAirResist ?? DEFAULT_BRAKE_AIR_RESIST, + brakeGravity: params.Range_BrakeGravity ?? DEFAULT_BRAKE_GRAVITY, + brakeToFreeFrame: + params.Range_BrakeToFreeStateFrame ?? DEFAULT_BRAKE_TO_FREE_FRAME, + burstFrame: params.Range_BurstFrame, + }; + + const trajectory = simulateTrajectoryPoints(trajectoryParams); + const lastPoint = trajectory[trajectory.length - 1]; + const range = lastPoint?.z ?? 0; + + return { + range, + blastRadius: params.BlastRadius, + rangeType: "calculated", + trajectory, + }; +} + +export interface WeaponWithRange { + weaponId: MainWeaponId; + range: number; + blastRadius?: number; + rangeType: "calculated" | "direct" | "unsupported"; + trajectory?: TrajectoryPoint[]; +} + +export function getWeaponsWithRange( + weaponIds: MainWeaponId[], +): WeaponWithRange[] { + return weaponIds + .map((weaponId) => { + const result = getWeaponRange(weaponId); + return { + weaponId, + ...result, + }; + }) + .filter((w) => w.rangeType !== "unsupported"); +} + +export const BENCHMARK_WEAPON_IDS = [40, 2070] satisfies MainWeaponId[]; + +export interface BenchmarkTrajectory { + id: MainWeaponId; + range: number; + trajectory?: TrajectoryPoint[]; +} + +export function getBenchmarkTrajectories(): BenchmarkTrajectory[] { + return BENCHMARK_WEAPON_IDS.map((weaponId) => { + const result = getWeaponRange(weaponId); + return { + id: weaponId, + range: result.range, + trajectory: result.trajectory, + }; + }).filter((b) => b.trajectory !== undefined); +} diff --git a/app/features/comp-analyzer/routes/comp-analyzer.all-ranges.tsx b/app/features/comp-analyzer/routes/comp-analyzer.all-ranges.tsx new file mode 100644 index 000000000..ffcfd06fc --- /dev/null +++ b/app/features/comp-analyzer/routes/comp-analyzer.all-ranges.tsx @@ -0,0 +1,36 @@ +import { Main } from "~/components/Main"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { + weaponCategories, + weaponIdToType, +} from "~/modules/in-game-lists/weapon-ids"; +import { RangeVisualization } from "../components/RangeVisualization"; + +export default function AllRangesPage() { + return ( +
+

All Weapon Ranges (Dev)

+ {weaponCategories.map((category) => { + const baseWeaponIds: MainWeaponId[] = category.weaponIds.flatMap( + (id): MainWeaponId[] => { + const weaponId = id as MainWeaponId; + return weaponIdToType(weaponId) === "BASE" ? [weaponId] : []; + }, + ); + + if (baseWeaponIds.length === 0) { + return null; + } + + return ( +
+

+ {category.name.toLowerCase()} +

+ +
+ ); + })} +
+ ); +} diff --git a/app/features/comp-analyzer/routes/comp-analyzer.tsx b/app/features/comp-analyzer/routes/comp-analyzer.tsx new file mode 100644 index 000000000..92cf7508b --- /dev/null +++ b/app/features/comp-analyzer/routes/comp-analyzer.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import type { MetaFunction, ShouldRevalidateFunction } from "react-router"; +import { Main } from "~/components/Main"; +import { Placeholder } from "~/components/Placeholder"; +import { useIsMounted } from "~/hooks/useIsMounted"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import type { SendouRouteHandle } from "~/utils/remix.server"; +import { COMP_ANALYZER_URL, navIconUrl } from "~/utils/urls"; +import { metaTags } from "../../../utils/remix"; +import { MAX_WEAPONS } from "../comp-analyzer-constants"; +import { useCategorization, useSelectedWeapons } from "../comp-analyzer-hooks"; +import { DamageComboList } from "../components/DamageComboBar"; +import { RangeVisualization } from "../components/RangeVisualization"; +import { SelectedWeapons } from "../components/SelectedWeapons"; +import { WeaponCategories } from "../components/WeaponCategories"; +import { WeaponGrid } from "../components/WeaponGrid"; + +export const meta: MetaFunction = (args) => { + return metaTags({ + title: "Composition Analyzer", + ogTitle: "Splatoon 3 composition analyzer", + location: args.location, + description: + "Analyze team compositions and discover damage combo synergies between weapons in Splatoon 3.", + }); +}; + +export const handle: SendouRouteHandle = { + i18n: ["weapons", "analyzer"], + breadcrumb: () => ({ + imgPath: navIconUrl("comp-analyzer"), + href: COMP_ANALYZER_URL, + type: "IMAGE", + }), +}; + +export const shouldRevalidate: ShouldRevalidateFunction = () => false; + +export default function CompAnalyzerShell() { + const isMounted = useIsMounted(); + + if (!isMounted) { + return ; + } + + return ; +} + +function CompAnalyzerPage() { + const [selectedWeaponIds, setSelectedWeaponIds] = useSelectedWeapons(); + const [categorization, setCategorization] = useCategorization(); + const [isGridCollapsed, setIsGridCollapsed] = useState( + selectedWeaponIds.length >= MAX_WEAPONS, + ); + + const handleWeaponClick = (weaponId: MainWeaponId) => { + if ( + selectedWeaponIds.length >= MAX_WEAPONS || + selectedWeaponIds.includes(weaponId) + ) { + return; + } + + const newSelectedWeapons = [...selectedWeaponIds, weaponId]; + setSelectedWeaponIds(newSelectedWeapons); + + if (newSelectedWeapons.length >= MAX_WEAPONS) { + setIsGridCollapsed(true); + } + }; + + const handleRemoveWeapon = (index: number) => { + setSelectedWeaponIds(selectedWeaponIds.filter((_, i) => i !== index)); + }; + + return ( +
+ + + setIsGridCollapsed(!isGridCollapsed)} + /> + + +
+ ); +} diff --git a/app/features/info/routes/contributions.tsx b/app/features/info/routes/contributions.tsx index c0dca1aef..cf34b01ad 100644 --- a/app/features/info/routes/contributions.tsx +++ b/app/features/info/routes/contributions.tsx @@ -120,6 +120,7 @@ export default function ContributionsPage() {
  • TORIPNG - {t("contributions:toripng")}
  • uberu - {t("contributions:uberu")}
  • yaga - {t("contributions:yaga")}
  • +
  • Centritide - {t("contributions:centritide")}
  • Antariska, yaga & harryXYZ - {t("contributions:antariska")}
  • diff --git a/app/features/links/links.json b/app/features/links/links.json index c97c6cd41..8fe49bd9d 100644 --- a/app/features/links/links.json +++ b/app/features/links/links.json @@ -98,5 +98,10 @@ "title": "Sunken scrolls", "url": "https://scrolls.tessaract.gay/", "description": "an archive for Splatoon guides that would otherwise be lost to time" + }, + { + "title": "Calculating weapon range sheet", + "url": "https://docs.google.com/spreadsheets/d/1nwuHQfBPCzV2FglB_9lGwPoZqWkrSqG78WBqn3Tfx80", + "description": "a spreadsheet by Centritide for calculating weapon ranges" } ] diff --git a/app/routes.ts b/app/routes.ts index 62418aa86..3505dd5b5 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -12,6 +12,10 @@ const devOnlyRoutes = "/admin/generate-images", "features/admin/routes/generate-images.tsx", ), + route( + "/comp-analyzer/all-ranges", + "features/comp-analyzer/routes/comp-analyzer.all-ranges.tsx", + ), ] satisfies RouteConfig) : []; @@ -80,6 +84,8 @@ export default [ route("/analyzer", "features/build-analyzer/routes/analyzer.tsx"), + route("/comp-analyzer", "features/comp-analyzer/routes/comp-analyzer.tsx"), + route( "/object-damage-calculator", "features/object-damage-calculator/routes/object-damage-calculator.tsx", diff --git a/app/styles/vars.css b/app/styles/vars.css index cccc9aaed..bc7fd39e8 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -46,6 +46,8 @@ html { --rounded-full: 200px; --rounded-xs: 8px; --rounded-sm: 10px; + --fonts-xxxl: 3rem; + --fonts-xxl: 2rem; --fonts-xl: 1.5rem; --fonts-lg: 1.2rem; --fonts-md: 1rem; diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 354a4a4da..6f9118cdd 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -103,6 +103,7 @@ export const PLANNER_URL = "/plans"; export const MAPS_URL = "/maps"; export const TIER_LIST_MAKER_URL = "/tier-list-maker"; export const ANALYZER_URL = "/analyzer"; +export const COMP_ANALYZER_URL = "/comp-analyzer"; export const OBJECT_DAMAGE_CALCULATOR_URL = "/object-damage-calculator"; export const VODS_PAGE = "/vods"; export const LEADERBOARDS_PAGE = "/leaderboards"; diff --git a/e2e/comp-analyzer.spec.ts b/e2e/comp-analyzer.spec.ts new file mode 100644 index 000000000..ff536b0ff --- /dev/null +++ b/e2e/comp-analyzer.spec.ts @@ -0,0 +1,232 @@ +import { expect, navigate, test } from "~/utils/playwright"; +import { COMP_ANALYZER_URL } from "~/utils/urls"; + +test.describe("Composition Analyzer", () => { + test("weapon selection, removal, and URL persistence", async ({ page }) => { + await navigate({ page, url: COMP_ANALYZER_URL }); + + const selectedWeapons = page.getByTestId("selected-weapons"); + await expect(selectedWeapons).toBeVisible(); + + // Initially no weapons selected - check for "Pick a weapon" text + await expect(page.getByText("Pick a weapon")).toHaveCount(4); + + // Click on Splattershot (weapon ID 40) + await page.getByTestId("weapon-button-40").click(); + + // First slot should now have the weapon + await expect(page.getByTestId("selected-weapon-0")).toBeVisible(); + await expect(page.getByText("Pick a weapon")).toHaveCount(3); + + // Remove the weapon + await page.getByTestId("remove-weapon-0").click(); + await expect(page.getByText("Pick a weapon")).toHaveCount(4); + + // Select 4 weapons to test auto-collapse + const categorizationToggle = page.getByTestId("categorization-toggle"); + await expect(categorizationToggle).toBeVisible(); + + await page.getByTestId("weapon-button-40").click(); + await page.getByTestId("weapon-button-50").click(); + await page.getByTestId("weapon-button-60").click(); + await page.getByTestId("weapon-button-70").click(); + + // Grid should auto-collapse + await expect(categorizationToggle).not.toBeVisible(); + + // URL should contain weapons parameter + const url = page.url(); + expect(url).toContain("weapons="); + + // Reload the page to test URL persistence + await page.reload(); + + // Weapons should still be selected after reload + await expect(page.getByTestId("selected-weapon-0")).toBeVisible(); + await expect(page.getByTestId("selected-weapon-1")).toBeVisible(); + await expect(page.getByTestId("selected-weapon-2")).toBeVisible(); + await expect(page.getByTestId("selected-weapon-3")).toBeVisible(); + }); + + test("weapon grid controls and categorization", async ({ page }) => { + await navigate({ page, url: COMP_ANALYZER_URL }); + + // Default is "category" - verify the radio is checked + const categoryRadio = page.getByTestId("categorization-category"); + await expect(categoryRadio).toBeChecked(); + + // Switch to sub categorization + await page.getByTestId("categorization-sub").click(); + await expect(page.getByTestId("categorization-sub")).toBeChecked(); + + // Switch to special categorization + await page.getByTestId("categorization-special").click(); + await expect(page.getByTestId("categorization-special")).toBeChecked(); + + // Test grid collapse/expand + const toggleButton = page.getByTestId("weapon-grid-toggle"); + const categorizationToggle = page.getByTestId("categorization-toggle"); + + // Grid should be expanded + await expect(categorizationToggle).toBeVisible(); + + // Collapse the grid + await toggleButton.click(); + await expect(categorizationToggle).not.toBeVisible(); + + // Expand the grid + await toggleButton.click(); + await expect(categorizationToggle).toBeVisible(); + + // Switch categorization and test URL persistence + await page.getByTestId("categorization-sub").click(); + const url = page.url(); + expect(url).toContain("categorization=sub"); + + // Reload the page + await page.reload(); + + // Categorization should still be sub after reload + await expect(page.getByTestId("categorization-sub")).toBeChecked(); + }); + + test("analysis sections appear and can be collapsed", async ({ page }) => { + await navigate({ page, url: COMP_ANALYZER_URL }); + + const damageComboList = page.getByTestId("damage-combo-list"); + const rangeVisualization = page.getByTestId("range-visualization"); + + // Both should not be visible initially + await expect(damageComboList).not.toBeVisible(); + await expect(rangeVisualization).not.toBeVisible(); + + // Select one weapon - damage combo still not visible + await page.getByTestId("weapon-button-40").click(); + await expect(damageComboList).not.toBeVisible(); + + // Select a second weapon with range data (blaster - ID 210) + await page.getByTestId("weapon-button-210").click(); + + // Both damage combo list and range visualization should now be visible + await expect(damageComboList).toBeVisible(); + await expect(rangeVisualization).toBeVisible(); + + // Test damage combo list collapse/expand + const damageComboToggle = page.getByTestId("damage-combo-toggle"); + + // Should be expanded by default - look for content inside + await expect( + damageComboList.locator(".content, [class*='content']"), + ).toBeVisible(); + + // Collapse + await damageComboToggle.click(); + await expect( + damageComboList.locator(".content, [class*='content']"), + ).not.toBeVisible(); + + // Expand again + await damageComboToggle.click(); + await expect( + damageComboList.locator(".content, [class*='content']"), + ).toBeVisible(); + }); + + test("damage combo sliders and filtering work correctly", async ({ + page, + }) => { + await navigate({ page, url: COMP_ANALYZER_URL }); + + // Select Splattershot Jr. (ID 10) which has Splat Bomb sub + await page.getByTestId("weapon-button-10").click(); + // Select Splattershot (ID 40) as second weapon + await page.getByTestId("weapon-button-40").click(); + + const damageComboList = page.getByTestId("damage-combo-list"); + await expect(damageComboList).toBeVisible(); + + // Part 1: Test Sub Defense slider + const sliders = damageComboList.locator("input[type='range']"); + const subDefenseSlider = sliders.first(); + + // Get initial damage values - find a combo row with sub weapon damage + const initialDamageValues = await damageComboList + .locator("[class*='damageValue']") + .allTextContents(); + + // Increase Sub Defense slider to max + await subDefenseSlider.fill("57"); + + // Get new damage values - they should be different (reduced for sub weapons) + const newDamageValues = await damageComboList + .locator("[class*='damageValue']") + .allTextContents(); + + // Verify damage values changed + expect(initialDamageValues.join(",")).not.toEqual( + newDamageValues.join(","), + ); + + // Reset slider + await subDefenseSlider.fill("0"); + + // Part 2: Test Ink Resistance slider + const inkResSlider = sliders.nth(1); + + // Get initial ink time frames if any exist + const initialInkTimes = await damageComboList + .locator("[class*='inkTime']") + .allTextContents(); + + // Increase Ink Resistance slider + await inkResSlider.fill("57"); + + // Get new ink time frames + const newInkTimes = await damageComboList + .locator("[class*='inkTime']") + .allTextContents(); + + // If there were ink times, they should have increased or more ink combos should appear + if (initialInkTimes.length > 0 || newInkTimes.length > 0) { + const initialTotalFrames = initialInkTimes.reduce( + (sum, t) => sum + (Number.parseInt(t, 10) || 0), + 0, + ); + const newTotalFrames = newInkTimes.reduce( + (sum, t) => sum + (Number.parseInt(t, 10) || 0), + 0, + ); + expect(newTotalFrames).toBeGreaterThanOrEqual(initialTotalFrames); + } + + // Part 3: Test damage type filtering + const damageTypeLabels = damageComboList.locator( + "[class*='damageTypeLabel']", + ); + const firstDamageTypeLabel = damageTypeLabels.first(); + + // Store the damage type text before clicking + const damageTypeText = await firstDamageTypeLabel.textContent(); + + // Verify no filtered items exist initially (use button selector to avoid matching filteredItemsRow) + const filteredItemsSelector = "button[class*='filteredItem']"; + await expect(damageComboList.locator(filteredItemsSelector)).toHaveCount(0); + + // Click to filter out this damage type + await firstDamageTypeLabel.click(); + + // Verify at least one filtered item appears + const filteredItems = damageComboList.locator(filteredItemsSelector); + const filteredCount = await filteredItems.count(); + expect(filteredCount).toBeGreaterThan(0); + + // Verify the first filtered item contains the expected damage type + await expect(filteredItems.first()).toContainText(damageTypeText ?? ""); + + // Click the first filtered item to restore it + await filteredItems.first().click(); + + // Verify the count decreased by one + await expect(filteredItems).toHaveCount(filteredCount - 1); + }); +}); diff --git a/locales/da/analyzer.json b/locales/da/analyzer.json index cad28d69f..3b242c734 100644 --- a/locales/da/analyzer.json +++ b/locales/da/analyzer.json @@ -168,5 +168,35 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.hits_one": "", + "comp.hits_other": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/da/common.json b/locales/da/common.json index 4f14c310e..3f72c54a3 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -9,6 +9,7 @@ "pages.faq": "FAQ", "pages.builds": "Sæt", "pages.analyzer": "Sætanalysator", + "pages.comp-analyzer": "", "pages.maps": "Banelister", "pages.plans": "Planlægger", "pages.object-damage-calculator": "Beregn Objektstade", diff --git a/locales/da/contributions.json b/locales/da/contributions.json index 8adad33dd..d3b6e918f 100644 --- a/locales/da/contributions.json +++ b/locales/da/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "Tegnede mini-Judd mens han holder et hjerte-emoji", "yaga": "Levede ikoner til special- og sekundære våben", + "centritide": "", "antariska": "Banebaggrundsbilleder til planlæggeren", "splatoon3ink": "X-kamp resultatsdata", "translation": "Oversættelse", diff --git a/locales/de/analyzer.json b/locales/de/analyzer.json index f6c7f1c8c..b2558829f 100644 --- a/locales/de/analyzer.json +++ b/locales/de/analyzer.json @@ -168,5 +168,35 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.hits_one": "", + "comp.hits_other": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/de/common.json b/locales/de/common.json index 1038231e0..de256d8db 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -9,6 +9,7 @@ "pages.faq": "FAQ", "pages.builds": "Ausrüstungen", "pages.analyzer": "Ausrüstungs-Analyse", + "pages.comp-analyzer": "", "pages.maps": "Arenen-Listen", "pages.plans": "Planer", "pages.object-damage-calculator": "Objekt-Schaden-Rechner", diff --git a/locales/de/contributions.json b/locales/de/contributions.json index 7755be2ad..0341ac459 100644 --- a/locales/de/contributions.json +++ b/locales/de/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "Zeichnete den Herz-haltenden Mini-Judd-Emoji", "yaga": "Stellte Icons von Sekundär- und Spezialwaffen zur Verfügung", + "centritide": "", "antariska": "Hintergrundbilder für den Arenenplaner", "splatoon3ink": "", "translation": "Übersetzung", diff --git a/locales/en/analyzer.json b/locales/en/analyzer.json index 3204300a2..6a33e823d 100644 --- a/locales/en/analyzer.json +++ b/locales/en/analyzer.json @@ -168,5 +168,35 @@ "damageReceiver.suffix.weakPoint": "{{weapon}} Weak Point", "damageReceiver.suffix.inhale": "{{weapon}} Inhale", "damageReceiver.suffix.canopy": "{{weapon}} Canopy", - "damageReceiver.suffix.canopyLaunched": "{{weapon}} Canopy (launched)" + "damageReceiver.suffix.canopyLaunched": "{{weapon}} Canopy (launched)", + "comp.groupBy": "Group by", + "comp.groupBy.category": "Weapon type", + "comp.groupBy.sub": "Sub weapon", + "comp.groupBy.special": "Special weapon", + "comp.selectWeapons": "Select up to {{max}} weapons", + "comp.removeWeapon": "Remove weapon", + "comp.pickWeapon": "Pick a weapon", + "comp.showWeaponGrid": "Show weapon selector", + "comp.hideWeaponGrid": "Hide weapon selector", + "comp.hits_one": "{{count}} hit", + "comp.hits_other": "{{count}} hits", + "comp.enemyRes": "Enemy Ink Resistance Up", + "comp.enemySubDef": "Enemy Sub Defense Up", + "comp.weaponRanges": "Weapon Ranges", + "comp.damageCombos": "Damage Combos", + "comp.subRoles": "Sub weapon roles", + "comp.specialRoles": "Special weapon roles", + "comp.subCategory.LETHAL": "One-hit", + "comp.subCategory.CHIP": "Chip", + "comp.subCategory.SEEKING": "Seeking", + "comp.subCategory.AREA_DENIAL": "Area Denial", + "comp.subCategory.MOBILITY": "Mobility", + "comp.subCategory.TRACKING": "Tracking", + "comp.subCategory.TURF": "Turf", + "comp.specialCategory.MELEE": "Melee", + "comp.specialCategory.RANGED_BURST": "Ranged Burst", + "comp.specialCategory.GLOBAL_TRACKING": "Global Tracking", + "comp.specialCategory.AREA_CONTROL": "Area Control", + "comp.specialCategory.TEAM_SHIELD": "Team Shield", + "comp.specialCategory.TEAM_BUFF": "Team Buff" } diff --git a/locales/en/common.json b/locales/en/common.json index 205634c75..c1512f364 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -9,6 +9,7 @@ "pages.faq": "FAQ", "pages.builds": "Builds", "pages.analyzer": "Build Analyzer", + "pages.comp-analyzer": "Comp Analyzer", "pages.maps": "Map Lists", "pages.plans": "Planner", "pages.object-damage-calculator": "DMG Calc", diff --git a/locales/en/contributions.json b/locales/en/contributions.json index 8ac527e3b..867b71e25 100644 --- a/locales/en/contributions.json +++ b/locales/en/contributions.json @@ -6,6 +6,7 @@ "toripng": "Made icons", "uberu": "Drew mini Judd holding heart emoji", "yaga": "Provided sub and special weapon icons", + "centritide": "Weapon range calculations", "antariska": "Map planner background images", "splatoon3ink": "X Battle results data", "translation": "Translation", diff --git a/locales/es-ES/analyzer.json b/locales/es-ES/analyzer.json index ce584106e..dafdfee4e 100644 --- a/locales/es-ES/analyzer.json +++ b/locales/es-ES/analyzer.json @@ -170,5 +170,36 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.hits_one": "", + "comp.hits_many": "", + "comp.hits_other": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 1b70d8615..466733a1c 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -9,6 +9,7 @@ "pages.faq": "FAQ", "pages.builds": "Builds", "pages.analyzer": "Analizador de builds", + "pages.comp-analyzer": "", "pages.maps": "Listas de mapas", "pages.plans": "Planeador", "pages.object-damage-calculator": "Calcula daño", diff --git a/locales/es-ES/contributions.json b/locales/es-ES/contributions.json index ff415a682..2b861b29a 100644 --- a/locales/es-ES/contributions.json +++ b/locales/es-ES/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "Dibujó el pequeño emoji the Justino abrazando un corazón", "yaga": "Proporcionó íconos de armas secundarias y especiales", + "centritide": "", "antariska": "Proporcionó las imagenes para el planeador", "splatoon3ink": "Da información de resultados de Combate X", "translation": "Traducción", diff --git a/locales/es-US/analyzer.json b/locales/es-US/analyzer.json index ce584106e..dafdfee4e 100644 --- a/locales/es-US/analyzer.json +++ b/locales/es-US/analyzer.json @@ -170,5 +170,36 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.hits_one": "", + "comp.hits_many": "", + "comp.hits_other": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/es-US/common.json b/locales/es-US/common.json index a9c395c43..ed6a088b0 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -9,6 +9,7 @@ "pages.faq": "Preguntas comunes", "pages.builds": "Builds", "pages.analyzer": "Analizador de builds", + "pages.comp-analyzer": "", "pages.maps": "Listas de escenarios", "pages.plans": "Planeador", "pages.object-damage-calculator": "Calcula daño", diff --git a/locales/es-US/contributions.json b/locales/es-US/contributions.json index ca0430279..50daba4c7 100644 --- a/locales/es-US/contributions.json +++ b/locales/es-US/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "Dibujó el pequeño emoji de Justino abrazando un corazón", "yaga": "Proporcionó íconos de armas secundarias y especiales", + "centritide": "", "antariska": "Proporcionó las imagenes para el planeador", "splatoon3ink": "La información de resultados de Combate X", "translation": "Traducción", diff --git a/locales/fr-CA/analyzer.json b/locales/fr-CA/analyzer.json index cae1668bb..23af57fe6 100644 --- a/locales/fr-CA/analyzer.json +++ b/locales/fr-CA/analyzer.json @@ -170,5 +170,36 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.hits_one": "", + "comp.hits_many": "", + "comp.hits_other": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 8859add05..900edde3a 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -9,6 +9,7 @@ "pages.faq": "FAQ", "pages.builds": "Sets", "pages.analyzer": "Analyseur de Sets", + "pages.comp-analyzer": "", "pages.maps": "Listes de Stages", "pages.plans": "Planificateur", "pages.object-damage-calculator": "Calculateur de dégâts", diff --git a/locales/fr-CA/contributions.json b/locales/fr-CA/contributions.json index 8ad41c362..cafea0a07 100644 --- a/locales/fr-CA/contributions.json +++ b/locales/fr-CA/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "A dessiné l'émote de Mini Charbitre portant un cœur", "yaga": "A fourni les icônes d'armes secondaires et spéciales", + "centritide": "", "antariska": "A fourni les images des stages pour l'outil de plannification", "splatoon3ink": "Données des résultats de Match X", "translation": "Traduction", diff --git a/locales/fr-EU/analyzer.json b/locales/fr-EU/analyzer.json index 491aa35e3..07d6ff773 100644 --- a/locales/fr-EU/analyzer.json +++ b/locales/fr-EU/analyzer.json @@ -170,5 +170,36 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.hits_one": "", + "comp.hits_many": "", + "comp.hits_other": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index b4cfe23c3..4b3382f37 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -9,6 +9,7 @@ "pages.faq": "FAQ", "pages.builds": "Sets", "pages.analyzer": "Analyseur de Sets", + "pages.comp-analyzer": "", "pages.maps": "Listes de Stages", "pages.plans": "Planificateur", "pages.object-damage-calculator": "Calculateur de dégâts", diff --git a/locales/fr-EU/contributions.json b/locales/fr-EU/contributions.json index 9229ebace..5a47c1d57 100644 --- a/locales/fr-EU/contributions.json +++ b/locales/fr-EU/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "A dessiné l'émote de Mini Charbitre portant un cœur", "yaga": "A fourni les icônes d'armes secondaires et spéciales", + "centritide": "", "antariska": "A fourni les images des stages pour l'outil de plannification", "splatoon3ink": "Données des résultats de Match X", "translation": "Traduction", diff --git a/locales/he/analyzer.json b/locales/he/analyzer.json index 5160cfa91..562b3d94d 100644 --- a/locales/he/analyzer.json +++ b/locales/he/analyzer.json @@ -170,5 +170,36 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.hits_one": "", + "comp.hits_two": "", + "comp.hits_other": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/he/common.json b/locales/he/common.json index 67ca64375..0879d187d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -9,6 +9,7 @@ "pages.faq": "שאלות ותשובות", "pages.builds": "ערכות", "pages.analyzer": "מנתח ערכות", + "pages.comp-analyzer": "", "pages.maps": "רשימת מפות", "pages.plans": "מתכנן", "pages.object-damage-calculator": "חישוב נזק", diff --git a/locales/he/contributions.json b/locales/he/contributions.json index a5989e31e..35d38862b 100644 --- a/locales/he/contributions.json +++ b/locales/he/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "צייר מיני ג'אד מחזיק אמוג'י לב", "yaga": "סיפק אייקונים של נשקי סאב וספיישל", + "centritide": "", "antariska": "תמונות רקע של מתכנן המפות", "splatoon3ink": "נתונים על תוצאות X Battle", "translation": "תרגום", diff --git a/locales/it/analyzer.json b/locales/it/analyzer.json index 06e82171c..e256e3862 100644 --- a/locales/it/analyzer.json +++ b/locales/it/analyzer.json @@ -170,5 +170,36 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.hits_one": "", + "comp.hits_many": "", + "comp.hits_other": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/it/common.json b/locales/it/common.json index c45b82b47..04e84f6d0 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -9,6 +9,7 @@ "pages.faq": "FAQ", "pages.builds": "Database Build", "pages.analyzer": "Analisi Build", + "pages.comp-analyzer": "", "pages.maps": "Liste Scenari", "pages.plans": "Pianificatore", "pages.object-damage-calculator": "Calcolatrice Danno", diff --git a/locales/it/contributions.json b/locales/it/contributions.json index 97dcb2af9..fe2b4e0f6 100644 --- a/locales/it/contributions.json +++ b/locales/it/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "Ha disegnato l'emoji del mini Giudigatto che tiene un cuore", "yaga": "Fornito icone per le armi secondarie e speciali", + "centritide": "", "antariska": "Immagini di sfondo del planner", "splatoon3ink": "Dati sui risultati delle Partite X", "translation": "Traduzioni", diff --git a/locales/ja/analyzer.json b/locales/ja/analyzer.json index b9d129dee..320dd4ea3 100644 --- a/locales/ja/analyzer.json +++ b/locales/ja/analyzer.json @@ -164,5 +164,33 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/ja/common.json b/locales/ja/common.json index 7e0f9467d..c5b2fde3a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -9,6 +9,7 @@ "pages.faq": "FAQ", "pages.builds": "ギア構成", "pages.analyzer": "ギア構成分析", + "pages.comp-analyzer": "", "pages.maps": "ステージ一覧", "pages.plans": "戦略プランナー", "pages.object-damage-calculator": "オブジェクトダメージ計算", diff --git a/locales/ja/contributions.json b/locales/ja/contributions.json index a75a3ceb8..320b40a11 100644 --- a/locales/ja/contributions.json +++ b/locales/ja/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "mini Judd holding heart 絵文字の作成", "yaga": "サブ/スペシャルウェポンアイコンの提供", + "centritide": "", "antariska": "Map プランナーのバックグラウンド画像", "splatoon3ink": "X ランクバトル結果", "translation": "翻訳", diff --git a/locales/ko/analyzer.json b/locales/ko/analyzer.json index d3fdc1390..ad06aa17b 100644 --- a/locales/ko/analyzer.json +++ b/locales/ko/analyzer.json @@ -164,5 +164,33 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/ko/common.json b/locales/ko/common.json index 3c52e9d87..52729f28d 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -9,6 +9,7 @@ "pages.faq": "FAQ", "pages.builds": "빌드", "pages.analyzer": "빌드 분석기", + "pages.comp-analyzer": "", "pages.maps": "맵 목록", "pages.plans": "계획표", "pages.object-damage-calculator": "대미지 계산기", diff --git a/locales/ko/contributions.json b/locales/ko/contributions.json index 5348ec24e..d734e2e3b 100644 --- a/locales/ko/contributions.json +++ b/locales/ko/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "하트 이모티콘을 든 꼬마심판을 그렸습니다", "yaga": "서브와 스페셜 웨펀 아이콘을 제공했습니다", + "centritide": "", "antariska": "맵 계획표 배경 이미지들", "splatoon3ink": "X 배틀 결과 데이터", "translation": "번역", diff --git a/locales/nl/analyzer.json b/locales/nl/analyzer.json index ee2c05cb8..f3a431c6b 100644 --- a/locales/nl/analyzer.json +++ b/locales/nl/analyzer.json @@ -168,5 +168,35 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.hits_one": "", + "comp.hits_other": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/nl/common.json b/locales/nl/common.json index cf881f032..a102a3a7d 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -9,6 +9,7 @@ "pages.faq": "FAQ", "pages.builds": "Uitrusting", "pages.analyzer": "Uitrusting Bestuderen", + "pages.comp-analyzer": "", "pages.maps": "Levellijsten", "pages.plans": "", "pages.object-damage-calculator": "", diff --git a/locales/nl/contributions.json b/locales/nl/contributions.json index 932f67e39..6ff436d37 100644 --- a/locales/nl/contributions.json +++ b/locales/nl/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "Heeft Justus jr. met een hart emoji getekend.", "yaga": "", + "centritide": "", "antariska": "", "splatoon3ink": "", "translation": "Vertaling", diff --git a/locales/pl/analyzer.json b/locales/pl/analyzer.json index c924e5df4..64fb9b82f 100644 --- a/locales/pl/analyzer.json +++ b/locales/pl/analyzer.json @@ -172,5 +172,37 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.hits_one": "", + "comp.hits_few": "", + "comp.hits_many": "", + "comp.hits_other": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/pl/common.json b/locales/pl/common.json index 2bbd3aa87..f40ab9eee 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -9,6 +9,7 @@ "pages.faq": "FAQ", "pages.builds": "Budowy", "pages.analyzer": "Analizator Budów", + "pages.comp-analyzer": "", "pages.maps": "Lista map", "pages.plans": "Planner", "pages.object-damage-calculator": "Kalkulator Szkód", diff --git a/locales/pl/contributions.json b/locales/pl/contributions.json index e36e43fcf..ddff3e83a 100644 --- a/locales/pl/contributions.json +++ b/locales/pl/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "Narysowała emotke Mini Judd'a trzymającego serce", "yaga": "Dostarczył ikony broni zapasowych i specjalnych", + "centritide": "", "antariska": "Stworzył/a tła do plannera map", "splatoon3ink": "", "translation": "Tłumaczenie", diff --git a/locales/pt-BR/analyzer.json b/locales/pt-BR/analyzer.json index bcfdb86ea..560dbd2c5 100644 --- a/locales/pt-BR/analyzer.json +++ b/locales/pt-BR/analyzer.json @@ -170,5 +170,36 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.hits_one": "", + "comp.hits_many": "", + "comp.hits_other": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 1175306c4..7d50364a6 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -9,6 +9,7 @@ "pages.faq": "Perguntas Frequentes", "pages.builds": "Builds", "pages.analyzer": "Analisador de Builds", + "pages.comp-analyzer": "", "pages.maps": "Listas de Mapas", "pages.plans": "Planejador", "pages.object-damage-calculator": "Calculadora de Dano", diff --git a/locales/pt-BR/contributions.json b/locales/pt-BR/contributions.json index b32dd0245..15292073d 100644 --- a/locales/pt-BR/contributions.json +++ b/locales/pt-BR/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "Desenhou o Lil Judd segurando o emoji de coração", "yaga": "Providenciou os ícones de armas secundárias e especiais", + "centritide": "", "antariska": "Planos de fundo do Planejador", "splatoon3ink": "Dados dos resultados do X Battle", "translation": "Tradução", diff --git a/locales/ru/analyzer.json b/locales/ru/analyzer.json index 911f01a44..6133da988 100644 --- a/locales/ru/analyzer.json +++ b/locales/ru/analyzer.json @@ -172,5 +172,37 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.hits_one": "", + "comp.hits_few": "", + "comp.hits_many": "", + "comp.hits_other": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/ru/common.json b/locales/ru/common.json index 82d622222..6434bef74 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -9,6 +9,7 @@ "pages.faq": "FAQ", "pages.builds": "Сборки", "pages.analyzer": "Анализатор сборок", + "pages.comp-analyzer": "", "pages.maps": "Списки карт", "pages.plans": "Планировщик", "pages.object-damage-calculator": "Калькулятор урона", diff --git a/locales/ru/contributions.json b/locales/ru/contributions.json index 91fe04569..549db390a 100644 --- a/locales/ru/contributions.json +++ b/locales/ru/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "Нарисовал Судокотика, держащего эмодзи-сердце", "yaga": "Предоставил иконки для запасного и особого оружия", + "centritide": "", "antariska": "Фоны для планировщика", "splatoon3ink": "Данные о результатах Боёв X", "translation": "Перевод", diff --git a/locales/zh/analyzer.json b/locales/zh/analyzer.json index 1f55108de..f9968a22a 100644 --- a/locales/zh/analyzer.json +++ b/locales/zh/analyzer.json @@ -164,5 +164,33 @@ "damageReceiver.suffix.weakPoint": "", "damageReceiver.suffix.inhale": "", "damageReceiver.suffix.canopy": "", - "damageReceiver.suffix.canopyLaunched": "" + "damageReceiver.suffix.canopyLaunched": "", + "comp.groupBy": "", + "comp.groupBy.category": "", + "comp.groupBy.sub": "", + "comp.groupBy.special": "", + "comp.selectWeapons": "", + "comp.removeWeapon": "", + "comp.pickWeapon": "", + "comp.showWeaponGrid": "", + "comp.hideWeaponGrid": "", + "comp.enemyRes": "", + "comp.enemySubDef": "", + "comp.weaponRanges": "", + "comp.damageCombos": "", + "comp.subRoles": "", + "comp.specialRoles": "", + "comp.subCategory.LETHAL": "", + "comp.subCategory.CHIP": "", + "comp.subCategory.SEEKING": "", + "comp.subCategory.AREA_DENIAL": "", + "comp.subCategory.MOBILITY": "", + "comp.subCategory.TRACKING": "", + "comp.subCategory.TURF": "", + "comp.specialCategory.MELEE": "", + "comp.specialCategory.RANGED_BURST": "", + "comp.specialCategory.GLOBAL_TRACKING": "", + "comp.specialCategory.AREA_CONTROL": "", + "comp.specialCategory.TEAM_SHIELD": "", + "comp.specialCategory.TEAM_BUFF": "" } diff --git a/locales/zh/common.json b/locales/zh/common.json index a8ec93dba..ecff79793 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -9,6 +9,7 @@ "pages.faq": "常见问题与解答", "pages.builds": "配装", "pages.analyzer": "配装分析器", + "pages.comp-analyzer": "", "pages.maps": "地图列表", "pages.plans": "地图规划", "pages.object-damage-calculator": "对物攻击计算器", diff --git a/locales/zh/contributions.json b/locales/zh/contributions.json index 649daf8c3..e271b322d 100644 --- a/locales/zh/contributions.json +++ b/locales/zh/contributions.json @@ -6,6 +6,7 @@ "toripng": "", "uberu": "制作小评审捧心的表情", "yaga": "提供副武器和特殊武器图标", + "centritide": "", "antariska": "地图规划背景图片", "splatoon3ink": "X比赛结果数据", "translation": "翻译", diff --git a/public/static-assets/img/layout/comp-analyzer.avif b/public/static-assets/img/layout/comp-analyzer.avif new file mode 100644 index 0000000000000000000000000000000000000000..1316ea1753befc06b95d410e78b28a8f714a5a32 GIT binary patch literal 5193 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 M07*qoM6N<$f}mfqZ2$lO literal 0 HcmV?d00001 diff --git a/scripts/create-analyzer-json.ts b/scripts/create-analyzer-json.ts index 78d96701c..581f58a62 100644 --- a/scripts/create-analyzer-json.ts +++ b/scripts/create-analyzer-json.ts @@ -214,6 +214,152 @@ function splitIntoBaseStatsAndKits( return { baseWeaponStats, weaponKits }; } +function getWeaponCategoryName(weaponId: number): string | undefined { + for (const category of [ + { name: "SHOOTERS", range: [0, 199] }, + { name: "BLASTERS", range: [200, 299] }, + { name: "ROLLERS", range: [1000, 1099] }, + { name: "BRUSHES", range: [1100, 1199] }, + { name: "CHARGERS", range: [2000, 2099] }, + { name: "SLOSHERS", range: [3000, 3099] }, + { name: "SPLATLINGS", range: [4000, 4099] }, + { name: "DUALIES", range: [5000, 5099] }, + { name: "BRELLAS", range: [6000, 6099] }, + { name: "STRINGERS", range: [7000, 7099] }, + { name: "SPLATANAS", range: [8000, 8099] }, + ]) { + if (weaponId >= category.range[0] && weaponId <= category.range[1]) { + return category.name; + } + } + return undefined; +} + +function extractRangeParams(weapon: MainWeapon, params: any) { + const category = getWeaponCategoryName(weapon.Id); + const isBrella = category === "BRELLAS"; + const isSlosher = category === "SLOSHERS"; + + if (category && !isBrella) { + return {}; + } + + const isCharger = params.MoveParam?.$type === "spl__BulletChargerMoveParam"; + + if (isCharger) { + return { + DistanceFullCharge: params.MoveParam?.DistanceFullCharge, + DistanceMaxCharge: params.MoveParam?.DistanceMaxCharge, + DistanceMinCharge: params.MoveParam?.DistanceMinCharge, + }; + } + + if (isSlosher) { + const units = params.UnitGroupParam?.Unit; + const unitForRange = units?.[0]; + const moveParam = unitForRange?.MoveParam; + return { + Range_SpawnSpeed: unitForRange?.SpawnSpeedGround, + Range_GoStraightStateEndMaxSpeed: moveParam?.GoStraightStateEndMaxSpeed, + Range_GoStraightToBrakeStateFrame: moveParam?.GoStraightToBrakeStateFrame, + Range_FreeGravity: moveParam?.FreeGravity, + Range_BrakeAirResist: moveParam?.BrakeAirResist, + Range_BrakeGravity: moveParam?.BrakeGravity, + Range_BrakeToFreeStateFrame: moveParam?.BrakeToFreeStateFrame, + Range_ZRate: params.spl__SpawnBulletAdditionMovePlayerParam?.ZRate, + }; + } + + const isRoller = category === "ROLLERS"; + if (isRoller) { + const unit = params.VerticalSwingUnitGroupParam?.Unit?.[0]; + const moveParam = unit?.UnitParam?.MoveParam; + return { + Range_SpawnSpeed: unit?.SpawnSpeedBase, + Range_GoStraightToBrakeStateFrame: moveParam?.GoStraightToBrakeStateFrame, + Range_FreeGravity: moveParam?.FreeGravity, + Range_FreeAirResist: moveParam?.FreeAirResist, + Range_ZRate: params.spl__SpawnBulletAdditionMovePlayerParam?.ZRate, + }; + } + + const isBrush = category === "BRUSHES"; + if (isBrush) { + const unit = params.SwingUnitGroupParam?.Unit?.[0]; + const moveParam = unit?.UnitParam?.MoveParam; + return { + Range_SpawnSpeed: unit?.SpawnSpeedBase, + Range_GoStraightToBrakeStateFrame: moveParam?.GoStraightToBrakeStateFrame, + Range_ZRate: params.spl__SpawnBulletAdditionMovePlayerParam?.ZRate, + }; + } + + const isSplatana = category === "SPLATANAS"; + if (isSplatana) { + const bulletParam = params.BulletSaberVerticalParam; + const moveParam = bulletParam?.MoveParam; + return { + Range_SpawnSpeed: moveParam?.SpawnSpeed, + Range_GoStraightStateEndMaxSpeed: moveParam?.GoStraightStateEndMaxSpeed, + Range_GoStraightToBrakeStateFrame: moveParam?.GoStraightToBrakeStateFrame, + Range_FreeGravity: moveParam?.FreeGravity, + Range_BrakeAirResist: moveParam?.BrakeAirResist, + Range_BrakeGravity: moveParam?.BrakeGravity, + Range_BrakeToFreeStateFrame: moveParam?.BrakeToFreeStateFrame, + Range_BurstFrame: bulletParam?.BurstParam?.BurstFrame, + }; + } + + const isStringer = category === "STRINGERS"; + if (isStringer) { + const bulletParam = params.spl__BulletStringerParam; + const moveParam = bulletParam?.MoveParam; + const isReefLux = + weapon.Id === 7020 || weapon.Id === 7021 || weapon.Id === 7022; + const blastRadius = isReefLux + ? undefined + : bulletParam?.DetonationParam?.BlastParam?.DistanceDamage?.[0]?.Distance; + return { + Range_SpawnSpeed: moveParam?.SpawnSpeedMax, + Range_GoStraightStateEndMaxSpeed: moveParam?.GoStraightStateEndMaxSpeed, + Range_GoStraightToBrakeStateFrame: moveParam?.GoStraightToBrakeStateFrame, + Range_FreeGravity: moveParam?.FreeGravity, + Range_BrakeAirResist: moveParam?.BrakeAirResist, + Range_BrakeGravity: moveParam?.BrakeGravity, + Range_BrakeToFreeStateFrame: moveParam?.BrakeToFreeStateFrame, + BlastRadius: blastRadius, + }; + } + + const isSplatling = category === "SPLATLINGS"; + if (isSplatling) { + const moveParam = params.MoveParam; + return { + Range_SpawnSpeed: moveParam?.SpawnSpeedFirstLastAndSecond, + Range_GoStraightStateEndMaxSpeed: moveParam?.GoStraightStateEndMaxSpeed, + Range_GoStraightToBrakeStateFrame: moveParam?.GoStraightToBrakeStateFrame, + }; + } + + const isBlaster = params.BlastParam?.DistanceDamage !== undefined; + const blastRadius = isBlaster + ? params.BlastParam?.DistanceDamage?.[1]?.Distance + : undefined; + + const moveParam = isBrella + ? params.spl__BulletShelterShotgunParam?.GroupParams?.[0]?.MoveParam + : params.MoveParam; + + return { + Range_SpawnSpeed: moveParam?.SpawnSpeed, + Range_GoStraightStateEndMaxSpeed: moveParam?.GoStraightStateEndMaxSpeed, + Range_GoStraightToBrakeStateFrame: moveParam?.GoStraightToBrakeStateFrame, + Range_FreeGravity: moveParam?.FreeGravity, + Range_ZRate: params.spl__SpawnBulletAdditionMovePlayerParam?.ZRate, + BlastRadius: blastRadius, + }; +} + function parametersToMainWeaponResult( weapon: MainWeapon, params: any, @@ -345,11 +491,14 @@ function parametersToMainWeaponResult( return Math.max(valueOne, valueTwo); }; + const rangeParams = extractRangeParams(weapon, params); + return { SpecialPoint: weapon.SpecialPoint, subWeaponId: resolveSubWeaponId(weapon), specialWeaponId: resolveSpecialWeaponId(weapon), overwrites: resolveOverwrites(params), + ...rangeParams, TripleShotSpanFrame: params.WeaponParam?.TripleShotSpanFrame, WeaponSpeedType: params.MainWeaponSetting?.WeaponSpeedType === "Mid"