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 000000000..1316ea175 Binary files /dev/null and b/public/static-assets/img/layout/comp-analyzer.avif differ diff --git a/public/static-assets/img/layout/comp-analyzer.png b/public/static-assets/img/layout/comp-analyzer.png new file mode 100644 index 000000000..cbd0e03de Binary files /dev/null and b/public/static-assets/img/layout/comp-analyzer.png differ 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"