Themed sub and special icons

This commit is contained in:
Kalle
2026-09-22 17:25:54 +03:00
parent 2b31ecd0fa
commit 90b4bb0b4a
20 changed files with 611 additions and 204 deletions

View File

@@ -57,7 +57,7 @@ export function GearSelect<Clearable extends boolean | undefined = undefined>({
<SendouSelectItemSection
className={idx === 0 ? "pt-0-5" : undefined}
heading={t(`game-misc:BRAND_${brandId}` as any)}
headingImgPath={brandImageUrl(brandId)}
headingImg={<Image path={brandImageUrl(brandId)} size={28} alt="" />}
key={key}
>
{gear.map(({ id, name }) => (

View File

@@ -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);
}

View File

@@ -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<ImageProps, "path" | "alt" | "title">;
export function SubWeaponImage({
subWeaponId,
alt,
testId,
...rest
}: SubWeaponImageProps) {
const { t } = useTranslation(["weapons"]);
const name = alt ?? t(`weapons:SUB_${subWeaponId}`);
return (
<Image
<InkTintedImage
{...rest}
alt={t(`weapons:SUB_${subWeaponId}`)}
title={t(`weapons:SUB_${subWeaponId}`)}
alt={name}
title={name || undefined}
testId={testId}
path={subWeaponImageUrl(subWeaponId)}
detailPath={subWeaponDetailImageUrl(subWeaponId)}
highlightPath={subWeaponHighlightImageUrl(subWeaponId)}
/>
);
}
type SpecialWeaponImageProps = {
specialWeaponId: SpecialWeaponId;
alt?: string;
} & Omit<ImageProps, "path" | "alt" | "title">;
export function SpecialWeaponImage({
specialWeaponId,
alt,
testId,
...rest
}: SpecialWeaponImageProps) {
const { t } = useTranslation(["weapons"]);
const name = alt ?? t(`weapons:SPECIAL_${specialWeaponId}`);
return (
<Image
<InkTintedImage
{...rest}
alt={t(`weapons:SPECIAL_${specialWeaponId}`)}
title={t(`weapons:SPECIAL_${specialWeaponId}`)}
alt={name}
title={name || undefined}
testId={testId}
path={specialWeaponImageUrl(specialWeaponId)}
detailPath={specialWeaponDetailImageUrl(specialWeaponId)}
highlightPath={specialWeaponHighlightImageUrl(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<ImageProps, "path" | "onClick" | "loading">;
function InkTintedImage({
path,
detailPath,
highlightPath,
alt,
title,
className,
containerClassName,
containerStyle,
width,
height,
size,
style,
testId,
}: InkTintedImageProps) {
return (
<div title={title} className={containerClassName} style={containerStyle}>
<span
role="img"
aria-label={alt}
data-testid={testId}
className={clsx(styles.inkTinted, className)}
style={
{
...style,
width: size ?? width,
height: size ?? height,
"--ink-silhouette": `url("${path}.avif")`,
"--ink-detail": `url("${detailPath}.avif")`,
"--ink-highlight": `url("${highlightPath}.avif")`,
} as React.CSSProperties
}
>
<span className={styles.inkArt} />
<span className={styles.inkHighlight} />
</span>
</div>
);
}
type TierImageProps = {
tier: { name: TierName; isPlus: boolean };
} & Omit<ImageProps, "path" | "alt" | "title" | "size" | "height">;

View File

@@ -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 }) => (
<SendouSelectItemSection
heading={name}
headingImgPath={
key === "quick-select"
? undefined
: name === "subs"
? subWeaponImageUrl(SPLAT_BOMB_ID)
: name === "specials"
? specialWeaponImageUrl(TRIZOOKA_ID)
: weaponCategoryUrl(name)
headingImg={
key === "quick-select" ? undefined : name === "subs" ? (
<SubWeaponImage subWeaponId={SPLAT_BOMB_ID} size={28} alt="" />
) : name === "specials" ? (
<SpecialWeaponImage
specialWeaponId={TRIZOOKA_ID}
size={28}
alt=""
/>
) : (
<Image path={weaponCategoryUrl(name)} size={28} alt="" />
)
}
className={idx === 0 ? "pt-0-5" : undefined}
key={key}
@@ -157,17 +162,15 @@ export function WeaponSelect<
className={styles.weaponImg}
/>
) : weapon.type === "SUB" ? (
<Image
path={subWeaponImageUrl(weapon.id)}
<SubWeaponImage
subWeaponId={weapon.id}
size={24}
alt=""
className={styles.weaponImg}
/>
) : (
<Image
path={specialWeaponImageUrl(weapon.id)}
<SpecialWeaponImage
specialWeaponId={weapon.id}
size={24}
alt=""
className={styles.weaponImg}
/>
)}

View File

@@ -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
<div role="group" aria-label={heading}>
<div className={clsx(className, styles.categoryHeading)}>
{headingImgPath ? (
<Image path={headingImgPath} size={28} alt="" />
) : null}
{headingImg}
{heading}
<div className={styles.categoryDivider} />
</div>

View File

@@ -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"}
>
<img
src={`${specialWeaponImageUrl(19)}.avif`}
width={imgSize}
height={imgSize}
alt=""
<SpecialWeaponImage
specialWeaponId={SPLATTERCOLOR_SCREEN_ID}
size={imgSize}
/>
<Icon
size={imgSize}

View File

@@ -15,7 +15,7 @@ import {
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { Image } from "~/components/Image";
import { Image, SpecialWeaponImage, SubWeaponImage } from "~/components/Image";
import { weaponToSelectedWeapon } from "~/components/layout/WeaponSearch";
import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
@@ -52,8 +52,6 @@ import {
ANALYZER_URL,
mainWeaponImageUrl,
navIconUrl,
specialWeaponImageUrl,
subWeaponImageUrl,
weaponParamsPage,
} from "~/utils/urls";
import { LinkButton, SendouButton } from "../../../components/elements/Button";
@@ -415,11 +413,9 @@ function BuildAnalyzerPage() {
title={t("analyzer:stat.category.sub")}
summaryRightContent={
<div className={styles.weaponInfoBadge}>
<Image
path={subWeaponImageUrl(analyzed.weapon.subWeaponSplId)}
width={20}
height={20}
alt={t(`weapons:SUB_${analyzed.weapon.subWeaponSplId}`)}
<SubWeaponImage
subWeaponId={analyzed.weapon.subWeaponSplId}
size={20}
/>
{t(`weapons:SUB_${analyzed.weapon.subWeaponSplId}`)}
</div>
@@ -504,15 +500,9 @@ function BuildAnalyzerPage() {
title={t("analyzer:stat.category.special")}
summaryRightContent={
<div className={styles.weaponInfoBadge}>
<Image
path={specialWeaponImageUrl(
analyzed.weapon.specialWeaponSplId,
)}
width={20}
height={20}
alt={t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`,
)}
<SpecialWeaponImage
specialWeaponId={analyzed.weapon.specialWeaponSplId}
size={20}
/>
{t(`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`)}
</div>
@@ -1341,12 +1331,7 @@ function EffectsSelector({
) : effect.type === "AURA" ? (
<span className="text-xs font-bold">AURA</span>
) : (
<Image
path={specialWeaponImageUrl(15)}
alt={t("weapons:SPECIAL_15")}
height={32}
width={32}
/>
<SpecialWeaponImage specialWeaponId={15} size={32} />
)}
</div>
<div>
@@ -1721,12 +1706,7 @@ function DamageTable({
<td>
<div className="stack horizontal xs items-center">
{damageIsSubWeaponDamage(val) ? (
<Image
alt=""
path={subWeaponImageUrl(val.subWeaponId)}
width={12}
height={12}
/>
<SubWeaponImage subWeaponId={val.subWeaponId} size={12} />
) : null}{" "}
{t(typeRowName as any)}{" "}
{damageIsSubWeaponDamage(val) && val.type === "SPLASH" ? (

View File

@@ -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 (
<Image
path={subWeaponImageUrl(subWeaponId)}
alt=""
<SubWeaponImage
subWeaponId={subWeaponId}
size={18}
className={styles.subSpecialWeaponIcon}
/>
@@ -212,9 +212,8 @@ function WeaponIcon({
if (isSpecialWeapon) {
return (
<Image
path={specialWeaponImageUrl(specialWeaponId)}
alt=""
<SpecialWeaponImage
specialWeaponId={specialWeaponId}
size={18}
className={styles.subSpecialWeaponIcon}
/>

View File

@@ -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({
</div>
<div className={styles.subSpecialContainer}>
<div className={styles.kitIcon}>
<Image
path={subWeaponImageUrl(params.subWeaponId)}
alt={t(`weapons:SUB_${params.subWeaponId}`)}
size={24}
/>
<SubWeaponImage subWeaponId={params.subWeaponId} size={24} />
</div>
<div className={styles.kitIcon}>
<Image
path={specialWeaponImageUrl(params.specialWeaponId)}
alt={t(`weapons:SPECIAL_${params.specialWeaponId}`)}
<SpecialWeaponImage
specialWeaponId={params.specialWeaponId}
size={24}
/>
</div>

View File

@@ -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<SpecialWeaponWithRange["rangeType"], string> = {
};
export function SpecialRangeVisualization() {
const { t } = useTranslation(["weapons"]);
const specials = getSpecialsWithRange();
if (specials.length === 0) {
return null;
@@ -53,12 +49,9 @@ export function SpecialRangeVisualization() {
return (
<div key={special.specialWeaponId} className={styles.row}>
<Image
path={specialWeaponImageUrl(special.specialWeaponId)}
width={28}
height={28}
alt={t(`weapons:SPECIAL_${special.specialWeaponId}`)}
title={t(`weapons:SPECIAL_${special.specialWeaponId}`)}
<SpecialWeaponImage
specialWeaponId={special.specialWeaponId}
size={28}
/>
<div className={styles.track}>
{blastWidth > 0 ? (

View File

@@ -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}
>
<Image
path={subWeaponImageUrl(item.subId)}
alt={t(`weapons:SUB_${item.subId}`)}
size={20}
/>
<SubWeaponImage subWeaponId={item.subId} size={20} />
<span className={styles.categoryName}>
{t(`analyzer:comp.subCategory.${item.category}`)}
</span>
@@ -109,11 +104,7 @@ export function WeaponCategories({ selectedWeaponIds }: WeaponCategoriesProps) {
className={styles.categoryItem}
data-first={index === 0}
>
<Image
path={specialWeaponImageUrl(item.specialId)}
alt={t(`weapons:SPECIAL_${item.specialId}`)}
size={20}
/>
<SpecialWeaponImage specialWeaponId={item.specialId} size={20} />
<span className={styles.categoryName}>
{t(`analyzer:comp.specialCategory.${item.category}`)}
</span>

View File

@@ -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) => (
<div key={group.key} className={styles.categorySection}>
<div className={styles.categoryHeader}>
{group.iconPath ? (
<Image path={group.iconPath} alt="" size={24} />
) : null}
<WeaponGroupIcon icon={group.icon} />
<span className={styles.categoryName}>
{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 <Image path={weaponCategoryUrl(icon.name)} alt="" size={24} />;
case "sub":
return <SubWeaponImage subWeaponId={icon.id} size={24} />;
case "special":
return <SpecialWeaponImage specialWeaponId={icon.id} size={24} />;
}
}
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,
};
})

View File

@@ -79,6 +79,8 @@
}
.receiverImage {
display: flex;
width: fit-content;
padding: var(--s-2);
border-radius: var(--radius-box);
background-color: var(--color-bg);

View File

@@ -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<DamageReceiver, string> = {
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<DamageReceiver, DamageReceiverIcon> = {
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 (
<SubWeaponImage
subWeaponId={icon.id}
alt={alt}
size={RECEIVER_IMAGE_SIZE}
containerClassName={styles.receiverImage}
/>
);
case "special":
return (
<SpecialWeaponImage
specialWeaponId={icon.id}
alt={alt}
size={RECEIVER_IMAGE_SIZE}
containerClassName={styles.receiverImage}
/>
);
case "path":
return (
<Image
containerClassName={styles.receiverImage}
alt={alt}
path={icon.path}
size={RECEIVER_IMAGE_SIZE}
/>
);
}
}
const damageReceiverAp: Partial<Record<DamageReceiver, JSX.Element>> = {
GreatBarrier_Barrier: (
<Ability ability="SPU" size="TINY" className={styles.ability} />
@@ -293,19 +348,15 @@ function DamageReceiversGrid({
className={styles.weaponImage}
/>
) : weapon.type === "SUB" ? (
<Image
alt=""
path={subWeaponImageUrl(weapon.id)}
width={24}
height={24}
<SubWeaponImage
subWeaponId={weapon.id}
size={24}
className={styles.weaponImage}
/>
) : (
<Image
alt=""
path={specialWeaponImageUrl(weapon.id)}
width={24}
height={24}
<SpecialWeaponImage
specialWeaponId={weapon.id}
size={24}
className={styles.weaponImage}
/>
)}
@@ -342,12 +393,9 @@ function DamageReceiversGrid({
<SendouPopover
trigger={
<button type="button" className={styles.receiverButton}>
<Image
className={styles.receiverImage}
<DamageReceiverImage
icon={damageReceiverImages[damageToReceiver.receiver]}
alt={translateReceiver(damageToReceiver.receiver)}
path={damageReceiverImages[damageToReceiver.receiver]}
width={40}
height={40}
/>
</button>
}

View File

@@ -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({
</div>
{group.noScreen ? (
<div className={styles.noScreen}>
<Image
path={specialWeaponImageUrl(SPLATTERCOLOR_SCREEN_ID)}
width={22}
height={22}
alt={`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`}
<SpecialWeaponImage
specialWeaponId={SPLATTERCOLOR_SCREEN_ID}
size={22}
/>
</div>
) : null}

View File

@@ -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}
>
<span className={styles.option}>
<Image path={option.imgPath} size={24} alt="" />
{weaponType === "SUB" ? (
<SubWeaponImage
subWeaponId={option.id as SubWeaponId}
size={24}
/>
) : (
<SpecialWeaponImage
specialWeaponId={option.id as SpecialWeaponId}
size={24}
/>
)}
{option.name}
</span>
</SendouSelectItem>

View File

@@ -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",

View File

@@ -0,0 +1,4 @@
---
type: feature
---
Sub and special weapon icons now follow theme colors

View File

@@ -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"

View File

@@ -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();