Skip to content

Commit

Permalink
Merge pull request #10310 from DestinyItemManager/loadout-specializat…
Browse files Browse the repository at this point in the history
…ion-indicators

Loadout specialization indicators
  • Loading branch information
nev-r authored Apr 8, 2024
2 parents 56e042a + e2b3a2c commit 3983162
Show file tree
Hide file tree
Showing 10 changed files with 190 additions and 9 deletions.
2 changes: 2 additions & 0 deletions config/i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,8 @@
"EquippableDifferent2": "Maximum Power isn't limited by the \"One Exotic\" rule when determining the Power of your drops, powerful, and pinnacle rewards.",
"Equipped": "Equipped",
"Fashion": "Choose fashion",
"FashionOnly": "Fashion-only",
"ModsOnly": "Mods-only",
"Filters": "Loadout Filters",
"FilteredItems": "Filtered Items",
"FindAnother": "Find another {{name}}",
Expand Down
62 changes: 61 additions & 1 deletion src/app/loadout-drawer/loadout-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { DimCharacterStat, DimStore } from 'app/inventory/store-types';
import { SocketOverrides } from 'app/inventory/store/override-sockets';
import { isPluggableItem } from 'app/inventory/store/sockets';
import { findItemsByBucket, getCurrentStore, getStore } from 'app/inventory/stores-helpers';
import { ArmorEnergyRules } from 'app/loadout-builder/types';
import { ArmorEnergyRules, LockableBucketHashes } from 'app/loadout-builder/types';
import { calculateAssumedItemEnergy } from 'app/loadout/armor-upgrade-utils';
import { isLoadoutBuilderItem } from 'app/loadout/item-utils';
import { UNSET_PLUG_HASH } from 'app/loadout/known-values';
Expand Down Expand Up @@ -44,6 +44,8 @@ import { HashLookup, LookupTable } from 'app/utils/util-types';
import {
DestinyClass,
DestinyInventoryItemDefinition,
DestinyItemSubType,
DestinyItemType,
DestinyLoadoutItemComponent,
} from 'bungie-api-ts/destiny2';
import deprecatedMods from 'data/d2/deprecated-mods.json';
Expand Down Expand Up @@ -712,6 +714,64 @@ export function isMissingItems(
return false;
}

export function isFashionOnly(defs: D2ManifestDefinitions, loadout: Loadout): boolean {
if (loadout.items.length) {
return false;
}
if (!loadout.parameters?.modsByBucket) {
return false;
}

for (const bucketHash in loadout.parameters.modsByBucket) {
// if this is mods for a non-armor bucket
if (!LockableBucketHashes.includes(Number(bucketHash))) {
return false;
}
const modsForThisArmorSlot = loadout.parameters.modsByBucket[bucketHash];
if (modsForThisArmorSlot.some((modHash) => !isFashionPlug(defs, modHash))) {
return false;
}
}

return true;
}
/** not fashion mods, just useful ones */
export function isArmorModsOnly(defs: D2ManifestDefinitions, loadout: Loadout): boolean {
// if it contains armor, it's not a mods-only loadout
if (loadout.items.length) {
return false;
}
// if there's no mods at all, this isn't a mods-only loadout
if (!loadout.parameters?.mods?.length && !loadout.parameters?.modsByBucket) {
return false;
}
// if there's specific mods, make sure none are fashion
if (loadout.parameters?.modsByBucket) {
for (const bucketHash in loadout.parameters.modsByBucket) {
// if this is mods for a non-armor bucket
if (!LockableBucketHashes.includes(Number(bucketHash))) {
return false;
}
const modsForThisArmorSlot = loadout.parameters.modsByBucket[bucketHash];
if (modsForThisArmorSlot.some((modHash) => isFashionPlug(defs, modHash))) {
return false;
}
}
}

return true;
}

/** given a hash we know is a plug, is this a fashion plug? */
function isFashionPlug(defs: D2ManifestDefinitions, modHash: number) {
const def = defs.InventoryItem.get(modHash);
return Boolean(
def &&
(def.itemSubType === DestinyItemSubType.Shader ||
def.itemSubType === DestinyItemSubType.Ornament ||
def.itemType === DestinyItemType.Armor),
);
}
/**
* Returns a flat list of mods as PluggableInventoryItemDefinitions in the Loadout, by default including auto stat mods.
* This INCLUDES both locked and unlocked mods; `unlockedPlugs` is used to identify if the expensive or cheap copy of an
Expand Down
7 changes: 4 additions & 3 deletions src/app/loadout/LoadoutView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ClassIcon from 'app/dim-ui/ClassIcon';
import { PressTip } from 'app/dim-ui/PressTip';
import ColorDestinySymbols from 'app/dim-ui/destiny-symbols/ColorDestinySymbols';
import { t } from 'app/i18next-t';
import type { BucketSortType } from 'app/inventory/inventory-buckets';
import { DimItem } from 'app/inventory/item-types';
import { allItemsSelector, createItemContextSelector } from 'app/inventory/selectors';
import { DimStore } from 'app/inventory/store-types';
Expand Down Expand Up @@ -113,11 +114,11 @@ export default function LoadoutView({

const [allMods, modDefinitions] = useLoadoutMods(loadout, store.id);

const categories = Object.groupBy(
const loadoutItemsByCategory: Record<BucketSortType, ResolvedLoadoutItem[]> = Object.groupBy(
items.concat(warnitems),
(li) => li.item.bucket.sort ?? 'Unknown',
);
const power = loadoutPower(store, categories);
const power = loadoutPower(store, loadoutItemsByCategory);

const selectionProps = $featureFlags.elgatoStreamDeck
? // eslint-disable-next-line
Expand Down Expand Up @@ -184,7 +185,7 @@ export default function LoadoutView({
category={category}
subclass={subclass}
store={store}
items={categories[category]}
items={loadoutItemsByCategory[category]}
allMods={modDefinitions}
modsByBucket={modsByBucket}
loadout={loadout}
Expand Down
16 changes: 15 additions & 1 deletion src/app/loadout/loadout-menu/LoadoutPopup.m.scss
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@
}
}
}

.fashionIcon {
height: 20px !important;
width: 20px !important;
margin-right: 5px;
}
.modificationIcon {
height: 18px !important;
width: 18px !important;
}
}

&:hover,
Expand All @@ -69,9 +79,13 @@
> span:first-child > :global(.app-icon) {
color: var(--theme-text-invert) !important;
}
> span:first-child > img:first-child {
> span:first-child > img:first-child,
.modificationIcon {
filter: none;
}
.fashionIcon {
filter: invert(1);
}

.note {
color: var(--theme-text-invert);
Expand Down
2 changes: 2 additions & 0 deletions src/app/loadout/loadout-menu/LoadoutPopup.m.scss.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 18 additions & 2 deletions src/app/loadout/loadout-menu/LoadoutPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import {
} from 'app/loadout-drawer/loadout-drawer-reducer';
import { editLoadout } from 'app/loadout-drawer/loadout-events';
import { InGameLoadout, Loadout } from 'app/loadout-drawer/loadout-types';
import { isMissingItems, newLoadout } from 'app/loadout-drawer/loadout-utils';
import {
isArmorModsOnly,
isFashionOnly,
isMissingItems,
newLoadout,
} from 'app/loadout-drawer/loadout-utils';
import { loadoutsForClassTypeSelector } from 'app/loadout-drawer/loadouts-selector';
import { makeRoomForPostmaster, totalPostmasterItems } from 'app/loadout-drawer/postmaster';
import { previousLoadoutSelector } from 'app/loadout-drawer/selectors';
Expand Down Expand Up @@ -58,7 +63,12 @@ import { Link } from 'react-router-dom';
import { InGameLoadoutIconWithIndex } from '../ingame/InGameLoadoutIcon';
import { applyInGameLoadout } from '../ingame/ingame-loadout-apply';
import { inGameLoadoutsForCharacterSelector } from '../ingame/selectors';
import { searchAndSortLoadoutsByQuery, useLoadoutFilterPills } from '../loadout-ui/menu-hooks';
import {
FashionIcon,
ModificationsIcon,
searchAndSortLoadoutsByQuery,
useLoadoutFilterPills,
} from '../loadout-ui/menu-hooks';
import styles from './LoadoutPopup.m.scss';
import { RandomLoadoutOptions, useRandomizeLoadout } from './LoadoutPopupRandomize';
import MaxlightButton from './MaxlightButton';
Expand Down Expand Up @@ -304,6 +314,12 @@ export default function LoadoutPopup({
title={loadout.notes ? loadout.notes : loadout.name}
onClick={() => applySavedLoadout(loadout)}
>
{defs.isDestiny2() && isFashionOnly(defs, loadout) && (
<FashionIcon className={styles.fashionIcon} />
)}
{defs.isDestiny2() && isArmorModsOnly(defs, loadout) && (
<ModificationsIcon className={styles.modificationIcon} />
)}
{(dimStore.isVault || loadout.classType === DestinyClass.Unknown) && (
<ClassIcon className={styles.loadoutTypeIcon} classType={loadout.classType} />
)}
Expand Down
13 changes: 13 additions & 0 deletions src/app/loadout/loadout-ui/menu-hooks.m.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,16 @@
margin: 0 4px 0 4px;
}
}

.fashionIcon {
background-position: center;
background-size: 150%;
border-radius: 50%;
height: 15px;
width: 15px;
}
.modificationIcon {
height: 13px;
width: 13px;
filter: invert(0.8);
}
2 changes: 2 additions & 0 deletions src/app/loadout/loadout-ui/menu-hooks.m.scss.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 71 additions & 2 deletions src/app/loadout/loadout-ui/menu-hooks.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { LoadoutSort } from '@destinyitemmanager/dim-api-types';
import { bungieBackgroundStyleAdvanced } from 'app/dim-ui/BungieImage';
import FilterPills, { Option } from 'app/dim-ui/FilterPills';
import ColorDestinySymbols from 'app/dim-ui/destiny-symbols/ColorDestinySymbols';
import { DimLanguage } from 'app/i18n';
import { t } from 'app/i18next-t';
import { t, tl } from 'app/i18next-t';
import { getHashtagsFromNote } from 'app/inventory/note-hashtags';
import { DimStore } from 'app/inventory/store-types';
import { findingDisplays } from 'app/loadout-analyzer/finding-display';
import { useSummaryLoadoutsAnalysis } from 'app/loadout-analyzer/hooks';
import { LoadoutAnalysisSummary, LoadoutFinding } from 'app/loadout-analyzer/types';
import { Loadout } from 'app/loadout-drawer/loadout-types';
import { isArmorModsOnly, isFashionOnly } from 'app/loadout-drawer/loadout-utils';
import { useD2Definitions } from 'app/manifest/selectors';
import { DEFAULT_ORNAMENTS } from 'app/search/d2-known-values';
import { faCheckCircle, refreshIcon } from 'app/shell/icons';
import AppIcon from 'app/shell/icons/AppIcon';
import { compareBy } from 'app/utils/comparators';
import { emptyArray } from 'app/utils/empty';
import { localizedIncludes, localizedSorter } from 'app/utils/intl';
import clsx from 'clsx';
import modificationsIcon from 'destiny-icons/general/modifications.svg';
import _ from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import styles from './menu-hooks.m.scss';
Expand Down Expand Up @@ -42,11 +47,17 @@ export function useLoadoutFilterPills(
return useLoadoutFilterPillsInternal(savedLoadouts, store, options);
}

const loadoutSpecializations = [tl('Loadouts.FashionOnly'), tl('Loadouts.ModsOnly')] as const;
type LoadoutSpecialization = (typeof loadoutSpecializations)[number];
type FilterPillType =
| {
tag: 'hashtag';
hashtag: string;
}
| {
tag: 'loadout-type';
type: LoadoutSpecialization;
}
| { tag: 'finding'; finding: LoadoutFinding };

function useLoadoutFilterPillsInternal(
Expand All @@ -65,6 +76,7 @@ function useLoadoutFilterPillsInternal(
} = {},
): [filteredLoadouts: Loadout[], filterPillsElement: React.ReactNode, hasSelectedFilters: boolean] {
const [selectedFilters, setSelectedFilters] = useState<Option<FilterPillType>[]>(emptyArray());
const defs = useD2Definitions();
const analysisSummary = useSummaryLoadoutsAnalysis(
savedLoadouts,
store,
Expand Down Expand Up @@ -104,6 +116,35 @@ function useLoadoutFilterPillsInternal(
(o) => o.key,
);

const loadoutsByType = useMemo(() => {
const loadoutsByType: Record<LoadoutSpecialization, Loadout[]> | undefined = defs && {
'Loadouts.FashionOnly': savedLoadouts.filter((l) => isFashionOnly(defs, l)),
'Loadouts.ModsOnly': savedLoadouts.filter((l) => isArmorModsOnly(defs, l)),
};
return loadoutsByType;
}, [defs, savedLoadouts]);
if (loadoutsByType) {
for (const k of loadoutSpecializations) {
if (loadoutsByType[k].length) {
filterOptions.push({
key: k,
value: { tag: 'loadout-type', type: k },
content: (
<>
{k === 'Loadouts.ModsOnly' ? (
<ModificationsIcon className="" />
) : (
<FashionIcon className="" />
)}
{t(k)}
{` (${loadoutsByType[k].length})`}
</>
),
});
}
}
}

if (analysisSummary) {
for (const [finding_, affectedLoadouts] of Object.entries(analysisSummary.loadoutsByFindings)) {
if (affectedLoadouts.size > 0) {
Expand Down Expand Up @@ -136,6 +177,9 @@ function useLoadoutFilterPillsInternal(
case 'hashtag': {
return loadoutsByHashtag[f.value.hashtag] ?? [];
}
case 'loadout-type': {
return loadoutsByType?.[f.value.type] ?? [];
}
case 'finding': {
const loadouts = analysisSummary?.loadoutsByFindings[f.value.finding];
return loadouts?.size
Expand All @@ -146,7 +190,13 @@ function useLoadoutFilterPillsInternal(
}),
)
: savedLoadouts,
[selectedFilters, savedLoadouts, loadoutsByHashtag, analysisSummary?.loadoutsByFindings],
[
selectedFilters,
savedLoadouts,
loadoutsByHashtag,
analysisSummary?.loadoutsByFindings,
loadoutsByType,
],
);

const pills =
Expand Down Expand Up @@ -240,3 +290,22 @@ export function searchAndSortLoadoutsByQuery(
: localizedSorter(language, (l) => l.name),
);
}

export function FashionIcon({ className }: { className: string }) {
const defs = useD2Definitions();
return (
defs && (
<div
className={clsx(className, styles.fashionIcon)}
style={bungieBackgroundStyleAdvanced(
defs.InventoryItem.get(DEFAULT_ORNAMENTS[2])?.displayProperties.icon,
undefined,
2,
)}
/>
)
);
}
export function ModificationsIcon({ className }: { className: string }) {
return <img className={clsx(className, styles.modificationIcon)} src={modificationsIcon} />;
}
2 changes: 2 additions & 0 deletions src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,7 @@
"EquippableDifferent2": "Maximum Power isn't limited by the \"One Exotic\" rule when determining the Power of your drops, powerful, and pinnacle rewards.",
"Failed": "Loadout failed to apply completely",
"Fashion": "Choose fashion",
"FashionOnly": "Fashion-only",
"FillFromEquipped": "Fill in using equipped",
"FillFromInventory": "Fill in using non-equipped",
"FilteredItems": "Filtered Items",
Expand Down Expand Up @@ -792,6 +793,7 @@
"UpgradeCostsDesc": "Some armor needs energy capacity upgrades to fit the requested mods. In total, these upgrades cost:"
},
"Mods": "Mods",
"ModsOnly": "Mods-only",
"MoveItems": "Moving items",
"NoSpace": "You're out of space in the vault and any other characters.",
"NoneMatch": "None of your loadouts matched the filters.",
Expand Down

0 comments on commit 3983162

Please sign in to comment.