import { type LinksFunction, type V2_MetaFunction } from "@remix-run/node"; import type { ShouldRevalidateFunction } from "@remix-run/react"; import { Link } from "@remix-run/react"; import * as React from "react"; import { useTranslation } from "~/hooks/useTranslation"; import { AbilitiesSelector } from "~/components/AbilitiesSelector"; import { Ability } from "~/components/Ability"; import { WeaponCombobox } from "~/components/Combobox"; import { Image } from "~/components/Image"; import { Main } from "~/components/Main"; import { Popover } from "~/components/Popover"; import { Toggle } from "~/components/Toggle"; import { useSetTitle } from "~/hooks/useSetTitle"; import { ANGLE_SHOOTER_ID, INK_MINE_ID, INK_STORM_ID, isAbility, POINT_SENSOR_ID, TOXIC_MIST_ID, TORPEDO_ID, type BuildAbilitiesTupleWithUnknown, type MainWeaponId, type SubWeaponId, abilitiesShort, } from "~/modules/in-game-lists"; import styles from "../analyzer.css"; import { damageTypeTranslationString } from "~/utils/i18next"; import { type SendouRouteHandle } from "~/utils/remix"; import { makeTitle } from "~/utils/strings"; import { ANALYZER_URL, mainWeaponImageUrl, navIconUrl, objectDamageCalculatorPage, specialWeaponImageUrl, subWeaponImageUrl, userNewBuildPage, } from "~/utils/urls"; import clsx from "clsx"; import { ABILITIES_WITHOUT_CHUNKS, getAbilityChunksMapAsArray, } from "../core/abilityChunksCalc"; import type { AbilityPoints, AnalyzedBuild, SpecialEffectType, Stat, } from "../analyzer-types"; import { lastDitchEffortIntensityToAp, SPECIAL_EFFECTS, } from "../core/specialEffects"; import { damageTypeToWeaponType, MAX_LDE_INTENSITY, } from "../analyzer-constants"; import { useAnalyzeBuild } from "../analyzer-hooks"; import { Tabs, Tab } from "~/components/Tabs"; import { buildIsEmpty, damageIsSubWeaponDamage, isStackableAbility, } from "../core/utils"; import { useUser } from "~/modules/auth"; import { atOrError } from "~/utils/arrays"; export const CURRENT_PATCH = "3.1"; export const meta: V2_MetaFunction = () => { return [ { title: makeTitle("Build Analyzer") }, { name: "description", content: "Detailed stats for any weapon and build in Splatoon 3.", }, ]; }; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; }; export const handle: SendouRouteHandle = { i18n: ["weapons", "analyzer"], breadcrumb: () => ({ imgPath: navIconUrl("analyzer"), href: ANALYZER_URL, type: "IMAGE", }), }; // Resolves this Github issue: https://github.com/Sendouc/sendou.ink/issues/1053 export const shouldRevalidate: ShouldRevalidateFunction = () => false; export default function BuildAnalyzerPage() { const user = useUser(); const { t } = useTranslation(["analyzer", "common", "weapons"]); useSetTitle(t("common:pages.analyzer")); const { build, build2, focusedBuild, mainWeaponId, handleChange, analyzed, analyzed2, focused, abilityPoints, abilityPoints2, ldeIntensity, allEffects, } = useAnalyzeBuild(); const statKeyToTuple = (key: keyof AnalyzedBuild["stats"]) => { return [analyzed.stats[key], analyzed2.stats[key]] as [Stat, Stat]; }; const objectShredderSelected = build[2][0] === "OS" || build2[2][0] === "OS"; const isComparing = !buildIsEmpty(build) && !buildIsEmpty(build2); const mainWeaponCategoryItems = [ analyzed.stats.shotSpreadAir && ( ), typeof analyzed.stats.shotSpreadGround === "number" && ( ), typeof analyzed.stats.mainWeaponWhiteInkSeconds === "number" && ( ), typeof analyzed.weapon.brellaCanopyHp === "number" && ( ), typeof analyzed.weapon.fullChargeSeconds === "number" && ( ), typeof analyzed.weapon.maxChargeHoldSeconds === "number" && ( ), ].filter(Boolean); // Handles edge case where a primary slot-only ability (e.g. Ninja Squid) is selected & the 'abilityPoints' count is still 0, // and also fixes an edge case with Ability Doubler as the only ability in the build const showAbilityChunksRequired: boolean = build.some( (gear) => gear.filter((ability) => !ABILITIES_WITHOUT_CHUNKS.has(ability)).length ); return (
opt && handleChange({ newMainWeaponId: Number(opt.value) as MainWeaponId, }) } fullWidth />
handleChange({ newFocused: 1 })} testId="build1-tab" > {t("analyzer:build1")} handleChange({ newFocused: 2 })} testId="build2-tab" > {t("analyzer:build2")} handleChange({ newFocused: 3 })} testId="ap-tab" > {t("analyzer:compare")} {focusedBuild ? ( { const firstBuildIsEmpty = build .flat() .every((ability) => ability === "UNKNOWN"); const buildWasEmptied = !firstBuildIsEmpty && newBuild .flat() .every((ability) => ability === "UNKNOWN") && focused === 1; // if we don't do this the // build2 would be duplicated if (buildWasEmptied) { handleChange({ newBuild: build2, newBuild2: newBuild, newFocused: 1, }); return; } handleChange({ [focused === 1 || firstBuildIsEmpty ? "newBuild" : "newBuild2"]: newBuild, newFocused: firstBuildIsEmpty ? 1 : undefined, }); }} /> ) : ( )}
handleChange({ newLdeIntensity }) } handleAddEffect={(newEffect) => handleChange({ newEffects: [...allEffects, newEffect] }) } handleRemoveEffect={(effectToRemove) => handleChange({ newEffects: allEffects.filter((e) => e !== effectToRemove), }) } effects={allEffects} /> {showAbilityChunksRequired && ( )}
{t("analyzer:patch")} {CURRENT_PATCH}
{mainWeaponCategoryItems.length > 0 && ( {t(`weapons:MAIN_${mainWeaponId}`)} {t(`weapons:MAIN_${mainWeaponId}`)}
} > {mainWeaponCategoryItems} )} {t(`weapons:SUB_${analyzed.weapon.subWeaponSplId}`)} {t(`weapons:SUB_${analyzed.weapon.subWeaponSplId}`)}
} > {analyzed.stats.subVelocity && ( )} {analyzed.stats.subFirstPhaseDuration && ( )} {analyzed.stats.subSecondPhaseDuration && ( )} {analyzed.stats.subMarkingTimeInSeconds && ( )} {analyzed.stats.subMarkingRadius && ( )} {analyzed.stats.subExplosionRadius && ( )} {analyzed.stats.subHp && ( )} {analyzed.stats.subQsjBoost && ( )} {t( {t(`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`)} } > {analyzed.stats.specialDurationInSeconds && ( )} {analyzed.stats.specialDamageDistance && ( )} {analyzed.stats.specialPaintRadius && ( )} {analyzed.stats.specialFieldHp && ( )} {analyzed.stats.specialDeviceHp && ( )} {analyzed.stats.specialHookInkConsumptionPercentage && ( )} {analyzed.stats.specialInkConsumptionPerSecondPercentage && ( )} {analyzed.stats.specialReticleRadius && ( )} {analyzed.stats.specialThrowDistance && ( )} {analyzed.stats.specialAutoChargeRate && ( )} {analyzed.stats.specialMaxRadius && ( )} {analyzed.stats.specialRadiusRange && ( )} {analyzed.stats.specialPowerUpDuration && ( )} {analyzed.stats.subWeaponDefenseDamages.length > 0 && ( {(["SRU"] as const).some( (ability) => (abilityPoints.get(ability) ?? 0) > 0 ) ? (
) : null} )} {analyzed.stats.damages.length > 0 && ( )} {analyzed.stats.fullInkTankOptions.length > 0 && ( {(["ISM", "ISS"] as const).some( (ability) => (abilityPoints.get(ability) ?? 0) > 0 ) ? (
) : null} )} {analyzed.stats.shootingRunSpeed && ( )} {analyzed.stats.shootingRunSpeedCharging && ( )} {analyzed.stats.shootingRunSpeedFullCharge && ( )} {objectShredderSelected && ( {t("analyzer:objCalcAd")} )} {user && focusedBuild && !buildIsEmpty(focusedBuild) ? ( {t("analyzer:newBuildPrompt")} ) : null}
); } function APCompare({ abilityPoints, abilityPoints2, build, build2, }: { abilityPoints: AbilityPoints; abilityPoints2: AbilityPoints; build: BuildAbilitiesTupleWithUnknown; build2: BuildAbilitiesTupleWithUnknown; }) { const { t } = useTranslation(["analyzer"]); const buildMains = build .flat() .filter((ability) => !isStackableAbility(ability) && ability !== "UNKNOWN"); const build2Mains = build2 .flat() .filter((ability) => !isStackableAbility(ability) && ability !== "UNKNOWN"); const hasAtLeastOneMainOnlyAbility = buildMains.length > 0 || build2Mains.length > 0; return (
{hasAtLeastOneMainOnlyAbility ? ( <>
{buildMains.map((ability) => ( ))}
{build2Mains.map((ability) => ( ))}
) : null} {([...abilitiesShort, "UNKNOWN"] as const).map((ability) => { const ap = abilityPoints.get(ability) ?? 0; const ap2 = abilityPoints2.get(ability) ?? 0; if (!ap && !ap2) return null; return (
{ap} {t("analyzer:abilityPoints.short")}
= ap2, })} style={{ width: `${ap}px` }} />
{ap2} {t("analyzer:abilityPoints.short")}
); })}
); } function EffectsSelector({ build, build2, effects, ldeIntensity, handleLdeIntensityChange, handleAddEffect, handleRemoveEffect, }: { build: BuildAbilitiesTupleWithUnknown; build2: BuildAbilitiesTupleWithUnknown; effects: Array; ldeIntensity: number; handleLdeIntensityChange: (newLdeIntensity: number) => void; handleAddEffect: (effect: SpecialEffectType) => void; handleRemoveEffect: (effect: SpecialEffectType) => void; }) { const { t } = useTranslation(["weapons", "analyzer"]); const effectsToShow = SPECIAL_EFFECTS.filter( (effect) => !isAbility(effect.type) || build.flat().includes(effect.type) || build2.flat().includes(effect.type) ).reverse(); // reverse to show Tacticooler first as it always shows return (
{effectsToShow.map((effect) => { return (
{isAbility(effect.type) ? ( ) : ( {t("weapons:SPECIAL_15")} )}
{effect.type === "LDE" ? ( ) : ( checked ? handleAddEffect(effect.type) : handleRemoveEffect(effect.type) } tiny /> )}
); })}
); } function AbilityChunksRequired({ build, }: { build: BuildAbilitiesTupleWithUnknown; }) { const { t } = useTranslation("analyzer"); const abilityChunksMapAsArray = getAbilityChunksMapAsArray(build); return (
{t("abilityChunks")}
{abilityChunksMapAsArray.map((a) => { const mainAbilityName = a[0]; const numChunksRequired = a[1]; return (
{numChunksRequired}
); })}
); } function StatCategory({ title, children, containerClassName = "analyzer__stat-collection", textBelow, summaryRightContent, testId, }: { title: string; children: React.ReactNode; containerClassName?: string; textBelow?: string; summaryRightContent?: React.ReactNode; testId?: string; }) { return (
{title} {summaryRightContent}
{children}
{textBelow && (
{textBelow}
)}
); } function StatCard({ title, stat, suffix, popoverInfo, abilityPoints, isComparing, testId, }: { title: string; stat: [Stat, Stat] | [Stat, Stat] | number | string; suffix?: string; popoverInfo?: string; abilityPoints: AbilityPoints; isComparing: boolean; testId?: string; }) { const { t } = useTranslation("analyzer"); const isStaticValue = typeof stat === "number" || typeof stat === "string"; const baseValue = isStaticValue ? stat : stat[0].baseValue; const showBuildValue = () => { if (isStaticValue) return false; if (isComparing) return true; // slightly hacky but handles the edge case // where baseValue === value which can happen when // you have Ninja Squid and stack swim speed // -> we still want to show the build value return [stat[0].modifiedBy].flat().some((ability) => { const hasStackable = (abilityPoints.get(ability) ?? 0) > 0; const hasEffect = baseValue !== stat[0].value; return hasEffect || hasStackable; }); }; const showComparison = isComparing && !isStaticValue; const isHighlighted = () => { if (!showComparison) return showBuildValue(); return ( stat[0].value !== stat[0].baseValue || stat[1].value !== stat[1].baseValue ); }; return (

{title}{" "} {popoverInfo && ( ?} > {popoverInfo} )}

{typeof stat === "number" ? t("value") : showComparison ? t("build1") : t("base")}

{" "}
{showComparison ? (stat as [Stat, Stat])[0].value : baseValue} {suffix}
{showBuildValue() ? (

{showComparison ? t("build2") : t("build")}

{" "}
{(stat as [Stat, Stat])[showComparison ? 1 : 0].value} {suffix}
) : null}
{!isStaticValue && ( )}
); } function ModifiedByAbilities({ abilities }: { abilities: Stat["modifiedBy"] }) { const abilitiesArray = Array.isArray(abilities) ? abilities : [abilities]; return (
{abilitiesArray.map((ability) => ( ))}
); } function DamageTable({ values, multiShots, }: { values: | AnalyzedBuild["stats"]["damages"] | AnalyzedBuild["stats"]["subWeaponDefenseDamages"]; multiShots: AnalyzedBuild["weapon"]["multiShots"]; }) { const { t } = useTranslation(["weapons", "analyzer"]); const showDistanceColumn = values.some((val) => val.distance); const firstRow = atOrError(values, 0); const showDamageColumn = !damageIsSubWeaponDamage(firstRow) || // essentially checking that we are using some sub resistance up values.some((val) => val.value !== (val as any).baseValue); return ( <> {showDistanceColumn && ( )} {damageIsSubWeaponDamage(firstRow) ? ( ) : null} {showDamageColumn && } {values.map((val) => { const damage = multiShots && damageTypeToWeaponType[val.type] === "MAIN" ? new Array(multiShots).fill(val.value).join(" + ") : val.value; const typeRowName = damageIsSubWeaponDamage(val) ? (`weapons:SUB_${val.subWeaponId}` as const) : damageTypeTranslationString({ damageType: val.type, }); return ( {showDistanceColumn && ( )} {damageIsSubWeaponDamage(val) && } {showDamageColumn && ( )} ); })}
{t("analyzer:damage.header.type")}{t("analyzer:damage.header.distance")}{t("analyzer:damage.header.baseDamage")}{t("analyzer:damage.header.damage")}
{damageIsSubWeaponDamage(val) ? ( ) : null}{" "} {t(typeRowName)}{" "} {damageIsSubWeaponDamage(val) && val.type === "SPLASH" ? ( <>({t("analyzer:damage.SPLASH")}) ) : null} {typeof val.distance === "number" ? val.distance : val.distance?.join("-")} {val.baseValue} {damage}{" "} {val.shotsToSplat && ( {t("analyzer:damage.toSplat", { count: val.shotsToSplat, })} )}
); } function ConsumptionTable({ isComparing, options, subWeaponId, }: { isComparing: boolean; options: [ AnalyzedBuild["stats"]["fullInkTankOptions"], AnalyzedBuild["stats"]["fullInkTankOptions"] ]; subWeaponId: SubWeaponId; }) { const [options1, options2] = options; const { t } = useTranslation(["analyzer", "weapons"]); const maxSubsToUse = subWeaponId === TORPEDO_ID ? 1 : Math.max(...options.flat().map((opt) => opt.subsUsed)); const types = Array.from(new Set(options1.map((opt) => opt.type))); return ( <> {types.map((type) => ( ))} {new Array(maxSubsToUse + 1).fill(null).map((_, subsUsed) => { const options1ForThisSubsUsed = options1.filter( (opt) => opt.subsUsed === subsUsed ); const options2ForThisSubsUsed = options2.filter( (opt) => opt.subsUsed === subsUsed ); const cells: React.ReactNode[] = []; // weird using basic for loop in react code but here we are essentially // zipping these two arrays into one cell and if one of the arrays // doesn't have value then it shows as a dash instead for ( let i = 0; i < Math.max( options1ForThisSubsUsed.length, options2ForThisSubsUsed.length ); i++ ) { const opt1 = options1ForThisSubsUsed[i]; const opt2 = options2ForThisSubsUsed[i]; const contents = !isComparing ? opt1.value : `${opt1?.value ?? "-"}/${opt2?.value ?? "-"}`; cells.push(); } return ( {cells} ); })}
{t(`weapons:SUB_${subWeaponId}`)}{t(`analyzer:stat.consumption.${type}`)}
{contents}
×{subsUsed}
{subWeaponId === TORPEDO_ID && (
<> {t("analyzer:torpedoExplanation")}
)} ); }