mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-26 01:09:02 -05:00
* Got something going * Style overwrites * width != height * More playing with lines * Migrations * Start bracket initial * Unhardcode stage generation params * Link to match page * Matches page initial * Support directly adding seed to map list generator * Add docs * Maps in matches page * Add invariant about tie breaker map pool * Fix PICNIC lacking tie breaker maps * Only link in bracket when tournament has started * Styled tournament roster inputs * Prefer IGN in tournament match page * ModeProgressIndicator * Some conditional rendering * Match action initial + better error display * Persist bestOf in DB * Resolve best of ahead of time * Move brackets-manager to core * Score reporting works * Clear winner on score report * ModeProgressIndicator: highlight winners * Fix inconsistent input * Better text when submitting match * mapCountPlayedInSetWithCertainty that works * UNDO_REPORT_SCORE implemented * Permission check when starting tournament * Remove IGN from upsert * View match results page * Source in DB * Match page waiting for teams * Move tournament bracket to feature folder * REOPEN_MATCH initial * Handle proper resetting of match * Inline bracket-manager * Syncify * Transactions * Handle match is locked gracefully * Match page auto refresh * Fix match refresh called "globally" * Bracket autoupdate * Move fillWithNullTillPowerOfTwo to utils with testing * Fix map lists not visible after tournament started * Optimize match events * Show UI while in progress to members * Fix start tournament alert not being responsive * Teams can check in * Fix map list 400 * xxx -> TODO * Seeds page * Remove map icons for team page * Don't display link to seeds after tournament has started * Admin actions initial * Change captain admin action * Make all hooks ts * Admin actions functioning * Fix validate error not displaying in CatchBoundary * Adjust validate args order * Remove admin loader * Make delete team button menancing * Only include checked in teams to bracket * Optimize to.id route loads * Working show map list generator toggle * Update full tournaments flow * Make full tournaments work with many start times * Handle undefined in crud * Dynamic stage banner * Handle default strat if map list generation fails * Fix crash on brackets if less than 2 teams * Add commented out test for reference * Add TODO * Add players from team during register * TrustRelationship * Prefers not to host feature * Last before merge * Rename some vars * More renames
1419 lines
47 KiB
TypeScript
1419 lines
47 KiB
TypeScript
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 && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
key="jumpShotSpread"
|
||
stat={statKeyToTuple("shotSpreadAir")}
|
||
title={t("analyzer:stat.jumpShotSpread")}
|
||
suffix="°"
|
||
/>
|
||
),
|
||
typeof analyzed.stats.shotSpreadGround === "number" && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
key="groundShotSpread"
|
||
stat={analyzed.stats.shotSpreadGround}
|
||
title={t("analyzer:stat.groundShotSpread")}
|
||
suffix="°"
|
||
/>
|
||
),
|
||
typeof analyzed.stats.mainWeaponWhiteInkSeconds === "number" && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
key="whiteInkSeconds"
|
||
stat={analyzed.stats.mainWeaponWhiteInkSeconds}
|
||
title={t("analyzer:stat.whiteInk")}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
),
|
||
typeof analyzed.weapon.brellaCanopyHp === "number" && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
key="brellaCanopyHp"
|
||
stat={analyzed.weapon.brellaCanopyHp}
|
||
title={t("analyzer:stat.canopyHp")}
|
||
suffix={t("analyzer:suffix.hp")}
|
||
/>
|
||
),
|
||
typeof analyzed.weapon.fullChargeSeconds === "number" && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
key="fullChargeSeconds"
|
||
stat={analyzed.weapon.fullChargeSeconds}
|
||
title={t("analyzer:stat.fullChargeSeconds")}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
),
|
||
typeof analyzed.weapon.maxChargeHoldSeconds === "number" && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
key="maxChargeHoldSeconds"
|
||
stat={analyzed.weapon.maxChargeHoldSeconds}
|
||
title={t("analyzer:stat.maxChargeHoldSeconds")}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
),
|
||
].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 (
|
||
<Main>
|
||
<div className="analyzer__container">
|
||
<div className="analyzer__left-column">
|
||
<div className="stack sm items-center w-full">
|
||
<div className="w-full">
|
||
<WeaponCombobox
|
||
inputName="weapon"
|
||
onChange={(opt) =>
|
||
opt &&
|
||
handleChange({
|
||
newMainWeaponId: Number(opt.value) as MainWeaponId,
|
||
})
|
||
}
|
||
fullWidth
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="stack md items-center w-full">
|
||
<div className="w-full">
|
||
<Tabs className="analyzer__sub-nav">
|
||
<Tab
|
||
active={focused === 1}
|
||
onClick={() => handleChange({ newFocused: 1 })}
|
||
testId="build1-tab"
|
||
>
|
||
{t("analyzer:build1")}
|
||
</Tab>
|
||
<Tab
|
||
active={focused === 2}
|
||
onClick={() => handleChange({ newFocused: 2 })}
|
||
testId="build2-tab"
|
||
>
|
||
{t("analyzer:build2")}
|
||
</Tab>
|
||
<Tab
|
||
active={focused === 3}
|
||
onClick={() => handleChange({ newFocused: 3 })}
|
||
testId="ap-tab"
|
||
>
|
||
{t("analyzer:compare")}
|
||
</Tab>
|
||
</Tabs>
|
||
{focusedBuild ? (
|
||
<AbilitiesSelector
|
||
selectedAbilities={focusedBuild}
|
||
onChange={(newBuild) => {
|
||
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,
|
||
});
|
||
}}
|
||
/>
|
||
) : (
|
||
<APCompare
|
||
abilityPoints={abilityPoints}
|
||
abilityPoints2={abilityPoints2}
|
||
build={build}
|
||
build2={build2}
|
||
/>
|
||
)}
|
||
</div>
|
||
<EffectsSelector
|
||
build={build}
|
||
build2={build2}
|
||
ldeIntensity={ldeIntensity}
|
||
handleLdeIntensityChange={(newLdeIntensity) =>
|
||
handleChange({ newLdeIntensity })
|
||
}
|
||
handleAddEffect={(newEffect) =>
|
||
handleChange({ newEffects: [...allEffects, newEffect] })
|
||
}
|
||
handleRemoveEffect={(effectToRemove) =>
|
||
handleChange({
|
||
newEffects: allEffects.filter((e) => e !== effectToRemove),
|
||
})
|
||
}
|
||
effects={allEffects}
|
||
/>
|
||
{showAbilityChunksRequired && (
|
||
<AbilityChunksRequired build={build} />
|
||
)}
|
||
</div>
|
||
<div className="analyzer__patch">
|
||
{t("analyzer:patch")} {CURRENT_PATCH}
|
||
</div>
|
||
</div>
|
||
<div className="stack md">
|
||
{mainWeaponCategoryItems.length > 0 && (
|
||
<StatCategory
|
||
title={t("analyzer:stat.category.main")}
|
||
summaryRightContent={
|
||
<div className="analyzer__weapon-info-badge">
|
||
<Image
|
||
path={mainWeaponImageUrl(mainWeaponId)}
|
||
width={20}
|
||
height={20}
|
||
alt={t(`weapons:MAIN_${mainWeaponId}`)}
|
||
/>
|
||
{t(`weapons:MAIN_${mainWeaponId}`)}
|
||
</div>
|
||
}
|
||
>
|
||
{mainWeaponCategoryItems}
|
||
</StatCategory>
|
||
)}
|
||
|
||
<StatCategory
|
||
title={t("analyzer:stat.category.sub")}
|
||
summaryRightContent={
|
||
<div className="analyzer__weapon-info-badge">
|
||
<Image
|
||
path={subWeaponImageUrl(analyzed.weapon.subWeaponSplId)}
|
||
width={20}
|
||
height={20}
|
||
alt={t(`weapons:SUB_${analyzed.weapon.subWeaponSplId}`)}
|
||
/>
|
||
{t(`weapons:SUB_${analyzed.weapon.subWeaponSplId}`)}
|
||
</div>
|
||
}
|
||
>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("subWeaponInkConsumptionPercentage")}
|
||
title={t("analyzer:stat.subWeaponInkConsumptionPercentage")}
|
||
suffix="%"
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={analyzed.stats.subWeaponWhiteInkSeconds}
|
||
title={t("analyzer:stat.whiteInk")}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
{analyzed.stats.subVelocity && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("subVelocity")}
|
||
title={t("analyzer:stat.sub.velocity")}
|
||
/>
|
||
)}
|
||
{analyzed.stats.subFirstPhaseDuration && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("subFirstPhaseDuration")}
|
||
title={t("analyzer:stat.sub.firstPhaseDuration")}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
)}
|
||
{analyzed.stats.subSecondPhaseDuration && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("subSecondPhaseDuration")}
|
||
title={t("analyzer:stat.sub.secondPhaseDuration")}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
)}
|
||
{analyzed.stats.subMarkingTimeInSeconds && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("subMarkingTimeInSeconds")}
|
||
title={t("analyzer:stat.sub.markingTimeInSeconds")}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
)}
|
||
{analyzed.stats.subMarkingRadius && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("subMarkingRadius")}
|
||
title={t("analyzer:stat.sub.markingRadius")}
|
||
/>
|
||
)}
|
||
{analyzed.stats.subExplosionRadius && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("subExplosionRadius")}
|
||
title={t("analyzer:stat.sub.explosionRadius")}
|
||
/>
|
||
)}
|
||
{analyzed.stats.subHp && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("subHp")}
|
||
title={t("analyzer:stat.sub.hp")}
|
||
suffix={t("analyzer:suffix.hp")}
|
||
/>
|
||
)}
|
||
{analyzed.stats.subQsjBoost && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("subQsjBoost")}
|
||
title={t("analyzer:stat.sub.qsjBoost")}
|
||
suffix={t("analyzer:abilityPoints.short")}
|
||
/>
|
||
)}
|
||
</StatCategory>
|
||
|
||
<StatCategory
|
||
title={t("analyzer:stat.category.special")}
|
||
summaryRightContent={
|
||
<div className="analyzer__weapon-info-badge">
|
||
<Image
|
||
path={specialWeaponImageUrl(
|
||
analyzed.weapon.specialWeaponSplId
|
||
)}
|
||
width={20}
|
||
height={20}
|
||
alt={t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
)}
|
||
/>
|
||
{t(`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`)}
|
||
</div>
|
||
}
|
||
>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialPoint")}
|
||
title={t("analyzer:stat.specialPoints")}
|
||
suffix={t("analyzer:suffix.specialPointsShort")}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialLost")}
|
||
title={t("analyzer:stat.specialLost")}
|
||
suffix="%"
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialLostSplattedByRP")}
|
||
title={t("analyzer:stat.specialLostSplattedByRP")}
|
||
suffix="%"
|
||
/>
|
||
{analyzed.stats.specialDurationInSeconds && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialDurationInSeconds")}
|
||
title={t("analyzer:stat.special.duration", {
|
||
weapon: t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
),
|
||
})}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
popoverInfo={
|
||
analyzed.weapon.specialWeaponSplId === INK_STORM_ID
|
||
? t("analyzer:stat.special.duration.inkStormExplanation")
|
||
: undefined
|
||
}
|
||
/>
|
||
)}
|
||
{analyzed.stats.specialDamageDistance && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialDamageDistance")}
|
||
title={t("analyzer:stat.special.damageDistance", {
|
||
weapon: t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
),
|
||
})}
|
||
/>
|
||
)}
|
||
{analyzed.stats.specialPaintRadius && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialPaintRadius")}
|
||
title={t("analyzer:stat.special.paintRadius", {
|
||
weapon: t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
),
|
||
})}
|
||
/>
|
||
)}
|
||
{analyzed.stats.specialFieldHp && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialFieldHp")}
|
||
title={t("analyzer:stat.special.shieldHp", {
|
||
weapon: t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
),
|
||
})}
|
||
suffix={t("analyzer:suffix.hp")}
|
||
/>
|
||
)}
|
||
{analyzed.stats.specialDeviceHp && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialDeviceHp")}
|
||
title={t("analyzer:stat.special.deviceHp", {
|
||
weapon: t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
),
|
||
})}
|
||
suffix={t("analyzer:suffix.hp")}
|
||
/>
|
||
)}
|
||
{analyzed.stats.specialHookInkConsumptionPercentage && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialHookInkConsumptionPercentage")}
|
||
title={t("analyzer:stat.special.inkConsumptionHook", {
|
||
weapon: t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
),
|
||
})}
|
||
suffix="%"
|
||
/>
|
||
)}
|
||
{analyzed.stats.specialInkConsumptionPerSecondPercentage && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple(
|
||
"specialInkConsumptionPerSecondPercentage"
|
||
)}
|
||
title={t("analyzer:stat.special.inkConsumptionPerSecond", {
|
||
weapon: t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
),
|
||
})}
|
||
suffix="%"
|
||
/>
|
||
)}
|
||
{analyzed.stats.specialReticleRadius && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialReticleRadius")}
|
||
title={t("analyzer:stat.special.reticleRadius", {
|
||
weapon: t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
),
|
||
})}
|
||
/>
|
||
)}
|
||
{analyzed.stats.specialThrowDistance && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialThrowDistance")}
|
||
title={t("analyzer:stat.special.throwDistance", {
|
||
weapon: t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
),
|
||
})}
|
||
/>
|
||
)}
|
||
{analyzed.stats.specialAutoChargeRate && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialAutoChargeRate")}
|
||
title={t("analyzer:stat.special.autoChargeRate", {
|
||
weapon: t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
),
|
||
})}
|
||
/>
|
||
)}
|
||
{analyzed.stats.specialMaxRadius && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialMaxRadius")}
|
||
title={t("analyzer:stat.special.maxRadius", {
|
||
weapon: t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
),
|
||
})}
|
||
popoverInfo={t("analyzer:stat.special.maxRadius.explanation")}
|
||
/>
|
||
)}
|
||
{analyzed.stats.specialRadiusRange && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialRadiusRange")}
|
||
title={t("analyzer:stat.special.radiusRange", {
|
||
weapon: t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
),
|
||
})}
|
||
/>
|
||
)}
|
||
{analyzed.stats.specialPowerUpDuration && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("specialPowerUpDuration")}
|
||
title={t("analyzer:stat.special.powerUpDuration", {
|
||
weapon: t(
|
||
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
|
||
),
|
||
})}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
)}
|
||
</StatCategory>
|
||
<StatCategory
|
||
title={t("analyzer:stat.category.subDef")}
|
||
textBelow={t("analyzer:trackingSubDefExplanation")}
|
||
>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("subDefToxicMistMovementReduction")}
|
||
title={t("analyzer:stat.movementReduction", {
|
||
weapon: t(`weapons:SUB_${TOXIC_MIST_ID}`),
|
||
})}
|
||
suffix="%"
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("subDefPointSensorMarkedTimeInSeconds")}
|
||
title={t("analyzer:stat.markedTime", {
|
||
weapon: t(`weapons:SUB_${POINT_SENSOR_ID}`),
|
||
})}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("subDefInkMineMarkedTimeInSeconds")}
|
||
title={t("analyzer:stat.markedTime", {
|
||
weapon: t(`weapons:SUB_${INK_MINE_ID}`),
|
||
})}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("subDefAngleShooterMarkedTimeInSeconds")}
|
||
title={t("analyzer:stat.markedTime", {
|
||
weapon: t(`weapons:SUB_${ANGLE_SHOOTER_ID}`),
|
||
})}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
</StatCategory>
|
||
|
||
{analyzed.stats.subWeaponDefenseDamages.length > 0 && (
|
||
<StatCategory
|
||
title={t("analyzer:stat.category.subWeaponDefenseDamages")}
|
||
containerClassName="analyzer__table-container"
|
||
textBelow={t("analyzer:damageSubDefExplanation")}
|
||
>
|
||
{(["SRU"] as const).some(
|
||
(ability) => (abilityPoints.get(ability) ?? 0) > 0
|
||
) ? (
|
||
<div className="analyzer__stat-card-highlighted" />
|
||
) : null}
|
||
<DamageTable
|
||
values={analyzed.stats.subWeaponDefenseDamages}
|
||
multiShots={analyzed.weapon.multiShots}
|
||
/>
|
||
</StatCategory>
|
||
)}
|
||
|
||
{analyzed.stats.damages.length > 0 && (
|
||
<StatCategory
|
||
title={t("analyzer:stat.category.damage")}
|
||
containerClassName="analyzer__table-container"
|
||
>
|
||
<DamageTable
|
||
values={analyzed.stats.damages}
|
||
multiShots={analyzed.weapon.multiShots}
|
||
/>
|
||
</StatCategory>
|
||
)}
|
||
|
||
{analyzed.stats.fullInkTankOptions.length > 0 && (
|
||
<StatCategory
|
||
title={t("analyzer:stat.category.actionsPerInkTank")}
|
||
containerClassName="analyzer__table-container"
|
||
>
|
||
{(["ISM", "ISS"] as const).some(
|
||
(ability) => (abilityPoints.get(ability) ?? 0) > 0
|
||
) ? (
|
||
<div className="analyzer__stat-card-highlighted" />
|
||
) : null}
|
||
<ConsumptionTable
|
||
isComparing={isComparing}
|
||
options={[
|
||
analyzed.stats.fullInkTankOptions,
|
||
analyzed2.stats.fullInkTankOptions,
|
||
]}
|
||
subWeaponId={analyzed.weapon.subWeaponSplId}
|
||
/>
|
||
</StatCategory>
|
||
)}
|
||
|
||
<StatCategory
|
||
title={t("analyzer:stat.category.movement")}
|
||
testId="movement-category"
|
||
>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
title={t("analyzer:attribute.weight")}
|
||
abilityPoints={abilityPoints}
|
||
stat={t(`analyzer:attribute.weight.${analyzed.weapon.speedType}`)}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("swimSpeed")}
|
||
title={t("analyzer:stat.swimSpeed")}
|
||
testId="swim-speed"
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("swimSpeedHoldingRainmaker")}
|
||
title={t("analyzer:stat.swimSpeedHoldingRainmaker")}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("runSpeed")}
|
||
title={t("analyzer:stat.runSpeed")}
|
||
/>
|
||
{analyzed.stats.shootingRunSpeed && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("shootingRunSpeed")}
|
||
title={t("analyzer:stat.shootingRunSpeed")}
|
||
/>
|
||
)}
|
||
{analyzed.stats.shootingRunSpeedCharging && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("shootingRunSpeedCharging")}
|
||
title={t("analyzer:stat.shootingRunSpeedCharging")}
|
||
/>
|
||
)}
|
||
{analyzed.stats.shootingRunSpeedFullCharge && (
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("shootingRunSpeedFullCharge")}
|
||
title={t("analyzer:stat.shootingRunSpeedFullCharge")}
|
||
/>
|
||
)}
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("squidSurgeChargeFrames")}
|
||
title={t("analyzer:stat.squidSurgeChargeFrames")}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("runSpeedInEnemyInk")}
|
||
title={t("analyzer:stat.runSpeedInEnemyInk")}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("framesBeforeTakingDamageInEnemyInk")}
|
||
title={t("analyzer:stat.framesBeforeTakingDamageInEnemyInk")}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("damageTakenInEnemyInkPerSecond")}
|
||
title={t("analyzer:stat.damageTakenInEnemyInkPerSecond")}
|
||
suffix={t("analyzer:suffix.hp")}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("enemyInkDamageLimit")}
|
||
title={t("analyzer:stat.enemyInkDamageLimit")}
|
||
suffix={t("analyzer:suffix.hp")}
|
||
/>
|
||
</StatCategory>
|
||
|
||
<StatCategory title={t("analyzer:stat.category.misc")}>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("squidFormInkRecoverySeconds")}
|
||
title={t("analyzer:stat.squidFormInkRecoverySeconds")}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("humanoidFormInkRecoverySeconds")}
|
||
title={t("analyzer:stat.humanoidFormInkRecoverySeconds")}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("quickRespawnTime")}
|
||
title={t("analyzer:stat.quickRespawnTime")}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("quickRespawnTimeSplattedByRP")}
|
||
title={t("analyzer:stat.quickRespawnTimeSplattedByRP")}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("superJumpTimeGroundFrames")}
|
||
title={t("analyzer:stat.superJumpTimeGround")}
|
||
/>
|
||
<StatCard
|
||
isComparing={isComparing}
|
||
abilityPoints={abilityPoints}
|
||
stat={statKeyToTuple("superJumpTimeTotal")}
|
||
title={t("analyzer:stat.superJumpTimeTotal")}
|
||
suffix={t("analyzer:suffix.seconds")}
|
||
/>
|
||
</StatCategory>
|
||
{objectShredderSelected && (
|
||
<Link
|
||
className="analyzer__noticeable-link"
|
||
to={objectDamageCalculatorPage(mainWeaponId)}
|
||
>
|
||
<Image
|
||
path={navIconUrl("object-damage-calculator")}
|
||
width={24}
|
||
height={24}
|
||
alt=""
|
||
/>
|
||
{t("analyzer:objCalcAd")}
|
||
</Link>
|
||
)}
|
||
{user && focusedBuild && !buildIsEmpty(focusedBuild) ? (
|
||
<Link
|
||
className="analyzer__noticeable-link"
|
||
to={userNewBuildPage(user, {
|
||
weapon: mainWeaponId,
|
||
build: focusedBuild,
|
||
})}
|
||
data-testid="new-build-prompt"
|
||
>
|
||
<Image
|
||
path={navIconUrl("builds")}
|
||
width={24}
|
||
height={24}
|
||
alt=""
|
||
/>
|
||
{t("analyzer:newBuildPrompt")}
|
||
</Link>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</Main>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="analyzer__ap-compare">
|
||
{hasAtLeastOneMainOnlyAbility ? (
|
||
<>
|
||
<div className="analyzer__ap-compare__mains">
|
||
{buildMains.map((ability) => (
|
||
<Ability key={ability} ability={ability} size="TINY" />
|
||
))}
|
||
</div>
|
||
<div />
|
||
<div className="analyzer__ap-compare__mains">
|
||
{build2Mains.map((ability) => (
|
||
<Ability key={ability} ability={ability} size="TINY" />
|
||
))}
|
||
</div>
|
||
</>
|
||
) : 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 (
|
||
<React.Fragment key={ability}>
|
||
<div
|
||
className={clsx("justify-self-end", {
|
||
invisible: !ap,
|
||
})}
|
||
data-testid="ap-compare-1"
|
||
>
|
||
{ap}
|
||
{t("analyzer:abilityPoints.short")}
|
||
</div>
|
||
<div
|
||
className={clsx("analyzer__ap-compare__bar", "justify-self-end", {
|
||
analyzer__better: ap >= ap2,
|
||
})}
|
||
style={{ width: `${ap}px` }}
|
||
/>
|
||
<Ability ability={ability} size="TINY" />
|
||
<div
|
||
className={clsx("analyzer__ap-compare__bar", {
|
||
analyzer__better: ap <= ap2,
|
||
})}
|
||
style={{ width: `${ap2}px` }}
|
||
/>
|
||
<div
|
||
className={clsx({ invisible: !ap2 })}
|
||
data-testid="ap-compare-2"
|
||
>
|
||
{ap2}
|
||
{t("analyzer:abilityPoints.short")}
|
||
</div>
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EffectsSelector({
|
||
build,
|
||
build2,
|
||
effects,
|
||
ldeIntensity,
|
||
handleLdeIntensityChange,
|
||
handleAddEffect,
|
||
handleRemoveEffect,
|
||
}: {
|
||
build: BuildAbilitiesTupleWithUnknown;
|
||
build2: BuildAbilitiesTupleWithUnknown;
|
||
effects: Array<SpecialEffectType>;
|
||
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 (
|
||
<div className="analyzer__effects-selector">
|
||
{effectsToShow.map((effect) => {
|
||
return (
|
||
<React.Fragment key={effect.type}>
|
||
<div>
|
||
{isAbility(effect.type) ? (
|
||
<Ability ability={effect.type} size="SUB" />
|
||
) : (
|
||
<Image
|
||
path={specialWeaponImageUrl(15)}
|
||
alt={t("weapons:SPECIAL_15")}
|
||
height={32}
|
||
width={32}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div>
|
||
{effect.type === "LDE" ? (
|
||
<select
|
||
value={ldeIntensity}
|
||
onChange={(e) =>
|
||
handleLdeIntensityChange(Number(e.target.value))
|
||
}
|
||
className="analyzer__lde-intensity-select"
|
||
>
|
||
{new Array(MAX_LDE_INTENSITY + 1).fill(null).map((_, i) => {
|
||
const percentage = ((i / MAX_LDE_INTENSITY) * 100)
|
||
.toFixed(2)
|
||
.replace(".00", "");
|
||
|
||
return (
|
||
<option key={i} value={i}>
|
||
{percentage}% (+{lastDitchEffortIntensityToAp(i)}{" "}
|
||
{t("analyzer:abilityPoints.short")})
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
) : (
|
||
<Toggle
|
||
checked={effects.includes(effect.type)}
|
||
setChecked={(checked) =>
|
||
checked
|
||
? handleAddEffect(effect.type)
|
||
: handleRemoveEffect(effect.type)
|
||
}
|
||
tiny
|
||
/>
|
||
)}
|
||
</div>
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AbilityChunksRequired({
|
||
build,
|
||
}: {
|
||
build: BuildAbilitiesTupleWithUnknown;
|
||
}) {
|
||
const { t } = useTranslation("analyzer");
|
||
const abilityChunksMapAsArray = getAbilityChunksMapAsArray(build);
|
||
|
||
return (
|
||
<details className="w-full">
|
||
<summary className="analyzer__ap-summary">{t("abilityChunks")}</summary>
|
||
<div className="stack sm horizontal flex-wrap mt-4">
|
||
{abilityChunksMapAsArray.map((a) => {
|
||
const mainAbilityName = a[0];
|
||
const numChunksRequired = a[1];
|
||
|
||
return (
|
||
<div
|
||
key={`abilityChunksRequired_${mainAbilityName}`}
|
||
className="stack items-center"
|
||
>
|
||
<Ability ability={mainAbilityName} size="TINY" />
|
||
<div className="analyzer__ap-text">{numChunksRequired}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<details className="analyzer__details">
|
||
<summary className="analyzer__summary" data-testid={testId}>
|
||
{title}
|
||
{summaryRightContent}
|
||
</summary>
|
||
<div className={containerClassName}>{children}</div>
|
||
{textBelow && (
|
||
<div className="analyzer__stat-category-explanation">{textBelow}</div>
|
||
)}
|
||
</details>
|
||
);
|
||
}
|
||
|
||
function StatCard({
|
||
title,
|
||
stat,
|
||
suffix,
|
||
popoverInfo,
|
||
abilityPoints,
|
||
isComparing,
|
||
testId,
|
||
}: {
|
||
title: string;
|
||
stat: [Stat, Stat] | [Stat<string>, Stat<string>] | 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 (
|
||
<div
|
||
className={clsx("analyzer__stat-card", {
|
||
"analyzer__stat-card-highlighted": isHighlighted(),
|
||
})}
|
||
data-testid={testId}
|
||
>
|
||
<div className="analyzer__stat-card__title-and-value-container">
|
||
<h3 className="analyzer__stat-card__title">
|
||
{title}{" "}
|
||
{popoverInfo && (
|
||
<Popover
|
||
containerClassName="analyzer__stat-card__popover"
|
||
triggerClassName="analyzer__stat-card__popover-trigger"
|
||
buttonChildren={<>?</>}
|
||
>
|
||
{popoverInfo}
|
||
</Popover>
|
||
)}
|
||
</h3>
|
||
<div className="analyzer__stat-card-values">
|
||
<div className="analyzer__stat-card__value">
|
||
<h4 className="analyzer__stat-card__value__title">
|
||
{typeof stat === "number"
|
||
? t("value")
|
||
: showComparison
|
||
? t("build1")
|
||
: t("base")}
|
||
</h4>{" "}
|
||
<div
|
||
className="analyzer__stat-card__value__number"
|
||
data-testid={testId ? `${testId}-base` : undefined}
|
||
>
|
||
{showComparison ? (stat as [Stat, Stat])[0].value : baseValue}
|
||
{suffix}
|
||
</div>
|
||
</div>
|
||
{showBuildValue() ? (
|
||
<div className="analyzer__stat-card__value">
|
||
<h4
|
||
className="analyzer__stat-card__value__title"
|
||
data-testid={testId ? `${testId}-build-title` : undefined}
|
||
>
|
||
{showComparison ? t("build2") : t("build")}
|
||
</h4>{" "}
|
||
<div className="analyzer__stat-card__value__number">
|
||
{(stat as [Stat, Stat])[showComparison ? 1 : 0].value}
|
||
{suffix}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="analyzer__stat-card__ability-container">
|
||
{!isStaticValue && (
|
||
<ModifiedByAbilities abilities={stat[0].modifiedBy} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ModifiedByAbilities({ abilities }: { abilities: Stat["modifiedBy"] }) {
|
||
const abilitiesArray = Array.isArray(abilities) ? abilities : [abilities];
|
||
|
||
return (
|
||
<div className="stack horizontal sm items-center justify-center">
|
||
{abilitiesArray.map((ability) => (
|
||
<Ability key={ability} ability={ability} size="TINY" />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>{t("analyzer:damage.header.type")}</th>
|
||
{showDistanceColumn && (
|
||
<th>{t("analyzer:damage.header.distance")}</th>
|
||
)}
|
||
{damageIsSubWeaponDamage(firstRow) ? (
|
||
<th>{t("analyzer:damage.header.baseDamage")}</th>
|
||
) : null}
|
||
{showDamageColumn && <th>{t("analyzer:damage.header.damage")}</th>}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{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 (
|
||
<tr key={val.id}>
|
||
<td className="stack horizontal xs items-center">
|
||
{damageIsSubWeaponDamage(val) ? (
|
||
<Image
|
||
alt=""
|
||
path={subWeaponImageUrl(val.subWeaponId)}
|
||
width={12}
|
||
height={12}
|
||
/>
|
||
) : null}{" "}
|
||
{t(typeRowName)}{" "}
|
||
{damageIsSubWeaponDamage(val) && val.type === "SPLASH" ? (
|
||
<>({t("analyzer:damage.SPLASH")})</>
|
||
) : null}
|
||
</td>
|
||
{showDistanceColumn && (
|
||
<td>
|
||
{typeof val.distance === "number"
|
||
? val.distance
|
||
: val.distance?.join("-")}
|
||
</td>
|
||
)}
|
||
{damageIsSubWeaponDamage(val) && <td>{val.baseValue}</td>}
|
||
{showDamageColumn && (
|
||
<td>
|
||
{damage}{" "}
|
||
{val.shotsToSplat && (
|
||
<span className="analyzer__shots-to-splat">
|
||
{t("analyzer:damage.toSplat", {
|
||
count: val.shotsToSplat,
|
||
})}
|
||
</span>
|
||
)}
|
||
</td>
|
||
)}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>{t(`weapons:SUB_${subWeaponId}`)}</th>
|
||
{types.map((type) => (
|
||
<th key={type}>{t(`analyzer:stat.consumption.${type}`)}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{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(<td key={opt1?.id ?? opt2.id}>{contents}</td>);
|
||
}
|
||
|
||
return (
|
||
<tr key={subsUsed}>
|
||
<td>×{subsUsed}</td>
|
||
{cells}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
{subWeaponId === TORPEDO_ID && (
|
||
<div className="analyzer__consumption-table-explanation">
|
||
<> {t("analyzer:torpedoExplanation")}</>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|