diff --git a/app/components/Combobox.tsx b/app/components/Combobox.tsx index 6840c17dc..9a9684902 100644 --- a/app/components/Combobox.tsx +++ b/app/components/Combobox.tsx @@ -1,8 +1,7 @@ import { Combobox as HeadlessCombobox } from "@headlessui/react"; -import * as React from "react"; -import Fuse from "fuse.js"; import clsx from "clsx"; -import type { Unpacked } from "~/utils/types"; +import Fuse from "fuse.js"; +import * as React from "react"; import type { GearType, UserWithPlusTier } from "~/db/types"; import { useAllEventsWithMapPools, useUsers } from "~/hooks/swr"; import { useTranslation } from "~/hooks/useTranslation"; @@ -10,13 +9,20 @@ import type { MainWeaponId } from "~/modules/in-game-lists"; import { clothesGearIds, headGearIds, - shoesGearIds, mainWeaponIds, + shoesGearIds, + subWeaponIds, weaponCategories, } from "~/modules/in-game-lists"; -import { gearImageUrl, mainWeaponImageUrl } from "~/utils/urls"; -import { Image } from "./Image"; +import { nonBombSubWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import { type SerializedMapPoolEvent } from "~/routes/calendar/map-pool-events"; +import type { Unpacked } from "~/utils/types"; +import { + gearImageUrl, + mainWeaponImageUrl, + subWeaponImageUrl, +} from "~/utils/urls"; +import { Image } from "./Image"; const MAX_RESULTS_SHOWN = 6; @@ -266,6 +272,56 @@ export function WeaponCombobox({ ); } +export function AllWeaponCombobox({ + id, + inputName, + onChange, + fullWidth, +}: Pick< + ComboboxProps, + "inputName" | "onChange" | "id" | "fullWidth" +>) { + const { t } = useTranslation("weapons"); + + const options = () => { + const result: ComboboxProps< + Record + >["options"] = []; + + for (const mainWeaponId of mainWeaponIds) { + result.push({ + value: `MAIN_${mainWeaponId}`, + label: t(`MAIN_${mainWeaponId}`), + imgPath: mainWeaponImageUrl(mainWeaponId), + }); + } + + for (const subWeaponId of subWeaponIds) { + if (nonBombSubWeaponIds.includes(subWeaponId)) continue; + + result.push({ + value: `SUB_${subWeaponId}`, + label: t(`SUB_${subWeaponId}`), + imgPath: subWeaponImageUrl(subWeaponId), + }); + } + + return result; + }; + + return ( + + ); +} + export function GearCombobox({ id, required, diff --git a/app/features/build-analyzer/analyzer-types.ts b/app/features/build-analyzer/analyzer-types.ts index 3b058fea5..8fa425104 100644 --- a/app/features/build-analyzer/analyzer-types.ts +++ b/app/features/build-analyzer/analyzer-types.ts @@ -253,3 +253,7 @@ export interface AnalyzedBuild { export type SpecialEffectType = (typeof SPECIAL_EFFECTS)[number]["type"]; export type AbilityValuesKeys = keyof typeof abilityValues; + +export type AnyWeapon = + | { type: "MAIN"; id: MainWeaponId } + | { type: "SUB"; id: SubWeaponId }; diff --git a/app/features/build-analyzer/core/utils.ts b/app/features/build-analyzer/core/utils.ts index 9932e3f4d..2a1c2cfe8 100644 --- a/app/features/build-analyzer/core/utils.ts +++ b/app/features/build-analyzer/core/utils.ts @@ -1,4 +1,5 @@ -import type { AbilityType } from "~/modules/in-game-lists"; +import type { AbilityType, SubWeaponId } from "~/modules/in-game-lists"; +import { subWeaponIds } from "~/modules/in-game-lists"; import { abilities, mainWeaponIds, @@ -13,6 +14,7 @@ import abilityValuesJson from "./ability-values.json"; import type { AbilityPoints, AnalyzedBuild, + AnyWeapon, MainWeaponParams, ParamsJson, SpecialWeaponParams, @@ -23,6 +25,7 @@ import invariant from "tiny-invariant"; import { EMPTY_BUILD } from "~/constants"; import { UNKNOWN_SHORT } from "../analyzer-constants"; import type { Unpacked } from "~/utils/types"; +import { nonBombSubWeaponIds } from "~/modules/in-game-lists/weapon-ids"; export function weaponParams(): ParamsJson { return weaponParamsJson as ParamsJson; @@ -163,6 +166,43 @@ export function hasEffect({ return high !== mid || mid !== low; } +const DEFAULT_ANY_WEAPON = { + type: "MAIN", + id: weaponCategories[0].weaponIds[0], +} as const; +export function validatedAnyWeaponFromSearchParams( + searchParams: URLSearchParams +): AnyWeapon { + const rawWeapon = searchParams.get("weapon"); + if (!rawWeapon) return DEFAULT_ANY_WEAPON; + + if (rawWeapon?.startsWith("SUB_")) { + const id = Number(rawWeapon.replace("SUB_", "")); + + if ( + !subWeaponIds + .filter((id) => !nonBombSubWeaponIds.includes(id)) + .includes(id as any) + ) { + return DEFAULT_ANY_WEAPON; + } + + return { type: "SUB", id: id as SubWeaponId }; + } + + if (rawWeapon?.startsWith("MAIN_")) { + const id = Number(rawWeapon.replace("MAIN_", "")); + + if (!mainWeaponIds.includes(id as any)) { + return DEFAULT_ANY_WEAPON; + } + + return { type: "MAIN", id: id as MainWeaponId }; + } + + return { type: "MAIN", id: validatedWeaponIdFromSearchParams(searchParams) }; +} + export function validatedWeaponIdFromSearchParams( searchParams: URLSearchParams ): MainWeaponId { diff --git a/app/features/build-analyzer/index.ts b/app/features/build-analyzer/index.ts index 566eefc67..2f239e1e2 100644 --- a/app/features/build-analyzer/index.ts +++ b/app/features/build-analyzer/index.ts @@ -4,6 +4,7 @@ export { validatedBuildFromSearchParams, serializeBuild, hpDivided, + validatedAnyWeaponFromSearchParams, } from "./core/utils"; export type { DamageType, @@ -11,6 +12,7 @@ export type { AnalyzedBuild, SpecialWeaponParams, SubWeaponParams, + AnyWeapon, } from "./analyzer-types"; export { buildStats, diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx index 42c705333..86f0ae46c 100644 --- a/app/features/build-analyzer/routes/analyzer.tsx +++ b/app/features/build-analyzer/routes/analyzer.tsx @@ -1281,7 +1281,7 @@ function DamageTable({ : val.value; const typeRowName = damageIsSubWeaponDamage(val) - ? t(`weapons:SUB_${val.subWeaponId}`) + ? (`weapons:SUB_${val.subWeaponId}` as const) : damageTypeTranslationString({ damageType: val.type, }); diff --git a/app/features/object-damage-calculator/calculator-constants.ts b/app/features/object-damage-calculator/calculator-constants.ts index 5eff33b1e..4ca5c94cb 100644 --- a/app/features/object-damage-calculator/calculator-constants.ts +++ b/app/features/object-damage-calculator/calculator-constants.ts @@ -2,6 +2,7 @@ import { type MainWeaponId, mainWeaponIds } from "~/modules/in-game-lists"; import type { DamageType } from "~/features/build-analyzer"; import type objectDamages from "./core/object-dmg.json"; import invariant from "tiny-invariant"; +import type { CombineWith } from "./calculator-types"; export const DAMAGE_RECEIVERS = [ "Chariot", // Crab Tank @@ -136,15 +137,7 @@ export const objectDamageJsonKeyPriority: Record< }; export const damageTypesToCombine: Partial< - Record< - MainWeaponId, - Array<{ - when: DamageType; - combineWith: DamageType; - /** for this weapon "when" damage already includes "combineWith" damage, so calculating multiplier only */ - multiplierOnly?: boolean; - }> - > + Record> > = { // Explosher 3040: [{ when: "DIRECT", combineWith: "DISTANCE" }], diff --git a/app/features/object-damage-calculator/calculator-hooks.ts b/app/features/object-damage-calculator/calculator-hooks.ts index e7f8bc72f..c79264060 100644 --- a/app/features/object-damage-calculator/calculator-hooks.ts +++ b/app/features/object-damage-calculator/calculator-hooks.ts @@ -1,15 +1,18 @@ import { useSearchParams } from "@remix-run/react"; import { assertType } from "~/utils/types"; -import { type MainWeaponId } from "~/modules/in-game-lists"; import { type DAMAGE_TYPE, buildStats, possibleApValues, - validatedWeaponIdFromSearchParams, + validatedAnyWeaponFromSearchParams, type AnalyzedBuild, type DamageType, } from "~/features/build-analyzer"; -import { calculateDamage } from "./core/objectDamage"; +import { + calculateDamage, + resolveAllUniqueDamageTypes, +} from "./core/objectDamage"; +import type { AnyWeapon } from "../build-analyzer/analyzer-types"; const ABILITY_POINTS_SP_KEY = "ap"; const DAMAGE_TYPE_SP_KEY = "dmg"; @@ -18,11 +21,11 @@ const MULTI_SHOT_SP_KEY = "multi"; export function useObjectDamage() { const [searchParams, setSearchParams] = useSearchParams(); - const mainWeaponId = validatedWeaponIdFromSearchParams(searchParams); + const anyWeapon = validatedAnyWeaponFromSearchParams(searchParams); const abilityPoints = validatedAbilityPointsFromSearchParams(searchParams); const isMultiShot = validatedMultiShotFromSearchParams(searchParams); const analyzed = buildStats({ - weaponSplId: mainWeaponId, + weaponSplId: anyWeapon.type === "MAIN" ? anyWeapon.id : 0, hasTacticooler: false, }); @@ -32,19 +35,19 @@ export function useObjectDamage() { }); const handleChange = ({ - newMainWeaponId = mainWeaponId, + newAnyWeapon = anyWeapon, newAbilityPoints = abilityPoints, newDamageType = damageType, newIsMultiShot = isMultiShot, }: { - newMainWeaponId?: MainWeaponId; + newAnyWeapon?: AnyWeapon; newAbilityPoints?: number; newDamageType?: DamageType; newIsMultiShot?: boolean; }) => { setSearchParams( { - weapon: String(newMainWeaponId), + weapon: `${newAnyWeapon.type}_${newAnyWeapon.id}`, [ABILITY_POINTS_SP_KEY]: String(newAbilityPoints), [DAMAGE_TYPE_SP_KEY]: newDamageType ?? "", [MULTI_SHOT_SP_KEY]: String(newIsMultiShot), @@ -54,10 +57,7 @@ export function useObjectDamage() { }; return { - weapon: { - type: "MAIN" as const, - id: mainWeaponId, - }, + weapon: anyWeapon, isMultiShot, multiShotCount: analyzed.stats.damages.find((d) => d.type === damageType) ?.multiShots, @@ -69,16 +69,14 @@ export function useObjectDamage() { ["SPU", abilityPoints], ]), analyzed, - mainWeaponId, + anyWeapon, damageType, isMultiShot, }) : null, abilityPoints: String(abilityPoints), damageType, - allDamageTypes: Array.from( - new Set(analyzed.stats.damages.map((d) => d.type)) - ), + allDamageTypes: resolveAllUniqueDamageTypes({ analyzed, anyWeapon }), }; } diff --git a/app/features/object-damage-calculator/calculator-types.ts b/app/features/object-damage-calculator/calculator-types.ts index 3ec594a55..cd475467a 100644 --- a/app/features/object-damage-calculator/calculator-types.ts +++ b/app/features/object-damage-calculator/calculator-types.ts @@ -1,5 +1,13 @@ +import type { DamageType } from "../build-analyzer"; import type { DAMAGE_RECEIVERS } from "./calculator-constants"; export type DamageReceiver = (typeof DAMAGE_RECEIVERS)[number]; export type HitPoints = Record; + +export interface CombineWith { + when: DamageType; + combineWith: DamageType; + /** for this weapon "when" damage already includes "combineWith" damage, so calculating multiplier only */ + multiplierOnly?: boolean; +} diff --git a/app/features/object-damage-calculator/calculator.css b/app/features/object-damage-calculator/calculator.css index 8e5a1ca91..8b50ef19c 100644 --- a/app/features/object-damage-calculator/calculator.css +++ b/app/features/object-damage-calculator/calculator.css @@ -47,9 +47,12 @@ font-size: var(--fonts-xs); font-weight: var(--semi-bold); padding-block: var(--s-2); - padding-inline: var(--s-2); + padding-inline: var(--s-4); text-align: center; white-space: nowrap; + display: flex; + flex-direction: column; + gap: var(--s-1-5); } .object-damage__weapon-image { diff --git a/app/features/object-damage-calculator/core/objectDamage.test.ts b/app/features/object-damage-calculator/core/objectDamage.test.ts index c7ce5ccd0..0367fea1f 100644 --- a/app/features/object-damage-calculator/core/objectDamage.test.ts +++ b/app/features/object-damage-calculator/core/objectDamage.test.ts @@ -25,7 +25,7 @@ function calculate({ return calculateDamage({ abilityPoints, analyzed, - mainWeaponId, + anyWeapon: { type: "MAIN", id: mainWeaponId }, damageType, isMultiShot: true, }); diff --git a/app/features/object-damage-calculator/core/objectDamage.ts b/app/features/object-damage-calculator/core/objectDamage.ts index 959a9e3f7..85e76af11 100644 --- a/app/features/object-damage-calculator/core/objectDamage.ts +++ b/app/features/object-damage-calculator/core/objectDamage.ts @@ -3,7 +3,6 @@ import type { AnalyzedBuild, DamageType, } from "~/features/build-analyzer"; -import { damageTypeToWeaponType } from "~/features/build-analyzer"; import objectDamages from "./object-dmg.json"; import type { MainWeaponId, @@ -18,7 +17,9 @@ import { DAMAGE_RECEIVERS, objectDamageJsonKeyPriority, } from "../calculator-constants"; -import type { DamageReceiver } from "../calculator-types"; +import type { CombineWith, DamageReceiver } from "../calculator-types"; +import type { AnyWeapon } from "~/features/build-analyzer"; +import { removeDuplicates } from "~/utils/arrays"; export function damageTypeToMultipliers({ type, @@ -101,25 +102,43 @@ export function multipliersToRecordWithFallbacks( ) as Record; } -const objectShredderMultipliers = objectDamages.ObjectEffect_Up.rates; -export function calculateDamage({ +export function resolveAllUniqueDamageTypes({ analyzed, - mainWeaponId, - abilityPoints, - damageType, - isMultiShot, + anyWeapon, +}: { + analyzed: AnalyzedBuild; + anyWeapon: AnyWeapon; +}) { + const damageTypes = + anyWeapon.type === "SUB" + ? analyzed.stats.subWeaponDefenseDamages + .filter((damage) => damage.subWeaponId === anyWeapon.id) + .map((d) => d.type) + : analyzed.stats.damages.map((d) => d.type); + + return removeDuplicates(damageTypes); +} + +function resolveFilteredDamages({ + analyzed, + damageType, + isMultiShot, + toCombine, + anyWeapon, }: { analyzed: AnalyzedBuild; - mainWeaponId: MainWeaponId; - abilityPoints: AbilityPoints; damageType: DamageType; isMultiShot: boolean; + toCombine?: CombineWith; + anyWeapon: AnyWeapon; }) { - const toCombine = (damageTypesToCombine[mainWeaponId] ?? []).find( - (c) => c.when === damageType - ); + if (anyWeapon.type === "SUB") { + return analyzed.stats.subWeaponDefenseDamages.filter( + (damage) => damage.subWeaponId === anyWeapon.id + ); + } - const filteredDamages = analyzed.stats.damages + return analyzed.stats.damages .filter((d) => d.type === damageType || toCombine?.combineWith === d.type) .map((damage) => { if (!isMultiShot || !damage.multiShots) return damage; @@ -129,24 +148,46 @@ export function calculateDamage({ value: damage.value * damage.multiShots, }; }); +} + +const objectShredderMultipliers = objectDamages.ObjectEffect_Up.rates; +export function calculateDamage({ + analyzed, + anyWeapon, + abilityPoints, + damageType, + isMultiShot, +}: { + analyzed: AnalyzedBuild; + anyWeapon: AnyWeapon; + abilityPoints: AbilityPoints; + damageType: DamageType; + isMultiShot: boolean; +}) { + const toCombine = + anyWeapon.type == "MAIN" + ? (damageTypesToCombine[anyWeapon.id] ?? []).find( + (c) => c.when === damageType + ) + : undefined; + + const filteredDamages = resolveFilteredDamages({ + analyzed, + damageType, + isMultiShot, + toCombine, + anyWeapon, + }); const hitPoints = objectHitPoints(abilityPoints); const multipliers = Object.fromEntries( filteredDamages.map((damage) => { - const weaponType = damageTypeToWeaponType[damage.type]; - const weaponId: any = - weaponType === "MAIN" - ? mainWeaponId - : weaponType === "SUB" - ? analyzed.weapon.subWeaponSplId - : analyzed.weapon.specialWeaponSplId; - return [ damage.type, multipliersToRecordWithFallbacks( damageTypeToMultipliers({ type: damage.type, - weapon: { type: weaponType, id: weaponId }, + weapon: anyWeapon, }) ), ]; diff --git a/app/features/object-damage-calculator/routes/object-damage-calculator.tsx b/app/features/object-damage-calculator/routes/object-damage-calculator.tsx index c5f9ed6b4..a6d35074e 100644 --- a/app/features/object-damage-calculator/routes/object-damage-calculator.tsx +++ b/app/features/object-damage-calculator/routes/object-damage-calculator.tsx @@ -1,40 +1,40 @@ -import { WeaponCombobox } from "~/components/Combobox"; +import type { LinksFunction } from "@remix-run/node"; +import type { ShouldRevalidateFunction } from "@remix-run/react"; +import clsx from "clsx"; +import React from "react"; +import { Ability } from "~/components/Ability"; +import { AllWeaponCombobox } from "~/components/Combobox"; import { Image, WeaponImage } from "~/components/Image"; +import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; +import { Toggle } from "~/components/Toggle"; +import type { AnyWeapon } from "~/features/build-analyzer"; +import { possibleApValues, type DamageType } from "~/features/build-analyzer"; +import { useSetTitle } from "~/hooks/useSetTitle"; +import { useTranslation } from "~/hooks/useTranslation"; import { - type MainWeaponId, BIG_BUBBLER_ID, BOOYAH_BOMB_ID, CRAB_TANK_ID, SPLASH_WALL_ID, + SPRINKLER_ID, SQUID_BEAKON_ID, TORPEDO_ID, WAVE_BREAKER_ID, - SPRINKLER_ID, } from "~/modules/in-game-lists"; +import { damageTypeTranslationString } from "~/utils/i18next"; +import type { SendouRouteHandle } from "~/utils/remix"; import { + OBJECT_DAMAGE_CALCULATOR_URL, mainWeaponImageUrl, modeImageUrl, navIconUrl, - OBJECT_DAMAGE_CALCULATOR_URL, specialWeaponImageUrl, subWeaponImageUrl, } from "~/utils/urls"; -import styles from "../calculator.css"; -import type { LinksFunction } from "@remix-run/node"; -import type { SendouRouteHandle } from "~/utils/remix"; -import React from "react"; -import { useTranslation } from "~/hooks/useTranslation"; -import clsx from "clsx"; -import { Label } from "~/components/Label"; -import { Ability } from "~/components/Ability"; -import { damageTypeTranslationString } from "~/utils/i18next"; -import { useSetTitle } from "~/hooks/useSetTitle"; -import type { ShouldRevalidateFunction } from "@remix-run/react"; -import { Toggle } from "~/components/Toggle"; import { useObjectDamage } from "../calculator-hooks"; -import { type DamageType, possibleApValues } from "~/features/build-analyzer"; import type { DamageReceiver } from "../calculator-types"; +import styles from "../calculator.css"; export const CURRENT_PATCH = "3.1"; @@ -72,19 +72,29 @@ export default function ObjectDamagePage() {
- - opt && + onChange={(opt) => { + if (!opt) return; + + const [type, id] = opt.value.split("_"); + handleChange({ - newMainWeaponId: Number(opt.value) as MainWeaponId, - }) - } + newAnyWeapon: { + id: Number(id), + type, + } as AnyWeapon, + }); + }} fullWidth />
-
+
- {t("analyzer:distanceInline", { value: damage.distance })} + {t("analyzer:distanceInline", { + value: Array.isArray(damage.distance) + ? damage.distance.join("-") + : damage.distance, + })}
- - {t(`weapons:MAIN_${weapon.id}`)} + {weapon.type === "MAIN" ? ( + + ) : ( + + )} + {t(`weapons:${weapon.type}_${weapon.id}` as any)}
))} diff --git a/app/modules/in-game-lists/weapon-ids.ts b/app/modules/in-game-lists/weapon-ids.ts index 023322278..dedc1dc55 100644 --- a/app/modules/in-game-lists/weapon-ids.ts +++ b/app/modules/in-game-lists/weapon-ids.ts @@ -1,4 +1,4 @@ -import type { MainWeaponId } from "./types"; +import type { MainWeaponId, SubWeaponId } from "./types"; export const weaponCategories = [ { @@ -92,6 +92,14 @@ export const subWeaponIds = [ TORPEDO_ID, ] as const; +export const nonBombSubWeaponIds = [ + SPRINKLER_ID, + SPLASH_WALL_ID, + SQUID_BEAKON_ID, + POINT_SENSOR_ID, + TOXIC_MIST_ID, +] as SubWeaponId[]; + export const TRIZOOKA_ID = 1; export const BIG_BUBBLER_ID = 2; export const ZIPCASTER_ID = 3; diff --git a/app/utils/i18next.ts b/app/utils/i18next.ts index 5fb842a30..2e1966f3d 100644 --- a/app/utils/i18next.ts +++ b/app/utils/i18next.ts @@ -1,8 +1,7 @@ import type { DamageType } from "~/features/build-analyzer"; -// TODO: type this correctly export const damageTypeTranslationString = ({ damageType, }: { damageType: DamageType; -}): any => `analyzer:damage.${damageType as "NORMAL_MIN"}`; +}) => `analyzer:damage.${damageType}` as const; diff --git a/public/locales/en/analyzer.json b/public/locales/en/analyzer.json index 5e5d04207..8779c9a16 100644 --- a/public/locales/en/analyzer.json +++ b/public/locales/en/analyzer.json @@ -100,6 +100,8 @@ "damage.SPLATANA_VERTICAL": "Vertical", "damage.SPLATANA_HORIZONTAL_DIRECT": "Horizontal direct", "damage.SPLATANA_HORIZONTAL": "Horizontal", + "damage.BOMB_DIRECT": "Direct", + "damage.BOMB_NORMAL": "Splash", "suffix.seconds": "s", "suffix.hp": "hp", "suffix.specialPointsShort": "p",