From e053d495043eb8c0fa6a02a3fc03755e031012a7 Mon Sep 17 00:00:00 2001 From: chainrez <186519033+chainrez@users.noreply.github.com> Date: Sun, 17 Nov 2024 22:16:28 -0800 Subject: [PATCH 1/5] WIP - more stat bar segments and details tooltip --- src/app/dim-ui/PressTip.tsx | 2 +- src/app/item-popup/ItemStat.m.scss | 93 ++++++++++--- src/app/item-popup/ItemStat.m.scss.d.ts | 11 +- src/app/item-popup/ItemStat.tsx | 178 +++++++++++++++++++----- src/app/shell/formatters.ts | 2 +- 5 files changed, 223 insertions(+), 63 deletions(-) diff --git a/src/app/dim-ui/PressTip.tsx b/src/app/dim-ui/PressTip.tsx index 15e62f719a..ae8ea89be4 100644 --- a/src/app/dim-ui/PressTip.tsx +++ b/src/app/dim-ui/PressTip.tsx @@ -125,7 +125,7 @@ function Control({ })} ref={tooltipContents} > - {Boolean(customization.header) && ( + {Boolean(customization.header || customization.subheader) && (

{customization.header}

{Boolean(customization.subheader) &&

{customization.subheader}

} diff --git a/src/app/item-popup/ItemStat.m.scss b/src/app/item-popup/ItemStat.m.scss index 16a8f7715c..db7db4dec7 100644 --- a/src/app/item-popup/ItemStat.m.scss +++ b/src/app/item-popup/ItemStat.m.scss @@ -52,7 +52,8 @@ backdrop-filter: blur(6px); } } -.barInner { + +.statBarSegment { display: block; height: 100%; float: left; @@ -61,6 +62,77 @@ background-color: white; color: black; transition: width 150ms ease-in-out; + + &.negative { + background-color: #640000; + } + + // An assumption: never more than 4 part effects in a single stat? + // 3 have been observed on weapons with a barrel + mag + grip. + &.parts { + opacity: 0.9; + & + &.parts { + opacity: 0.8; + & + &.parts { + opacity: 0.7; + & + &.parts { + opacity: 0.6; + } + } + } + } + + &.mod { + background-color: $stat-modded; + & + &.mod { + opacity: 0.8; + & + &.mod { + opacity: 0.6; + } + } + } + + // Colors for the stat bars + &.masterwork { + background-color: $stat-masterworked; + } +} + +.statBarTooltip { + display: grid; + grid-template-columns: auto auto; + column-gap: 4px; + + & > :nth-child(2n-1) { + text-align: right; + } + + .mod { + color: $stat-modded; + } + .masterwork { + color: $stat-masterworked; + } + .negative { + color: $red; + } + .base { + opacity: 1; + } + .parts { + opacity: 0.8; + } + + .tooltipTotalRow { + display: grid; + grid-template-columns: subgrid; + grid-column: span 2; + border-top: 1px solid #fff; + font-weight: bold; + span:nth-child(2) { + text-align: left; + } + } } .qualitySummary { @@ -75,27 +147,8 @@ margin-left: 4px; } -// Colors for the stat bars -.masterworkStatBar { - background-color: $stat-masterworked; -} -.moddedStatBar { - background-color: $stat-modded; - & + .moddedStatBar { - background-color: $stat-modded; - opacity: 0.8; - & + .moddedStatBar { - background-color: $stat-modded; - opacity: 0.6; - } - } -} - .masterworked { color: $stat-masterworked; - .statName { - font-weight: bold; - } } .modded { diff --git a/src/app/item-popup/ItemStat.m.scss.d.ts b/src/app/item-popup/ItemStat.m.scss.d.ts index bfb41a1dd6..1dc3abf17f 100644 --- a/src/app/item-popup/ItemStat.m.scss.d.ts +++ b/src/app/item-popup/ItemStat.m.scss.d.ts @@ -2,20 +2,25 @@ // Please do not change this file! interface CssExports { 'barContainer': string; - 'barInner': string; + 'base': string; 'customTotal': string; 'icon': string; - 'masterworkStatBar': string; + 'masterwork': string; 'masterworked': string; + 'mod': string; 'modded': string; - 'moddedStatBar': string; + 'negative': string; 'negativeModded': string; 'nonDimmedStatIcons': string; + 'parts': string; 'quality': string; 'qualitySummary': string; 'smallStatToggle': string; 'statBar': string; + 'statBarSegment': string; + 'statBarTooltip': string; 'statName': string; + 'tooltipTotalRow': string; 'totalRow': string; 'totalStatDetailed': string; 'totalStatMasterwork': string; diff --git a/src/app/item-popup/ItemStat.tsx b/src/app/item-popup/ItemStat.tsx index f533319bb2..2fe6d88878 100644 --- a/src/app/item-popup/ItemStat.tsx +++ b/src/app/item-popup/ItemStat.tsx @@ -18,6 +18,7 @@ import { DestinySocketCategoryStyle } from 'bungie-api-ts/destiny2'; import clsx from 'clsx'; import { ItemCategoryHashes, StatHashes } from 'data/d2/generated-enums'; import { clamp } from 'es-toolkit'; +import React from 'react'; import { useSelector } from 'react-redux'; import { getSocketsWithStyle, socketContainsIntrinsicPlug } from '../utils/socket-utils'; import styles from './ItemStat.m.scss'; @@ -30,12 +31,38 @@ const modItemCategoryHashes = new Set([ ItemCategoryHashes.ArmorMods, // armor 2.0 mods ]); +// used in displaying the component segments on item stats +const weaponParts = new Set([ + ItemCategoryHashes.WeaponModsBowstring, + ItemCategoryHashes.WeaponModsBatteries, + ItemCategoryHashes.WeaponModsSwordBlades, + ItemCategoryHashes.WeaponModsLaunchTubes, + ItemCategoryHashes.WeaponModsScopes, + ItemCategoryHashes.WeaponModsHafts, + ItemCategoryHashes.WeaponModsStocks, + ItemCategoryHashes.WeaponModsSwordGuards, + ItemCategoryHashes.WeaponModsBarrels, + ItemCategoryHashes.WeaponModsArrows, + ItemCategoryHashes.WeaponModsGrips, + ItemCategoryHashes.WeaponModsSights, + ItemCategoryHashes.WeaponModsMagazines, +]); + // Some stat labels are long. This lets us replace them with i18n const statLabels: LookupTable = { [StatHashes.RoundsPerMinute]: tl('Organizer.Stats.RPM'), [StatHashes.AirborneEffectiveness]: tl('Organizer.Stats.Airborne'), }; +type StatSegmentType = 'base' | 'parts' | 'mod' | 'masterwork'; +const statStyles: Record = { + base: [styles.base, 'Base Stat'], + parts: [styles.parts, 'Parts'], + mod: [styles.mod, tl('Loadouts.Mods')], + masterwork: [styles.masterwork, 'Masterwork'], +}; +type StatSegments = [value: number, statSegmentType: StatSegmentType, modName?: string][]; + /** * A single stat line. */ @@ -43,6 +70,17 @@ export default function ItemStat({ stat, item }: { stat: DimStat; item?: DimItem const showQuality = useSelector(settingSelector('itemQuality')); const customStatsList = useSelector(customStatsSelector); const customStatHashes = customStatsList.map((c) => c.statHash); + + const modEffects = + item && + getModEffects(item, stat.statHash).sort(reverseComparator(compareBy(([value]) => value))); + const modEffectsTotal = modEffects ? sumBy(modEffects, ([value]) => value) : 0; + + const partEffects = + item && + getPartEffects(item, stat.statHash).sort(reverseComparator(compareBy(([value]) => value))); + const partEffectsTotal = partEffects ? sumBy(partEffects, ([value]) => value) : 0; + const armor2MasterworkSockets = item?.sockets && getSocketsWithStyle(item.sockets, DestinySocketCategoryStyle.EnergyMeter); const armor2MasterworkValue = @@ -50,36 +88,39 @@ export default function ItemStat({ stat, item }: { stat: DimStat; item?: DimItem const masterworkValue = item?.masterworkInfo?.stats?.find((s) => s.hash === stat.statHash)?.value ?? 0; + // This bool controls the stat name being gold const isMasterworkedStat = masterworkValue !== 0; - const masterworkDisplayValue = masterworkValue ?? armor2MasterworkValue; - - const modEffects = - item && - getModEffects(item, stat.statHash).sort(reverseComparator(compareBy(([value]) => value))); - const modEffectsTotal = modEffects ? sumBy(modEffects, ([value]) => value) : 0; - + const masterworkDisplayValue = masterworkValue || armor2MasterworkValue; + let masterworkDisplayWidth = masterworkDisplayValue || 0; + // baseBar here is the leftmost segment of the stat bar. + // For armor, this is the "roll," the sum of its invisible stat plugs. + // For weapons, this is the default base stat in its item definition, before barrels/mags/etc. const baseBar = item?.bucket.inArmor ? // if it's armor, the base bar length should be // the shortest of base or resulting value, but not below 0 Math.max(Math.min(stat.base, stat.value), 0) : // otherwise, for weapons, we just subtract masterwork and // consider the "base" to include selected perks but not mods - stat.value - masterworkValue - modEffectsTotal; + stat.value - masterworkValue - modEffectsTotal - partEffectsTotal; - const segments: [amount: number, classname?: string, modName?: string][] = [[baseBar]]; + const segments: StatSegments = [[baseBar, 'base']]; - if (modEffects && modEffectsTotal > 0) { - for (const [effectAmount, modName] of modEffects) { - segments.push([effectAmount, styles.moddedStatBar, modName]); - } + for (const [effectAmount, modName] of partEffects ?? []) { + segments.push([effectAmount, 'parts', modName]); + } - if (masterworkDisplayValue) { - segments.push([masterworkDisplayValue, styles.masterworkStatBar]); + for (const [effectAmount, modName] of modEffects ?? []) { + segments.push([effectAmount, 'mod', modName]); + } + + if (masterworkDisplayWidth) { + // Account for a masterwork being completely counteracted by a mod penalty. + // A MW segment cannot be longer than the bar's total. + // ie: a +6 base, a +2mw, and a -10 mod, results in 0. MW segment width is 0. + if (modEffectsTotal < 0) { + masterworkDisplayWidth = clamp(masterworkDisplayWidth, 0, stat.value); } - } else if (modEffectsTotal < 0 && masterworkDisplayValue) { - segments.push([clamp(masterworkDisplayValue, 0, stat.value), styles.masterworkStatBar]); - } else if (masterworkDisplayValue) { - segments.push([masterworkDisplayValue, styles.masterworkStatBar]); + segments.push([masterworkDisplayWidth, 'masterwork']); } // Get the values that contribute to the total stat value @@ -93,7 +134,7 @@ export default function ItemStat({ stat, item }: { stat: DimStat; item?: DimItem const optionalClasses = { [styles.masterworked]: isMasterworkedStat, - [styles.modded]: modSign > 0, + [styles.mod]: modSign > 0, [styles.negativeModded]: modSign < 0, [styles.totalRow]: Boolean(totalDetails), [styles.customTotal]: customStatHashes.includes(stat.statHash), @@ -160,24 +201,68 @@ export default function ItemStat({ stat, item }: { stat: DimStat; item?: DimItem ); } -function StatBar({ segments, stat }: { segments: [number, string?, string?][]; stat: DimStat }) { +function StatBar({ segments, stat }: { segments: StatSegments; stat: DimStat }) { return ( -