From 90b4bb0b4a081cadb91cb59c2d5d4b2688e03552 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:25:54 +0300 Subject: [PATCH] Themed sub and special icons --- app/components/GearSelect.tsx | 2 +- app/components/Image.module.css | 59 +++++ app/components/Image.tsx | 77 +++++- app/components/WeaponSelect.tsx | 43 ++-- app/components/elements/Select.tsx | 9 +- app/components/match-page/MatchBanner.tsx | 13 +- .../build-analyzer/routes/analyzer.tsx | 38 +-- .../components/DamageComboBar.tsx | 23 +- .../components/SelectedWeapons.tsx | 24 +- .../components/SpecialRangeVisualization.tsx | 15 +- .../components/WeaponCategories.tsx | 15 +- .../comp-analyzer/components/WeaponGrid.tsx | 41 ++- .../object-damage-calculator.module.css | 2 + .../routes/object-damage-calculator.tsx | 150 +++++++---- app/features/sendouq/components/GroupCard.tsx | 23 +- app/form/fields/SubSpecialSelectFormField.tsx | 24 +- app/utils/urls.ts | 17 ++ .../2026-09-21-ink-colored-weapon-icons.md | 4 + package.json | 1 + scripts/create-ink-detail-images.ts | 235 ++++++++++++++++++ 20 files changed, 611 insertions(+), 204 deletions(-) create mode 100644 changelog/2026-09-21-ink-colored-weapon-icons.md create mode 100644 scripts/create-ink-detail-images.ts diff --git a/app/components/GearSelect.tsx b/app/components/GearSelect.tsx index 959615fd5..5cacdc1e0 100644 --- a/app/components/GearSelect.tsx +++ b/app/components/GearSelect.tsx @@ -57,7 +57,7 @@ export function GearSelect({ } key={key} > {gear.map(({ id, name }) => ( diff --git a/app/components/Image.module.css b/app/components/Image.module.css index d877ae152..c16fef254 100644 --- a/app/components/Image.module.css +++ b/app/components/Image.module.css @@ -6,3 +6,62 @@ grid-column: 1; grid-row: 1; } + +/* the outline is the union of eight offset copies of the silhouette, which mask + layers composite with `add` by default. chained drop-shadows would instead + feed each shadow into the next and scatter ghost copies at every sum of the + offsets. the art is inset by the outline width so the outline stays inside + the box the icon was asked to fill */ +.inkTinted { + --ink-outline-width: 1px; + --ink-outline-color: rgba(0, 0, 0, 0.25); + + --_art-size: calc(100% - var(--ink-outline-width) * 2); + --_straight: var(--ink-outline-width); + --_diagonal: calc(var(--ink-outline-width) * 0.707); + + display: block; + position: relative; + background-color: var(--ink-outline-color); + mask-image: + var(--ink-silhouette), var(--ink-silhouette), var(--ink-silhouette), + var(--ink-silhouette), var(--ink-silhouette), var(--ink-silhouette), + var(--ink-silhouette), var(--ink-silhouette); + mask-size: var(--_art-size); + mask-repeat: no-repeat; + mask-position: + calc(50% - var(--_straight)) 50%, + calc(50% + var(--_straight)) 50%, + 50% calc(50% - var(--_straight)), + 50% calc(50% + var(--_straight)), + calc(50% - var(--_diagonal)) calc(50% - var(--_diagonal)), + calc(50% + var(--_diagonal)) calc(50% - var(--_diagonal)), + calc(50% - var(--_diagonal)) calc(50% + var(--_diagonal)), + calc(50% + var(--_diagonal)) calc(50% + var(--_diagonal)); +} + +/* real elements rather than pseudo elements: snapdom (the image export) only + inlines mask images it finds on real ones, and a dropped mask paints the + whole layer over the icon */ +.inkArt, +.inkHighlight { + position: absolute; + inset: 0; + mask-size: var(--_art-size); + mask-repeat: no-repeat; + mask-position: center; +} + +.inkArt { + background-color: var(--color-accent); + background-image: var(--ink-detail); + background-size: var(--_art-size); + background-repeat: no-repeat; + background-position: center; + mask-image: var(--ink-silhouette); +} + +.inkHighlight { + background-color: #fff; + mask-image: var(--ink-highlight); +} diff --git a/app/components/Image.tsx b/app/components/Image.tsx index a24ff642d..8255f4905 100644 --- a/app/components/Image.tsx +++ b/app/components/Image.tsx @@ -14,8 +14,12 @@ import { outlinedFiveStarMainWeaponImageUrl, outlinedMainWeaponImageUrl, outlinedTenStarMainWeaponImageUrl, + specialWeaponDetailImageUrl, + specialWeaponHighlightImageUrl, specialWeaponImageUrl, stageImageUrl, + subWeaponDetailImageUrl, + subWeaponHighlightImageUrl, subWeaponImageUrl, TIER_PLUS_URL, tierImageUrl, @@ -167,48 +171,109 @@ export function StageImage({ stageId, testId, ...rest }: StageImageProps) { type SubWeaponImageProps = { subWeaponId: SubWeaponId; + alt?: string; } & Omit; export function SubWeaponImage({ subWeaponId, + alt, testId, ...rest }: SubWeaponImageProps) { const { t } = useTranslation(["weapons"]); + const name = alt ?? t(`weapons:SUB_${subWeaponId}`); + return ( - {t(`weapons:SUB_${subWeaponId}`)} ); } type SpecialWeaponImageProps = { specialWeaponId: SpecialWeaponId; + alt?: string; } & Omit; export function SpecialWeaponImage({ specialWeaponId, + alt, testId, ...rest }: SpecialWeaponImageProps) { const { t } = useTranslation(["weapons"]); + const name = alt ?? t(`weapons:SPECIAL_${specialWeaponId}`); + return ( - {t(`weapons:SPECIAL_${specialWeaponId}`)} ); } +type InkTintedImageProps = { + /** Icon whose alpha channel is the silhouette to fill with the accent color. */ + path: string; + /** Overlay holding the teal parts of the icon. */ + detailPath: string; + /** Mask of the white parts of the icon, painted in the text color. */ + highlightPath: string; +} & Omit; + +function InkTintedImage({ + path, + detailPath, + highlightPath, + alt, + title, + className, + containerClassName, + containerStyle, + width, + height, + size, + style, + testId, +}: InkTintedImageProps) { + return ( +
+ + + + +
+ ); +} + type TierImageProps = { tier: { name: TierName; isPlus: boolean }; } & Omit; diff --git a/app/components/WeaponSelect.tsx b/app/components/WeaponSelect.tsx index baeb5e7d4..36bc5f1ce 100644 --- a/app/components/WeaponSelect.tsx +++ b/app/components/WeaponSelect.tsx @@ -7,7 +7,12 @@ import { SendouSelectItem, SendouSelectItemSection, } from "~/components/elements/Select"; -import { Image, WeaponImage } from "~/components/Image"; +import { + Image, + SpecialWeaponImage, + SubWeaponImage, + WeaponImage, +} from "~/components/Image"; import type { AnyWeapon } from "~/features/build-analyzer/analyzer-types"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { filterWeapon } from "~/modules/in-game-lists/utils"; @@ -20,11 +25,7 @@ import { TRIZOOKA_ID, weaponCategories, } from "~/modules/in-game-lists/weapon-ids"; -import { - specialWeaponImageUrl, - subWeaponImageUrl, - weaponCategoryUrl, -} from "~/utils/urls"; +import { weaponCategoryUrl } from "~/utils/urls"; import styles from "./WeaponSelect.module.css"; @@ -124,14 +125,18 @@ export function WeaponSelect< {({ key, items: weapons, name, idx }) => ( + ) : name === "specials" ? ( + + ) : ( + + ) } className={idx === 0 ? "pt-0-5" : undefined} key={key} @@ -157,17 +162,15 @@ export function WeaponSelect< className={styles.weaponImg} /> ) : weapon.type === "SUB" ? ( - ) : ( - )} diff --git a/app/components/elements/Select.tsx b/app/components/elements/Select.tsx index cfab43b5b..71faab644 100644 --- a/app/components/elements/Select.tsx +++ b/app/components/elements/Select.tsx @@ -7,7 +7,6 @@ import { SendouButton } from "~/components/elements/Button"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { type FocusMove, rovingFocusIndex } from "~/utils/roving-focus"; import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; -import { Image } from "../Image"; import { focusLeftTo, isOwnToggle, @@ -901,14 +900,14 @@ function SelectOption(props: SendouSelectItemProps) { interface SendouSelectItemSectionProps { heading: string; - headingImgPath?: string; + headingImg?: React.ReactNode; children: React.ReactNode; className?: string; } export function SendouSelectItemSection({ heading, - headingImgPath, + headingImg, children, className, }: SendouSelectItemSectionProps) { @@ -922,9 +921,7 @@ export function SendouSelectItemSection({ // biome-ignore lint/a11y/useSemanticElements: a fieldset would carry form semantics this listbox section does not have
- {headingImgPath ? ( - - ) : null} + {headingImg} {heading}
diff --git a/app/components/match-page/MatchBanner.tsx b/app/components/match-page/MatchBanner.tsx index 5570789b8..c296ef9ec 100644 --- a/app/components/match-page/MatchBanner.tsx +++ b/app/components/match-page/MatchBanner.tsx @@ -10,8 +10,9 @@ import { } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; -import { specialWeaponImageUrl, stageBannerImageUrl } from "~/utils/urls"; -import { ModeImage } from "../Image"; +import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids"; +import { stageBannerImageUrl } from "~/utils/urls"; +import { ModeImage, SpecialWeaponImage } from "../Image"; import styles from "./MatchBanner.module.css"; interface BannerHost { @@ -240,11 +241,9 @@ function ScreenNotice({ screenLegal }: { screenLegal: boolean }) { testId={screenLegal ? "screen-allowed" : "screen-banned"} aria-label={screenLegal ? "Screen allowed" : "Screen banned"} > - - {t(`weapons:SUB_${analyzed.weapon.subWeaponSplId}`)} {t(`weapons:SUB_${analyzed.weapon.subWeaponSplId}`)}
@@ -504,15 +500,9 @@ function BuildAnalyzerPage() { title={t("analyzer:stat.category.special")} summaryRightContent={
- {t( {t(`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`)}
@@ -1341,12 +1331,7 @@ function EffectsSelector({ ) : effect.type === "AURA" ? ( AURA ) : ( - {t("weapons:SPECIAL_15")} + )}
@@ -1721,12 +1706,7 @@ function DamageTable({
{damageIsSubWeaponDamage(val) ? ( - + ) : null}{" "} {t(typeRowName as any)}{" "} {damageIsSubWeaponDamage(val) && val.type === "SPLASH" ? ( diff --git a/app/features/comp-analyzer/components/DamageComboBar.tsx b/app/features/comp-analyzer/components/DamageComboBar.tsx index 098508f30..2d1df8d6a 100644 --- a/app/features/comp-analyzer/components/DamageComboBar.tsx +++ b/app/features/comp-analyzer/components/DamageComboBar.tsx @@ -1,7 +1,12 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { SendouSwitch } from "~/components/elements/Switch"; -import { Image, WeaponImage } from "~/components/Image"; +import { + Image, + SpecialWeaponImage, + SubWeaponImage, + WeaponImage, +} from "~/components/Image"; import { MAX_AP } from "~/features/build-analyzer/analyzer-constants"; import { mainWeaponParams } from "~/features/build-analyzer/core/utils"; import type { @@ -9,11 +14,7 @@ import type { SpecialWeaponId, SubWeaponId, } from "~/modules/in-game-lists/types"; -import { - abilityImageUrl, - specialWeaponImageUrl, - subWeaponImageUrl, -} from "~/utils/urls"; +import { abilityImageUrl } from "~/utils/urls"; import { LETHAL_DAMAGE } from "../comp-analyzer-constants"; import { useSingleWeaponCombos, @@ -201,9 +202,8 @@ function WeaponIcon({ }: WeaponIconProps) { if (isSubWeapon) { return ( - @@ -212,9 +212,8 @@ function WeaponIcon({ if (isSpecialWeapon) { return ( - diff --git a/app/features/comp-analyzer/components/SelectedWeapons.tsx b/app/features/comp-analyzer/components/SelectedWeapons.tsx index 35b7328d3..a30619d2e 100644 --- a/app/features/comp-analyzer/components/SelectedWeapons.tsx +++ b/app/features/comp-analyzer/components/SelectedWeapons.tsx @@ -16,14 +16,15 @@ import { import { CSS } from "@dnd-kit/utilities"; import clsx from "clsx"; import { useTranslation } from "react-i18next"; -import { Image, WeaponImage } from "~/components/Image"; +import { + Image, + SpecialWeaponImage, + SubWeaponImage, + WeaponImage, +} from "~/components/Image"; import { mainWeaponParams } from "~/features/build-analyzer/core/utils"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import { - abilityImageUrl, - specialWeaponImageUrl, - subWeaponImageUrl, -} from "~/utils/urls"; +import { abilityImageUrl } from "~/utils/urls"; import { MAX_WEAPONS } from "../comp-analyzer-constants"; import styles from "./SelectedWeapons.module.css"; @@ -173,16 +174,11 @@ function SortableWeaponRow({
- {t(`weapons:SUB_${params.subWeaponId}`)} +
- {t(`weapons:SPECIAL_${params.specialWeaponId}`)}
diff --git a/app/features/comp-analyzer/components/SpecialRangeVisualization.tsx b/app/features/comp-analyzer/components/SpecialRangeVisualization.tsx index 6ca7f606d..6b5adf1ee 100644 --- a/app/features/comp-analyzer/components/SpecialRangeVisualization.tsx +++ b/app/features/comp-analyzer/components/SpecialRangeVisualization.tsx @@ -1,8 +1,6 @@ // note: dev only component, not used in production code -import { useTranslation } from "react-i18next"; -import { Image } from "~/components/Image"; -import { specialWeaponImageUrl } from "~/utils/urls"; +import { SpecialWeaponImage } from "~/components/Image"; import { getSpecialsWithRange, type SpecialWeaponWithRange, @@ -15,8 +13,6 @@ const RANGE_TYPE_COLOR: Record = { }; export function SpecialRangeVisualization() { - const { t } = useTranslation(["weapons"]); - const specials = getSpecialsWithRange(); if (specials.length === 0) { return null; @@ -53,12 +49,9 @@ export function SpecialRangeVisualization() { return (
- {t(`weapons:SPECIAL_${special.specialWeaponId}`)}
{blastWidth > 0 ? ( diff --git a/app/features/comp-analyzer/components/WeaponCategories.tsx b/app/features/comp-analyzer/components/WeaponCategories.tsx index 9951266cc..14ab5f2b5 100644 --- a/app/features/comp-analyzer/components/WeaponCategories.tsx +++ b/app/features/comp-analyzer/components/WeaponCategories.tsx @@ -1,12 +1,11 @@ import { useTranslation } from "react-i18next"; -import { Image } from "~/components/Image"; +import { SpecialWeaponImage, SubWeaponImage } from "~/components/Image"; import { mainWeaponParams } from "~/features/build-analyzer/core/utils"; import type { MainWeaponId, SpecialWeaponId, SubWeaponId, } from "~/modules/in-game-lists/types"; -import { specialWeaponImageUrl, subWeaponImageUrl } from "~/utils/urls"; import { SPECIAL_CATEGORY_ORDER, SPECIAL_WEAPON_CATEGORIES, @@ -86,11 +85,7 @@ export function WeaponCategories({ selectedWeaponIds }: WeaponCategoriesProps) { className={styles.categoryItem} data-first={index === 0} > - {t(`weapons:SUB_${item.subId}`)} + {t(`analyzer:comp.subCategory.${item.category}`)} @@ -109,11 +104,7 @@ export function WeaponCategories({ selectedWeaponIds }: WeaponCategoriesProps) { className={styles.categoryItem} data-first={index === 0} > - {t(`weapons:SPECIAL_${item.specialId}`)} + {t(`analyzer:comp.specialCategory.${item.category}`)} diff --git a/app/features/comp-analyzer/components/WeaponGrid.tsx b/app/features/comp-analyzer/components/WeaponGrid.tsx index a21407420..e06ace951 100644 --- a/app/features/comp-analyzer/components/WeaponGrid.tsx +++ b/app/features/comp-analyzer/components/WeaponGrid.tsx @@ -1,5 +1,10 @@ import { useTranslation } from "react-i18next"; -import { Image, WeaponImage } from "~/components/Image"; +import { + Image, + SpecialWeaponImage, + SubWeaponImage, + WeaponImage, +} from "~/components/Image"; import { Label } from "~/components/Label"; import { mainWeaponParams } from "~/features/build-analyzer/core/utils"; import type { @@ -13,11 +18,7 @@ import { subWeaponIds, weaponCategories, } from "~/modules/in-game-lists/weapon-ids"; -import { - specialWeaponImageUrl, - subWeaponImageUrl, - weaponCategoryUrl, -} from "~/utils/urls"; +import { weaponCategoryUrl } from "~/utils/urls"; import { MAX_WEAPONS } from "../comp-analyzer-constants"; import type { CategorizationType } from "../comp-analyzer-types"; import styles from "./WeaponGrid.module.css"; @@ -112,9 +113,7 @@ export function WeaponGrid({ {groupedWeapons.map((group) => (
- {group.iconPath ? ( - - ) : null} + {group.name.startsWith("SUB_") || group.name.startsWith("SPECIAL_") @@ -155,19 +154,35 @@ export function WeaponGrid({ ); } +type WeaponGroupIconValue = + | { kind: "category"; name: (typeof weaponCategories)[number]["name"] } + | { kind: "sub"; id: SubWeaponId } + | { kind: "special"; id: SpecialWeaponId }; + interface WeaponGroup { key: string; name: string; - iconPath: string | null; + icon: WeaponGroupIconValue; weaponIds: MainWeaponId[]; } +function WeaponGroupIcon({ icon }: { icon: WeaponGroupIconValue }) { + switch (icon.kind) { + case "category": + return ; + case "sub": + return ; + case "special": + return ; + } +} + function groupWeaponsByType(categorization: CategorizationType): WeaponGroup[] { if (categorization === "category") { return weaponCategories.map((category) => ({ key: category.name, name: category.name.toLowerCase(), - iconPath: weaponCategoryUrl(category.name), + icon: { kind: "category" as const, name: category.name }, weaponIds: [...category.weaponIds] as MainWeaponId[], })); } @@ -183,7 +198,7 @@ function groupWeaponsByType(categorization: CategorizationType): WeaponGroup[] { return { key: `sub-${subId}`, name: `SUB_${subId}`, - iconPath: subWeaponImageUrl(subId as SubWeaponId), + icon: { kind: "sub" as const, id: subId as SubWeaponId }, weaponIds: weaponsWithSub, }; }) @@ -200,7 +215,7 @@ function groupWeaponsByType(categorization: CategorizationType): WeaponGroup[] { return { key: `special-${specialId}`, name: `SPECIAL_${specialId}`, - iconPath: specialWeaponImageUrl(specialId as SpecialWeaponId), + icon: { kind: "special" as const, id: specialId as SpecialWeaponId }, weaponIds: weaponsWithSpecial, }; }) diff --git a/app/features/object-damage-calculator/routes/object-damage-calculator.module.css b/app/features/object-damage-calculator/routes/object-damage-calculator.module.css index c35059a70..3b1d42183 100644 --- a/app/features/object-damage-calculator/routes/object-damage-calculator.module.css +++ b/app/features/object-damage-calculator/routes/object-damage-calculator.module.css @@ -79,6 +79,8 @@ } .receiverImage { + display: flex; + width: fit-content; padding: var(--s-2); border-radius: var(--radius-box); background-color: var(--color-bg); 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 ba7eb4937..900198ad2 100644 --- a/app/features/object-damage-calculator/routes/object-damage-calculator.tsx +++ b/app/features/object-damage-calculator/routes/object-damage-calculator.tsx @@ -5,12 +5,21 @@ import type { MetaFunction, ShouldRevalidateFunction } from "react-router"; import { Ability } from "~/components/Ability"; import { SendouPopover } from "~/components/elements/Popover"; import { SendouSwitch } from "~/components/elements/Switch"; -import { Image, WeaponImage } from "~/components/Image"; +import { + Image, + SpecialWeaponImage, + SubWeaponImage, + WeaponImage, +} from "~/components/Image"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import { WeaponSelect } from "~/components/WeaponSelect"; import { possibleApValues } from "~/features/build-analyzer/analyzer-constants"; import type { DamageType } from "~/features/build-analyzer/analyzer-types"; +import type { + SpecialWeaponId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; import { BIG_BUBBLER_ID, BOOYAH_BOMB_ID, @@ -33,9 +42,7 @@ import { modeImageUrl, navIconUrl, OBJECT_DAMAGE_CALCULATOR_URL, - specialWeaponImageUrl, specialWeaponVariantImageUrl, - subWeaponImageUrl, } from "~/utils/urls"; import { translateDamageReceiver } from "../calculator-constants"; import { useObjectDamage } from "../calculator-hooks"; @@ -191,41 +198,89 @@ function DamageTypesSelect({ ); } -const damageReceiverImages: Record = { - Bomb_TorpedoBullet: subWeaponImageUrl(TORPEDO_ID), - BlowerInhale: specialWeaponImageUrl(INK_VAC_ID), - Chariot: specialWeaponImageUrl(CRAB_TANK_ID), - Gachihoko_Barrier: modeImageUrl("RM"), - GreatBarrier_Barrier: specialWeaponImageUrl(BIG_BUBBLER_ID), - GreatBarrier_WeakPoint: specialWeaponVariantImageUrl( - BIG_BUBBLER_ID, - "weakpoints", - ), - NiceBall_Armor: specialWeaponImageUrl(BOOYAH_BOMB_ID), - ShockSonar: specialWeaponImageUrl(WAVE_BREAKER_ID), - Wsb_Flag: subWeaponImageUrl(SQUID_BEAKON_ID), - Wsb_Shield: subWeaponImageUrl(SPLASH_WALL_ID), - Wsb_Sprinkler: subWeaponImageUrl(SPRINKLER_ID), - BulletUmbrellaCanopyNormal: mainWeaponImageUrl(6000), - BulletUmbrellaCanopyWide: mainWeaponImageUrl(6010), - BulletUmbrellaCanopyCompact: mainWeaponImageUrl(6020), - BulletShelterCanopyFocus: mainWeaponImageUrl(6030), - BulletUmbrellaCanopyNormal_Launched: mainWeaponVariantImageUrl( - 6000, - "launched", - ), - BulletUmbrellaCanopyWide_Launched: mainWeaponVariantImageUrl( - 6010, - "launched", - ), - BulletShelterCanopyFocus_Launched: mainWeaponVariantImageUrl( - 6030, - "launched", - ), - Decoy: specialWeaponImageUrl(SUPER_CHUMP_ID), - BulletPogo: specialWeaponImageUrl(TRIPLE_SPLASHDOWN_ID), +const RECEIVER_IMAGE_SIZE = 24; + +type DamageReceiverIcon = + | { kind: "sub"; id: SubWeaponId } + | { kind: "special"; id: SpecialWeaponId } + | { kind: "path"; path: string }; + +const damageReceiverImages: Record = { + Bomb_TorpedoBullet: { kind: "sub", id: TORPEDO_ID }, + BlowerInhale: { kind: "special", id: INK_VAC_ID }, + Chariot: { kind: "special", id: CRAB_TANK_ID }, + Gachihoko_Barrier: { kind: "path", path: modeImageUrl("RM") }, + GreatBarrier_Barrier: { kind: "special", id: BIG_BUBBLER_ID }, + GreatBarrier_WeakPoint: { + kind: "path", + path: specialWeaponVariantImageUrl(BIG_BUBBLER_ID, "weakpoints"), + }, + NiceBall_Armor: { kind: "special", id: BOOYAH_BOMB_ID }, + ShockSonar: { kind: "special", id: WAVE_BREAKER_ID }, + Wsb_Flag: { kind: "sub", id: SQUID_BEAKON_ID }, + Wsb_Shield: { kind: "sub", id: SPLASH_WALL_ID }, + Wsb_Sprinkler: { kind: "sub", id: SPRINKLER_ID }, + BulletUmbrellaCanopyNormal: { kind: "path", path: mainWeaponImageUrl(6000) }, + BulletUmbrellaCanopyWide: { kind: "path", path: mainWeaponImageUrl(6010) }, + BulletUmbrellaCanopyCompact: { + kind: "path", + path: mainWeaponImageUrl(6020), + }, + BulletShelterCanopyFocus: { kind: "path", path: mainWeaponImageUrl(6030) }, + BulletUmbrellaCanopyNormal_Launched: { + kind: "path", + path: mainWeaponVariantImageUrl(6000, "launched"), + }, + BulletUmbrellaCanopyWide_Launched: { + kind: "path", + path: mainWeaponVariantImageUrl(6010, "launched"), + }, + BulletShelterCanopyFocus_Launched: { + kind: "path", + path: mainWeaponVariantImageUrl(6030, "launched"), + }, + Decoy: { kind: "special", id: SUPER_CHUMP_ID }, + BulletPogo: { kind: "special", id: TRIPLE_SPLASHDOWN_ID }, }; +function DamageReceiverImage({ + icon, + alt, +}: { + icon: DamageReceiverIcon; + alt: string; +}) { + switch (icon.kind) { + case "sub": + return ( + + ); + case "special": + return ( + + ); + case "path": + return ( + {alt} + ); + } +} + const damageReceiverAp: Partial> = { GreatBarrier_Barrier: ( @@ -293,19 +348,15 @@ function DamageReceiversGrid({ className={styles.weaponImage} /> ) : weapon.type === "SUB" ? ( - ) : ( - )} @@ -342,12 +393,9 @@ function DamageReceiversGrid({ - {translateReceiver(damageToReceiver.receiver)} } diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 8027885d5..f15c1d91b 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -9,7 +9,13 @@ import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; -import { Image, ModeImage, TierImage, WeaponImage } from "~/components/Image"; +import { + Image, + ModeImage, + SpecialWeaponImage, + TierImage, + WeaponImage, +} from "~/components/Image"; import { NoteAvatar } from "~/components/NoteAvatar"; import { useUser } from "~/features/auth/core/user"; import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants"; @@ -24,12 +30,7 @@ import { languagesUnified } from "~/modules/i18n/config"; import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids"; import { nullFilledArray } from "~/utils/arrays"; import { inGameNameWithoutDiscriminator } from "~/utils/strings"; -import { - SENDOUQ_LOOKING_PAGE, - specialWeaponImageUrl, - TIERS_PAGE, - tierImageUrl, -} from "~/utils/urls"; +import { SENDOUQ_LOOKING_PAGE, TIERS_PAGE, tierImageUrl } from "~/utils/urls"; import { finishUpdateIfUnmoved, usePageViewTransitionClass, @@ -144,11 +145,9 @@ export function GroupCard({
{group.noScreen ? (
- {`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`}
) : null} diff --git a/app/form/fields/SubSpecialSelectFormField.tsx b/app/form/fields/SubSpecialSelectFormField.tsx index 8263199ed..7e2fdfdde 100644 --- a/app/form/fields/SubSpecialSelectFormField.tsx +++ b/app/form/fields/SubSpecialSelectFormField.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; -import { Image } from "~/components/Image"; +import { SpecialWeaponImage, SubWeaponImage } from "~/components/Image"; import type { SpecialWeaponId, SubWeaponId, @@ -9,7 +9,6 @@ import { specialWeaponIds, subWeaponIds, } from "~/modules/in-game-lists/weapon-ids"; -import { specialWeaponImageUrl, subWeaponImageUrl } from "~/utils/urls"; import type { FormFieldProps } from "../types"; import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper"; import styles from "./SubSpecialSelectFormField.module.css"; @@ -38,17 +37,12 @@ export function SubSpecialSelectFormField({ const { t } = useTranslation(["weapons"]); const { translatedLabel } = useTranslatedTexts({ label }); - const options: Array<{ id: number; name: string; imgPath: string }> = + const options: Array<{ id: number; name: string }> = weaponType === "SUB" - ? subWeaponIds.map((id) => ({ - id, - name: t(`weapons:SUB_${id}`), - imgPath: subWeaponImageUrl(id), - })) + ? subWeaponIds.map((id) => ({ id, name: t(`weapons:SUB_${id}`) })) : specialWeaponIds.map((id) => ({ id, name: t(`weapons:SPECIAL_${id}`), - imgPath: specialWeaponImageUrl(id), })); return ( @@ -73,7 +67,17 @@ export function SubSpecialSelectFormField({ textValue={option.name} > - + {weaponType === "SUB" ? ( + + ) : ( + + )} {option.name} diff --git a/app/utils/urls.ts b/app/utils/urls.ts index a030a6726..01c0e52df 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -398,6 +398,23 @@ export const subWeaponImageUrl = (subWeaponSplId: SubWeaponId) => `${STATIC_ASSETS_URL}/img/sub-weapons/${subWeaponSplId}`; export const specialWeaponImageUrl = (specialWeaponSplId: SpecialWeaponId) => `${STATIC_ASSETS_URL}/img/special-weapons/${specialWeaponSplId}`; +/** + * Teal parts of a sub weapon icon, as an overlay laid on top of the accent + * colored silhouette. Generated by `scripts/create-ink-detail-images.ts`. + */ +export const subWeaponDetailImageUrl = (subWeaponSplId: SubWeaponId) => + `${STATIC_ASSETS_URL}/img/sub-weapons-detail/${subWeaponSplId}`; +/** Special weapon counterpart of {@link subWeaponDetailImageUrl}. */ +export const specialWeaponDetailImageUrl = ( + specialWeaponSplId: SpecialWeaponId, +) => `${STATIC_ASSETS_URL}/img/special-weapons-detail/${specialWeaponSplId}`; +/** White parts of a sub weapon icon, as a mask so they can be painted separately. */ +export const subWeaponHighlightImageUrl = (subWeaponSplId: SubWeaponId) => + `${STATIC_ASSETS_URL}/img/sub-weapons-highlight/${subWeaponSplId}`; +/** Special weapon counterpart of {@link subWeaponHighlightImageUrl}. */ +export const specialWeaponHighlightImageUrl = ( + specialWeaponSplId: SpecialWeaponId, +) => `${STATIC_ASSETS_URL}/img/special-weapons-highlight/${specialWeaponSplId}`; export const specialWeaponVariantImageUrl = ( specialWeaponSplId: SpecialWeaponId, variant: "weakpoints", diff --git a/changelog/2026-09-21-ink-colored-weapon-icons.md b/changelog/2026-09-21-ink-colored-weapon-icons.md new file mode 100644 index 000000000..39d5b970e --- /dev/null +++ b/changelog/2026-09-21-ink-colored-weapon-icons.md @@ -0,0 +1,4 @@ +--- +type: feature +--- +Sub and special weapon icons now follow theme colors diff --git a/package.json b/package.json index 9953badc8..9d7d5e2b2 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts", "og:generate": "node scripts/generate-og-images.ts", "changelog:image": "node scripts/generate-changelog-image.ts", + "ink-detail:generate": "node scripts/create-ink-detail-images.ts", "notification:test": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/send-test-notification.ts", "i18n:sync": "node scripts/collapse-single-plural-keys.ts && i18next-locales-sync -e true -p en -s da de es-ES es-US fr-CA fr-EU he it ja ko nl pl pt-BR ru zh -l locales && pnpm run biome:fix", "knip": "knip" diff --git a/scripts/create-ink-detail-images.ts b/scripts/create-ink-detail-images.ts new file mode 100644 index 000000000..0ee0f1898 --- /dev/null +++ b/scripts/create-ink-detail-images.ts @@ -0,0 +1,235 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ + +// Splits sub & special weapon icons into the layers that let them take the +// user's accent color as their ink color, writing them into the assets repo, +// see docs/dev/how-to.md +// +// The source icons are painted in exactly three tones (ink purple, teal, +// white), so each one becomes: +// +// detail the teal tone, keeping its color, as an overlay +// highlight the white tone as a mask, so the app can paint it on top of the +// recolored ink without a seam +// +// The ink tone needs no file of its own: the app fills the source icon's own +// alpha channel with the accent color and lays these two on top. See +// app/components/Image.tsx. + +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { range } from "remeda"; +import sharp from "sharp"; + +const DEFAULT_ASSETS_IMG_DIR = fileURLToPath( + new URL("../../assets/assets/img", import.meta.url), +); + +const SUB_WEAPON_ID_COUNT = 14; +const SPECIAL_WEAPON_ID_COUNT = 19; + +/** The three tones every sub & special weapon icon is painted in. */ +const INK = [65, 59, 185]; +const TEAL = [53, 191, 193]; +const WHITE = [255, 255, 255]; +const TONES = [INK, TEAL, WHITE]; +const TONE_PAIRS = [ + [0, 1], + [0, 2], + [1, 2], +]; + +/** How teal a pixel has to be to vouch for the teal around it, see `clearStrayTeal`. */ +const TEAL_SEED_WEIGHT = 0.7; +const TEAL_SEED_MIN_ALPHA = 0.5; + +/** 4:4:4 because the chroma subsampling of the source icons is what smears the tone boundaries in the first place. */ +const AVIF_OPTIONS = { + quality: 90, + effort: 6, + chromaSubsampling: "4:4:4", +} as const; + +const GROUPS = [ + { + name: "sub-weapons", + ids: range(0, SUB_WEAPON_ID_COUNT), + }, + { + name: "special-weapons", + ids: range(1, SPECIAL_WEAPON_ID_COUNT + 1), + }, +]; + +async function main() { + const imgDir = path.resolve(process.argv[2] ?? DEFAULT_ASSETS_IMG_DIR); + + for (const group of GROUPS) { + const dirs = { + detail: path.join(imgDir, `${group.name}-detail`), + highlight: path.join(imgDir, `${group.name}-highlight`), + }; + for (const dir of Object.values(dirs)) { + await fs.mkdir(dir, { recursive: true }); + } + + for (const id of group.ids) { + const source = await fs.readFile( + path.join(imgDir, group.name, `${id}.avif`), + ); + const layers = await toInkLayers(source); + + await fs.writeFile(path.join(dirs.detail, `${id}.avif`), layers.detail); + await fs.writeFile( + path.join(dirs.highlight, `${id}.avif`), + layers.highlight, + ); + } + + console.log(`${group.name}: wrote ${group.ids.length} icons`); + } + + console.log(`\nOutput in ${imgDir}, commit & push it in the assets repo.`); +} + +async function toInkLayers(source: Buffer) { + const { data, info } = await sharp(source) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + const pixelCount = info.width * info.height; + const alphas = new Float32Array(pixelCount); + const tealWeights = new Float32Array(pixelCount); + const whiteWeights = new Float32Array(pixelCount); + + for (let p = 0; p < pixelCount; p++) { + const i = p * 4; + alphas[p] = data[i + 3] / 255; + if (alphas[p] === 0) continue; + + const [, teal, white] = toneWeightsOf([data[i], data[i + 1], data[i + 2]]); + tealWeights[p] = teal; + whiteWeights[p] = white; + } + + clearStrayTeal({ + tealWeights, + alphas, + width: info.width, + height: info.height, + }); + + const detail = Buffer.alloc(data.length); + const highlight = Buffer.alloc(data.length); + for (let p = 0; p < pixelCount; p++) { + const i = p * 4; + const alpha = alphas[p]; + if (alpha === 0) continue; + + // the layers stack detail over ink and highlight over detail, so each one + // only has to cover what the layers under it still show through + const highlightAlpha = alpha * whiteWeights[p]; + const covered = 1 - highlightAlpha; + const detailAlpha = covered === 0 ? 0 : (alpha * tealWeights[p]) / covered; + + for (const channel of [0, 1, 2]) { + detail[i + channel] = TEAL[channel]; + highlight[i + channel] = WHITE[channel]; + } + detail[i + 3] = toByte(detailAlpha); + highlight[i + 3] = toByte(highlightAlpha); + } + + const toAvif = (buffer: Buffer) => + sharp(buffer, { + raw: { width: info.width, height: info.height, channels: 4 }, + }) + .avif(AVIF_OPTIONS) + .toBuffer(); + + return { detail: await toAvif(detail), highlight: await toAvif(highlight) }; +} + +/** + * Zeroes teal coverage no teal region can account for. A pixel is only part + * teal because a teal region overlaps it, so it has to touch a pixel that is + * confidently teal. Anything else is the lossy source's chroma noise read as a + * trace of teal, which would speckle the recolored icon. + */ +function clearStrayTeal({ + tealWeights, + alphas, + width, + height, +}: { + tealWeights: Float32Array; + alphas: Float32Array; + width: number; + height: number; +}) { + const nearTeal = new Uint8Array(tealWeights.length); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const p = y * width + x; + if ( + tealWeights[p] < TEAL_SEED_WEIGHT || + alphas[p] < TEAL_SEED_MIN_ALPHA + ) { + continue; + } + + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const ny = y + dy; + const nx = x + dx; + if (ny < 0 || ny >= height || nx < 0 || nx >= width) continue; + nearTeal[ny * width + nx] = 1; + } + } + } + } + + for (let p = 0; p < tealWeights.length; p++) { + if (!nearTeal[p]) tealWeights[p] = 0; + } +} + +function toByte(value: number) { + return Math.round(Math.max(0, Math.min(1, value)) * 255); +} + +/** + * How much of each tone a pixel is made of. A pixel sits inside one flat region + * or on the boundary between two, so the best explanation is a blend of exactly + * two tones. Fitting all three at once instead lets the lossy source's chroma + * noise turn an ink/white edge into teal. + */ +function toneWeightsOf(color: number[]) { + const dot = (a: number[], b: number[]) => + a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + + let best = { from: 0, to: 0, blend: 0, residual: Number.POSITIVE_INFINITY }; + for (const [from, to] of TONE_PAIRS) { + const toward = TONES[to].map((c, i) => c - TONES[from][i]); + const offset = color.map((c, i) => c - TONES[from][i]); + const blend = Math.max( + 0, + Math.min(1, dot(offset, toward) / dot(toward, toward)), + ); + const residual = Math.hypot( + ...color.map((c, i) => c - (TONES[from][i] + blend * toward[i])), + ); + + if (residual < best.residual) best = { from, to, blend, residual }; + } + + const weights = [0, 0, 0]; + weights[best.from] = 1 - best.blend; + weights[best.to] = best.blend; + + return weights; +} + +await main();