mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
Themed sub and special icons
This commit is contained in:
@@ -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 }) => (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -79,6 +79,8 @@
|
||||
}
|
||||
|
||||
.receiverImage {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
4
changelog/2026-09-21-ink-colored-weapon-icons.md
Normal file
4
changelog/2026-09-21-ink-colored-weapon-icons.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: feature
|
||||
---
|
||||
Sub and special weapon icons now follow theme colors
|
||||
@@ -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"
|
||||
|
||||
235
scripts/create-ink-detail-images.ts
Normal file
235
scripts/create-ink-detail-images.ts
Normal 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();
|
||||
Reference in New Issue
Block a user