mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Comp Analyzer (#2759)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
562cfab0d0
commit
0e89ee7bb7
|
|
@ -21,6 +21,11 @@ export const navItems = [
|
|||
url: "analyzer",
|
||||
prefetch: true,
|
||||
},
|
||||
{
|
||||
name: "comp-analyzer",
|
||||
url: "comp-analyzer",
|
||||
prefetch: true,
|
||||
},
|
||||
{
|
||||
name: "builds",
|
||||
url: "builds",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -727,7 +727,7 @@ function subWeaponIdToEffectKey(
|
|||
}
|
||||
}
|
||||
|
||||
function subWeaponDamageValue({
|
||||
export function subWeaponDamageValue({
|
||||
baseValue,
|
||||
subWeaponId,
|
||||
abilityPoints,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
78
app/features/comp-analyzer/comp-analyzer-constants.ts
Normal file
78
app/features/comp-analyzer/comp-analyzer-constants.ts
Normal file
|
|
@ -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<SubWeaponId, SubWeaponCategory> = {
|
||||
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];
|
||||
74
app/features/comp-analyzer/comp-analyzer-hooks.ts
Normal file
74
app/features/comp-analyzer/comp-analyzer-hooks.ts
Normal file
|
|
@ -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<CategorizationType>({
|
||||
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<MainWeaponId[]>({
|
||||
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<number>({
|
||||
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<number>({
|
||||
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),
|
||||
});
|
||||
}
|
||||
30
app/features/comp-analyzer/comp-analyzer-types.ts
Normal file
30
app/features/comp-analyzer/comp-analyzer-types.ts
Normal file
|
|
@ -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";
|
||||
}>;
|
||||
}
|
||||
304
app/features/comp-analyzer/components/DamageComboBar.module.css
Normal file
304
app/features/comp-analyzer/components/DamageComboBar.module.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
361
app/features/comp-analyzer/components/DamageComboBar.tsx
Normal file
361
app/features/comp-analyzer/components/DamageComboBar.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={styles.comboRow}>
|
||||
<div className={styles.barSection}>
|
||||
{inkTimeFrames ? (
|
||||
<InkTimeSegment
|
||||
inkDamage={inkDamage}
|
||||
totalDamage={totalWithInk}
|
||||
inkTimeFrames={inkTimeFrames}
|
||||
/>
|
||||
) : null}
|
||||
{combo.segments.map((segment, index) => (
|
||||
<SegmentBar
|
||||
key={index}
|
||||
segment={segment}
|
||||
totalDamage={totalWithInk}
|
||||
damageTypeLabel={t(`analyzer:damage.${segment.damageType}` as any)}
|
||||
onToggleFilter={onToggleFilter}
|
||||
/>
|
||||
))}
|
||||
{thresholdPosition < 100 && !inkTimeFrames ? (
|
||||
<div
|
||||
className={styles.thresholdLine}
|
||||
style={{ left: `${thresholdPosition}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.totalSection}>
|
||||
<span className={styles.totalDamage}>
|
||||
{combo.totalDamage.toFixed(1)}
|
||||
</span>
|
||||
<span className={styles.hitCount}>
|
||||
{t("analyzer:comp.hits", { count: combo.hitCount })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={styles.segmentWrapper}
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
>
|
||||
<div className={styles.segment} data-slot-color={slotColor}>
|
||||
<WeaponIcon
|
||||
weaponId={segment.weaponId}
|
||||
isSubWeapon={segment.isSubWeapon}
|
||||
isSpecialWeapon={segment.isSpecialWeapon}
|
||||
subWeaponId={params.subWeaponId}
|
||||
specialWeaponId={params.specialWeaponId}
|
||||
/>
|
||||
<span className={styles.damageValue}>{segment.damageValue}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.damageTypeLabel}
|
||||
onClick={handleFilterClick}
|
||||
>
|
||||
{damageTypeLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InkTimeSegmentProps {
|
||||
inkDamage: number;
|
||||
totalDamage: number;
|
||||
inkTimeFrames: number;
|
||||
}
|
||||
|
||||
function InkTimeSegment({
|
||||
inkDamage,
|
||||
totalDamage,
|
||||
inkTimeFrames,
|
||||
}: InkTimeSegmentProps) {
|
||||
const widthPercent = (inkDamage / totalDamage) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.segmentWrapper}
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
>
|
||||
<div className={styles.inkTimeSegment}>
|
||||
<Image path={abilityImageUrl("RES")} alt="" size={18} />
|
||||
<span className={styles.inkTimeDamage}>{inkDamage.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className={styles.inkTimeLabel}>{inkTimeFrames}f</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WeaponIconProps {
|
||||
weaponId: MainWeaponId;
|
||||
isSubWeapon: boolean;
|
||||
isSpecialWeapon: boolean;
|
||||
subWeaponId: SubWeaponId;
|
||||
specialWeaponId: SpecialWeaponId;
|
||||
}
|
||||
|
||||
function WeaponIcon({
|
||||
weaponId,
|
||||
isSubWeapon,
|
||||
isSpecialWeapon,
|
||||
subWeaponId,
|
||||
specialWeaponId,
|
||||
}: WeaponIconProps) {
|
||||
if (isSubWeapon) {
|
||||
return (
|
||||
<Image
|
||||
path={subWeaponImageUrl(subWeaponId)}
|
||||
alt=""
|
||||
size={18}
|
||||
className={styles.subSpecialWeaponIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSpecialWeapon) {
|
||||
return (
|
||||
<Image
|
||||
path={specialWeaponImageUrl(specialWeaponId)}
|
||||
alt=""
|
||||
size={18}
|
||||
className={styles.subSpecialWeaponIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WeaponImage
|
||||
weaponSplId={weaponId}
|
||||
variant="build"
|
||||
size={24}
|
||||
className={styles.weaponIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilteredItemProps {
|
||||
filterKey: ExcludedDamageKey;
|
||||
onRestore: (key: ExcludedDamageKey) => void;
|
||||
}
|
||||
|
||||
function FilteredItem({ filterKey, onRestore }: FilteredItemProps) {
|
||||
const { t } = useTranslation(["analyzer", "weapons"]);
|
||||
const params = mainWeaponParams(filterKey.weaponId);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filteredItem}
|
||||
onClick={() => onRestore(filterKey)}
|
||||
>
|
||||
<WeaponIcon
|
||||
weaponId={filterKey.weaponId}
|
||||
isSubWeapon={filterKey.weaponType === "sub"}
|
||||
isSpecialWeapon={filterKey.weaponType === "special"}
|
||||
subWeaponId={params.subWeaponId}
|
||||
specialWeaponId={params.specialWeaponId}
|
||||
/>
|
||||
<span>{t(`analyzer:damage.${filterKey.damageType}` as any)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface DamageComboListProps {
|
||||
weaponIds: MainWeaponId[];
|
||||
}
|
||||
|
||||
export function DamageComboList({ weaponIds }: DamageComboListProps) {
|
||||
const { t } = useTranslation(["analyzer"]);
|
||||
const [excludedKeys, setExcludedKeys] = useState<ExcludedDamageKey[]>([]);
|
||||
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 (
|
||||
<div className={styles.container} data-testid="damage-combo-list">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.header}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
data-testid="damage-combo-toggle"
|
||||
>
|
||||
<span className={styles.headerTitle}>
|
||||
{t("analyzer:comp.damageCombos")}
|
||||
</span>
|
||||
<span className={styles.collapseIcon}>{isCollapsed ? "+" : "-"}</span>
|
||||
</button>
|
||||
{isCollapsed ? null : (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.slidersContainer}>
|
||||
<Image path={abilityImageUrl("SRU")} alt="" size={24} />
|
||||
<label className={styles.resSliderLabel}>
|
||||
{t("analyzer:comp.enemySubDef")}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={MAX_AP}
|
||||
value={targetSubDefenseAp}
|
||||
onChange={(e) => setTargetSubDefenseAp(Number(e.target.value))}
|
||||
className={styles.resSlider}
|
||||
/>
|
||||
<span className={styles.resSliderValue}>
|
||||
{targetSubDefenseAp} AP
|
||||
</span>
|
||||
<Image path={abilityImageUrl("RES")} alt="" size={24} />
|
||||
<label className={styles.resSliderLabel}>
|
||||
{t("analyzer:comp.enemyRes")}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={MAX_AP}
|
||||
value={targetResAp}
|
||||
onChange={(e) => setTargetResAp(Number(e.target.value))}
|
||||
className={styles.resSlider}
|
||||
/>
|
||||
<span className={styles.resSliderValue}>{targetResAp} AP</span>
|
||||
</div>
|
||||
{excludedKeys.length > 0 ? (
|
||||
<div className={styles.filteredItemsRow}>
|
||||
{excludedKeys.map((key) => (
|
||||
<FilteredItem
|
||||
key={filterKeyToString(key)}
|
||||
filterKey={key}
|
||||
onRestore={handleToggleFilter}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{combos.map((combo, index) => {
|
||||
const inkTimeFrames = calculateInkTimeToKill(
|
||||
combo.totalDamage,
|
||||
targetResAp,
|
||||
);
|
||||
if (combo.totalDamage < 100 && inkTimeFrames === null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DamageComboBar
|
||||
key={index}
|
||||
combo={combo}
|
||||
inkTimeFrames={inkTimeFrames}
|
||||
onToggleFilter={handleToggleFilter}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
355
app/features/comp-analyzer/components/RangeVisualization.tsx
Normal file
355
app/features/comp-analyzer/components/RangeVisualization.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={styles.container} data-testid="range-visualization">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.header}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
data-testid="range-toggle"
|
||||
>
|
||||
<span className={styles.headerTitle}>
|
||||
{t("analyzer:comp.weaponRanges")}
|
||||
</span>
|
||||
<span className={styles.collapseIcon}>{isCollapsed ? "+" : "-"}</span>
|
||||
</button>
|
||||
{!isCollapsed ? (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.benchmarkLegend}>
|
||||
{BENCHMARK_WEAPON_IDS.map((weaponId) => (
|
||||
<div key={weaponId} className={styles.legendItem}>
|
||||
<span className={styles.legendLine} />
|
||||
<span className={styles.legendLabel}>
|
||||
{t(`weapons:MAIN_${weaponId}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<TrajectoryChart
|
||||
weapons={weaponsWithRange}
|
||||
benchmarkTrajectories={benchmarkTrajectories}
|
||||
maxRange={maxRange}
|
||||
minY={minY}
|
||||
maxY={maxY}
|
||||
weaponIds={weaponIds}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.chartContainer}>
|
||||
<div className={styles.weaponLegend}>
|
||||
{weapons.map((weapon, index) => {
|
||||
const slotIndex = weaponIds.indexOf(weapon.weaponId);
|
||||
const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length];
|
||||
return (
|
||||
<div
|
||||
key={`${weapon.weaponId}-${index}`}
|
||||
className={styles.weaponLegendItem}
|
||||
>
|
||||
<WeaponImage
|
||||
weaponSplId={weapon.weaponId}
|
||||
variant="build"
|
||||
size={24}
|
||||
/>
|
||||
<span
|
||||
className={styles.weaponLegendColor}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className={styles.weaponLegendRange}>
|
||||
{weapon.range.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<svg
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||
className={styles.chart}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<g transform={`translate(${padding.left}, ${padding.top})`}>
|
||||
{/* Grid lines */}
|
||||
{xTicks.map((tick) => (
|
||||
<line
|
||||
key={`x-grid-${tick}`}
|
||||
x1={xScale(tick)}
|
||||
y1={0}
|
||||
x2={xScale(tick)}
|
||||
y2={innerHeight}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
))}
|
||||
{yTicks.map((tick) => (
|
||||
<line
|
||||
key={`y-grid-${tick}`}
|
||||
x1={0}
|
||||
y1={yScale(tick)}
|
||||
x2={innerWidth}
|
||||
y2={yScale(tick)}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ground line (y=0) */}
|
||||
<line
|
||||
x1={0}
|
||||
y1={groundY}
|
||||
x2={innerWidth}
|
||||
y2={groundY}
|
||||
stroke="var(--text-lighter)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
|
||||
{/* Benchmark vertical lines */}
|
||||
{benchmarkTrajectories.map((benchmark, index) => {
|
||||
const x = xScale(benchmark.range);
|
||||
return (
|
||||
<line
|
||||
key={benchmark.id}
|
||||
x1={x}
|
||||
y1={0}
|
||||
x2={x}
|
||||
y2={innerHeight}
|
||||
stroke={BENCHMARK_COLORS[index % BENCHMARK_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4,4"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<path
|
||||
key={`${weapon.weaponId}-${index}`}
|
||||
d={trajectoryToPath(weapon.trajectory)}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<circle
|
||||
key={`blast-${weapon.weaponId}-${index}`}
|
||||
cx={xScale(groundPoint.z)}
|
||||
cy={yScale(0)}
|
||||
r={radiusPixels}
|
||||
fill={`${color}33`}
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X axis labels */}
|
||||
{xTicks.map((tick) => (
|
||||
<text
|
||||
key={`x-label-${tick}`}
|
||||
x={xScale(tick)}
|
||||
y={innerHeight + 15}
|
||||
textAnchor="middle"
|
||||
fontSize={10}
|
||||
fill="var(--text-lighter)"
|
||||
>
|
||||
{tick}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Y axis labels */}
|
||||
{yTicks.map((tick) => (
|
||||
<text
|
||||
key={`y-label-${tick}`}
|
||||
x={-8}
|
||||
y={yScale(tick) + 3}
|
||||
textAnchor="end"
|
||||
fontSize={10}
|
||||
fill="var(--text-lighter)"
|
||||
>
|
||||
{tick}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Axis labels */}
|
||||
<text
|
||||
x={innerWidth / 2}
|
||||
y={innerHeight + 25}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fill="var(--text-lighter)"
|
||||
>
|
||||
z
|
||||
</text>
|
||||
<text
|
||||
x={-25}
|
||||
y={innerHeight / 2}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fill="var(--text-lighter)"
|
||||
transform={`rotate(-90, -25, ${innerHeight / 2})`}
|
||||
>
|
||||
y
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof SelectedWeapons> = {
|
||||
selectedWeaponIds: [],
|
||||
onRemove: vi.fn(),
|
||||
};
|
||||
|
||||
function renderSelectedWeapons(
|
||||
props: Partial<ComponentProps<typeof SelectedWeapons>> = {},
|
||||
) {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: "/",
|
||||
element: <SelectedWeapons {...defaultProps} {...props} />,
|
||||
},
|
||||
],
|
||||
{ initialEntries: ["/"] },
|
||||
);
|
||||
|
||||
return render(<RouterProvider router={router} />);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
122
app/features/comp-analyzer/components/SelectedWeapons.module.css
Normal file
122
app/features/comp-analyzer/components/SelectedWeapons.module.css
Normal file
|
|
@ -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%;
|
||||
}
|
||||
93
app/features/comp-analyzer/components/SelectedWeapons.tsx
Normal file
93
app/features/comp-analyzer/components/SelectedWeapons.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={styles.selectedWeapons} data-testid="selected-weapons">
|
||||
{slots.map((weaponId, index) => {
|
||||
if (weaponId === null) {
|
||||
return (
|
||||
<div key={`empty-${index}`} className={styles.selectedWeaponRow}>
|
||||
<div className={styles.weaponImageContainerEmpty}>
|
||||
<Image path={abilityImageUrl("UNKNOWN")} alt="" size={48} />
|
||||
</div>
|
||||
<div className={styles.weaponNamePillEmpty}>
|
||||
<span className={styles.weaponNameEmpty}>
|
||||
{t("analyzer:comp.pickWeapon")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.subSpecialContainerSpacer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const params = mainWeaponParams(weaponId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={styles.selectedWeaponRow}
|
||||
data-testid={`selected-weapon-${index}`}
|
||||
>
|
||||
<div className={styles.weaponImageContainer}>
|
||||
<WeaponImage weaponSplId={weaponId} variant="build" size={48} />
|
||||
</div>
|
||||
<div className={styles.weaponNamePill}>
|
||||
<span className={styles.weaponName}>
|
||||
{t(`weapons:MAIN_${weaponId}`)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeButton}
|
||||
onClick={() => onRemove(index)}
|
||||
aria-label={t("analyzer:comp.removeWeapon")}
|
||||
data-testid={`remove-weapon-${index}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.subSpecialContainer}>
|
||||
<div className={styles.kitIcon}>
|
||||
<Image
|
||||
path={subWeaponImageUrl(params.subWeaponId)}
|
||||
alt={t(`weapons:SUB_${params.subWeaponId}`)}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.kitIcon}>
|
||||
<Image
|
||||
path={specialWeaponImageUrl(params.specialWeaponId)}
|
||||
alt={t(`weapons:SPECIAL_${params.specialWeaponId}`)}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
126
app/features/comp-analyzer/components/WeaponCategories.tsx
Normal file
126
app/features/comp-analyzer/components/WeaponCategories.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.categorySection}>
|
||||
<div className={styles.categoryLabel}>
|
||||
{t("analyzer:comp.subRoles")}
|
||||
</div>
|
||||
<div className={styles.categoryPill}>
|
||||
{sortedSubItems.map((item, index) => (
|
||||
<div
|
||||
key={`${item.weaponIndex}-${item.subId}`}
|
||||
className={styles.categoryItem}
|
||||
data-first={index === 0}
|
||||
>
|
||||
<Image
|
||||
path={subWeaponImageUrl(item.subId)}
|
||||
alt={t(`weapons:SUB_${item.subId}`)}
|
||||
size={20}
|
||||
/>
|
||||
<span className={styles.categoryName}>
|
||||
{t(`analyzer:comp.subCategory.${item.category}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.categorySection}>
|
||||
<div className={styles.categoryLabel}>
|
||||
{t("analyzer:comp.specialRoles")}
|
||||
</div>
|
||||
<div className={styles.categoryPill}>
|
||||
{sortedSpecialItems.map((item, index) => (
|
||||
<div
|
||||
key={`${item.weaponIndex}-${item.specialId}`}
|
||||
className={styles.categoryItem}
|
||||
data-first={index === 0}
|
||||
>
|
||||
<Image
|
||||
path={specialWeaponImageUrl(item.specialId)}
|
||||
alt={t(`weapons:SPECIAL_${item.specialId}`)}
|
||||
size={20}
|
||||
/>
|
||||
<span className={styles.categoryName}>
|
||||
{t(`analyzer:comp.specialCategory.${item.category}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof WeaponGrid> = {
|
||||
selectedWeaponIds: [],
|
||||
onWeaponClick: vi.fn(),
|
||||
categorization: "category",
|
||||
onCategorizationChange: vi.fn(),
|
||||
isCollapsed: false,
|
||||
onToggleCollapse: vi.fn(),
|
||||
};
|
||||
|
||||
function renderWeaponGrid(
|
||||
props: Partial<ComponentProps<typeof WeaponGrid>> = {},
|
||||
) {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: "/",
|
||||
element: <WeaponGrid {...defaultProps} {...props} />,
|
||||
},
|
||||
],
|
||||
{ initialEntries: ["/"] },
|
||||
);
|
||||
|
||||
return render(<RouterProvider router={router} />);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
app/features/comp-analyzer/components/WeaponGrid.module.css
Normal file
104
app/features/comp-analyzer/components/WeaponGrid.module.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
204
app/features/comp-analyzer/components/WeaponGrid.tsx
Normal file
204
app/features/comp-analyzer/components/WeaponGrid.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={styles.container} data-testid="weapon-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapseToggle}
|
||||
onClick={onToggleCollapse}
|
||||
data-testid="weapon-grid-toggle"
|
||||
>
|
||||
<span
|
||||
className={`${styles.collapseArrow} ${isCollapsed ? styles.collapseArrowCollapsed : ""}`}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
<span>
|
||||
{isCollapsed
|
||||
? t("analyzer:comp.showWeaponGrid")
|
||||
: t("analyzer:comp.hideWeaponGrid")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
<div>
|
||||
<Label>{t("analyzer:comp.groupBy")}</Label>
|
||||
<div
|
||||
className={styles.categorizationToggle}
|
||||
data-testid="categorization-toggle"
|
||||
>
|
||||
<label className="stack horizontal sm items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="categorization"
|
||||
value="category"
|
||||
checked={categorization === "category"}
|
||||
onChange={() => onCategorizationChange("category")}
|
||||
data-testid="categorization-category"
|
||||
/>
|
||||
<span>{t("analyzer:comp.groupBy.category")}</span>
|
||||
</label>
|
||||
<label className="stack horizontal sm items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="categorization"
|
||||
value="sub"
|
||||
checked={categorization === "sub"}
|
||||
onChange={() => onCategorizationChange("sub")}
|
||||
data-testid="categorization-sub"
|
||||
/>
|
||||
<span>{t("analyzer:comp.groupBy.sub")}</span>
|
||||
</label>
|
||||
<label className="stack horizontal sm items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="categorization"
|
||||
value="special"
|
||||
checked={categorization === "special"}
|
||||
onChange={() => onCategorizationChange("special")}
|
||||
data-testid="categorization-special"
|
||||
/>
|
||||
<span>{t("analyzer:comp.groupBy.special")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.weaponGrid}>
|
||||
{groupedWeapons.map((group) => (
|
||||
<div key={group.key} className={styles.categorySection}>
|
||||
<div className={styles.categoryHeader}>
|
||||
{group.iconPath ? (
|
||||
<Image path={group.iconPath} alt="" size={24} />
|
||||
) : null}
|
||||
<span className={styles.categoryName}>
|
||||
{group.name.startsWith("SUB_") ||
|
||||
group.name.startsWith("SPECIAL_")
|
||||
? t(`weapons:${group.name}` as "SUB_0")
|
||||
: group.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.categoryWeapons}>
|
||||
{group.weaponIds.map((weaponId) => {
|
||||
const isSelected = selectedWeaponIds.includes(weaponId);
|
||||
const isDisabled = !isSelected && isMaxSelected;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={weaponId}
|
||||
type="button"
|
||||
className={`${styles.weaponButton} ${isSelected ? styles.weaponButtonSelected : ""}`}
|
||||
onClick={() => onWeaponClick(weaponId)}
|
||||
disabled={isDisabled}
|
||||
title={t(`weapons:MAIN_${weaponId}`)}
|
||||
data-testid={`weapon-button-${weaponId}`}
|
||||
>
|
||||
<WeaponImage
|
||||
weaponSplId={weaponId}
|
||||
variant="build"
|
||||
size={32}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
311
app/features/comp-analyzer/core/damage-combinations.test.ts
Normal file
311
app/features/comp-analyzer/core/damage-combinations.test.ts
Normal file
|
|
@ -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<string, number>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
316
app/features/comp-analyzer/core/damage-combinations.ts
Normal file
316
app/features/comp-analyzer/core/damage-combinations.ts
Normal file
|
|
@ -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<string>,
|
||||
): 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<number>;
|
||||
typeCountMap: Map<DamageType, number>;
|
||||
slotDamageType: Map<number, DamageType>;
|
||||
}
|
||||
|
||||
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
|
||||
192
app/features/comp-analyzer/core/weapon-range.ts
Normal file
192
app/features/comp-analyzer/core/weapon-range.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Main className="stack lg">
|
||||
<h1>All Weapon Ranges (Dev)</h1>
|
||||
{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 (
|
||||
<section key={category.name}>
|
||||
<h2 style={{ textTransform: "capitalize" }}>
|
||||
{category.name.toLowerCase()}
|
||||
</h2>
|
||||
<RangeVisualization weaponIds={baseWeaponIds} />
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
95
app/features/comp-analyzer/routes/comp-analyzer.tsx
Normal file
95
app/features/comp-analyzer/routes/comp-analyzer.tsx
Normal file
|
|
@ -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 <Placeholder />;
|
||||
}
|
||||
|
||||
return <CompAnalyzerPage />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Main className="stack lg">
|
||||
<SelectedWeapons
|
||||
selectedWeaponIds={selectedWeaponIds}
|
||||
onRemove={handleRemoveWeapon}
|
||||
/>
|
||||
<WeaponCategories selectedWeaponIds={selectedWeaponIds} />
|
||||
<WeaponGrid
|
||||
selectedWeaponIds={selectedWeaponIds}
|
||||
onWeaponClick={handleWeaponClick}
|
||||
categorization={categorization}
|
||||
onCategorizationChange={setCategorization}
|
||||
isCollapsed={isGridCollapsed}
|
||||
onToggleCollapse={() => setIsGridCollapsed(!isGridCollapsed)}
|
||||
/>
|
||||
<RangeVisualization weaponIds={selectedWeaponIds} />
|
||||
<DamageComboList weaponIds={selectedWeaponIds} />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
|
@ -120,6 +120,7 @@ export default function ContributionsPage() {
|
|||
<li>TORIPNG - {t("contributions:toripng")}</li>
|
||||
<li>uberu - {t("contributions:uberu")}</li>
|
||||
<li>yaga - {t("contributions:yaga")}</li>
|
||||
<li>Centritide - {t("contributions:centritide")}</li>
|
||||
<li>Antariska, yaga & harryXYZ - {t("contributions:antariska")}</li>
|
||||
<li>
|
||||
<a href={SPLATOON_3_INK} target="_blank" rel="noreferrer">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
232
e2e/comp-analyzer.spec.ts
Normal file
232
e2e/comp-analyzer.spec.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"pages.faq": "שאלות ותשובות",
|
||||
"pages.builds": "ערכות",
|
||||
"pages.analyzer": "מנתח ערכות",
|
||||
"pages.comp-analyzer": "",
|
||||
"pages.maps": "רשימת מפות",
|
||||
"pages.plans": "מתכנן",
|
||||
"pages.object-damage-calculator": "חישוב נזק",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"toripng": "",
|
||||
"uberu": "צייר מיני ג'אד מחזיק אמוג'י לב",
|
||||
"yaga": "סיפק אייקונים של נשקי סאב וספיישל",
|
||||
"centritide": "",
|
||||
"antariska": "תמונות רקע של מתכנן המפות",
|
||||
"splatoon3ink": "נתונים על תוצאות X Battle",
|
||||
"translation": "תרגום",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"pages.faq": "FAQ",
|
||||
"pages.builds": "ギア構成",
|
||||
"pages.analyzer": "ギア構成分析",
|
||||
"pages.comp-analyzer": "",
|
||||
"pages.maps": "ステージ一覧",
|
||||
"pages.plans": "戦略プランナー",
|
||||
"pages.object-damage-calculator": "オブジェクトダメージ計算",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"toripng": "",
|
||||
"uberu": "mini Judd holding heart 絵文字の作成",
|
||||
"yaga": "サブ/スペシャルウェポンアイコンの提供",
|
||||
"centritide": "",
|
||||
"antariska": "Map プランナーのバックグラウンド画像",
|
||||
"splatoon3ink": "X ランクバトル結果",
|
||||
"translation": "翻訳",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"pages.faq": "FAQ",
|
||||
"pages.builds": "빌드",
|
||||
"pages.analyzer": "빌드 분석기",
|
||||
"pages.comp-analyzer": "",
|
||||
"pages.maps": "맵 목록",
|
||||
"pages.plans": "계획표",
|
||||
"pages.object-damage-calculator": "대미지 계산기",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"toripng": "",
|
||||
"uberu": "하트 이모티콘을 든 꼬마심판을 그렸습니다",
|
||||
"yaga": "서브와 스페셜 웨펀 아이콘을 제공했습니다",
|
||||
"centritide": "",
|
||||
"antariska": "맵 계획표 배경 이미지들",
|
||||
"splatoon3ink": "X 배틀 결과 데이터",
|
||||
"translation": "번역",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"toripng": "",
|
||||
"uberu": "Heeft Justus jr. met een hart emoji getekend.",
|
||||
"yaga": "",
|
||||
"centritide": "",
|
||||
"antariska": "",
|
||||
"splatoon3ink": "",
|
||||
"translation": "Vertaling",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"pages.faq": "FAQ",
|
||||
"pages.builds": "Сборки",
|
||||
"pages.analyzer": "Анализатор сборок",
|
||||
"pages.comp-analyzer": "",
|
||||
"pages.maps": "Списки карт",
|
||||
"pages.plans": "Планировщик",
|
||||
"pages.object-damage-calculator": "Калькулятор урона",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"toripng": "",
|
||||
"uberu": "Нарисовал Судокотика, держащего эмодзи-сердце",
|
||||
"yaga": "Предоставил иконки для запасного и особого оружия",
|
||||
"centritide": "",
|
||||
"antariska": "Фоны для планировщика",
|
||||
"splatoon3ink": "Данные о результатах Боёв X",
|
||||
"translation": "Перевод",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"pages.faq": "常见问题与解答",
|
||||
"pages.builds": "配装",
|
||||
"pages.analyzer": "配装分析器",
|
||||
"pages.comp-analyzer": "",
|
||||
"pages.maps": "地图列表",
|
||||
"pages.plans": "地图规划",
|
||||
"pages.object-damage-calculator": "对物攻击计算器",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"toripng": "",
|
||||
"uberu": "制作小评审捧心的表情",
|
||||
"yaga": "提供副武器和特殊武器图标",
|
||||
"centritide": "",
|
||||
"antariska": "地图规划背景图片",
|
||||
"splatoon3ink": "X比赛结果数据",
|
||||
"translation": "翻译",
|
||||
|
|
|
|||
BIN
public/static-assets/img/layout/comp-analyzer.avif
Normal file
BIN
public/static-assets/img/layout/comp-analyzer.avif
Normal file
Binary file not shown.
BIN
public/static-assets/img/layout/comp-analyzer.png
Normal file
BIN
public/static-assets/img/layout/comp-analyzer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user