feat : dreamgineer ask me try to use this and now im ran out of token sad [no ci]
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
import { Image, Link } from "./primitives";
|
||||
|
||||
import type {
|
||||
CatalogAscensionGroup,
|
||||
CatalogBaseStat,
|
||||
} from "@/lib/catalog/lunaris";
|
||||
import {
|
||||
mergePosterMaterials,
|
||||
type PosterMaterial,
|
||||
} from "@/lib/material-poster";
|
||||
import { accentGradient } from "@/lib/overview-image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type PosterBlendMode =
|
||||
| "normal"
|
||||
| "multiply"
|
||||
| "screen"
|
||||
| "overlay"
|
||||
| "darken"
|
||||
| "lighten"
|
||||
| "color-dodge"
|
||||
| "color-burn"
|
||||
| "hard-light"
|
||||
| "soft-light"
|
||||
| "difference"
|
||||
| "exclusion"
|
||||
| "hue"
|
||||
| "saturation"
|
||||
| "color"
|
||||
| "luminosity";
|
||||
|
||||
type PosterCharacter = {
|
||||
name: string;
|
||||
nameCardImageUrl: string | null;
|
||||
baseStats: CatalogBaseStat[];
|
||||
ascension: Array<
|
||||
Omit<CatalogAscensionGroup, "items"> & { items: PosterMaterial[] }
|
||||
>;
|
||||
leveling: Array<
|
||||
Omit<CatalogAscensionGroup, "items"> & { items: PosterMaterial[] }
|
||||
>;
|
||||
};
|
||||
|
||||
const POSTER_EXPORT_WIDTH = 1600;
|
||||
const POSTER_IMAGE_QUALITY = 100;
|
||||
const MAX_OPTIMIZED_IMAGE_WIDTH = 3840;
|
||||
|
||||
const statLabels: Record<string, string> = {
|
||||
hp: "HP",
|
||||
atk: "ATK",
|
||||
def: "DEF",
|
||||
em: "EM",
|
||||
};
|
||||
|
||||
function statLabel(key: string): string {
|
||||
return statLabels[key.toLowerCase()] ?? key;
|
||||
}
|
||||
|
||||
function statIconName(key: string): string | null {
|
||||
const normalized = key.toLowerCase().replace(/[^a-z]/gu, "");
|
||||
if (["hp", "atk", "def", "em", "er"].includes(normalized)) return normalized;
|
||||
if (["critrate", "crit", "criticalrate"].includes(normalized))
|
||||
return "critrate";
|
||||
if (["critdmg", "critdamage", "criticaldamage"].includes(normalized))
|
||||
return "critdmg";
|
||||
return null;
|
||||
}
|
||||
|
||||
function statValue(key: string, value: number | string): string {
|
||||
const formatted =
|
||||
typeof value === "number"
|
||||
? value.toLocaleString("en-US", { maximumFractionDigits: 2 })
|
||||
: value;
|
||||
return key.includes("%") ? `${formatted}%` : formatted;
|
||||
}
|
||||
|
||||
export function MaterialPoster({
|
||||
character,
|
||||
characterArtUrl,
|
||||
artworkOffsetX,
|
||||
artworkOffsetY,
|
||||
artworkScale,
|
||||
topColors,
|
||||
bottomColors,
|
||||
interactive = false,
|
||||
linkMaterials = false,
|
||||
className,
|
||||
bleeding = [],
|
||||
}: {
|
||||
character: PosterCharacter;
|
||||
characterArtUrl: string | null;
|
||||
artworkOffsetX: number;
|
||||
artworkOffsetY: number;
|
||||
topColors: [string, string];
|
||||
bottomColors: [string, string];
|
||||
interactive?: boolean;
|
||||
linkMaterials?: boolean;
|
||||
onArtworkMove?: (offset: { x: number; y: number }) => void;
|
||||
className?: string;
|
||||
artworkScale: number;
|
||||
bleeding?: Array<{
|
||||
id: string;
|
||||
imageUrl: string | null;
|
||||
amount: number;
|
||||
blendMode: PosterBlendMode;
|
||||
}>;
|
||||
}) {
|
||||
const stats = character.baseStats.slice(0, 4);
|
||||
const materials = mergePosterMaterials(
|
||||
[...character.ascension, ...character.leveling].flatMap(
|
||||
(group) => group.items,
|
||||
),
|
||||
);
|
||||
const artworkImageSize = Math.min(
|
||||
MAX_OPTIMIZED_IMAGE_WIDTH,
|
||||
Math.max(
|
||||
POSTER_EXPORT_WIDTH,
|
||||
Math.ceil((POSTER_EXPORT_WIDTH * artworkScale) / 100),
|
||||
),
|
||||
);
|
||||
const background = `linear-gradient(180deg, ${topColors[0]} 0%, ${topColors[1]} 22%, rgba(0,0,0,0) 42%, rgba(0,0,0,0) 62%, ${bottomColors[0]} 88%, ${bottomColors[1]} 100%)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"@container relative aspect-[4/5] w-full select-none overflow-hidden bg-black text-white",
|
||||
interactive && characterArtUrl && "cursor-move",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundImage: background }}
|
||||
role={interactive && characterArtUrl ? "application" : undefined}
|
||||
aria-label={
|
||||
interactive && characterArtUrl ? "ลากเพื่อเลื่อนภาพตัวละคร" : undefined
|
||||
}
|
||||
>
|
||||
{character.nameCardImageUrl ? (
|
||||
<Image
|
||||
src={character.nameCardImageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes={`${POSTER_EXPORT_WIDTH}px`}
|
||||
quality={POSTER_IMAGE_QUALITY}
|
||||
className="object-cover opacity-75"
|
||||
priority={!interactive}
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-black/55" />
|
||||
{characterArtUrl ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
transform: `translate(${artworkOffsetX}%, ${artworkOffsetY}%) scale(${artworkScale / 100})`,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={characterArtUrl}
|
||||
alt={character.name}
|
||||
fill
|
||||
unoptimized
|
||||
sizes={`${artworkImageSize}px`}
|
||||
quality={POSTER_IMAGE_QUALITY}
|
||||
className="object-contain object-center"
|
||||
priority={!interactive}
|
||||
/>
|
||||
{[...bleeding].reverse().map((layer) => (
|
||||
<Image
|
||||
key={layer.id}
|
||||
src={characterArtUrl}
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
sizes={`${artworkImageSize}px`}
|
||||
quality={POSTER_IMAGE_QUALITY}
|
||||
className="object-contain object-center"
|
||||
style={{
|
||||
opacity: Math.max(0, Math.min(100, layer.amount)) / 100,
|
||||
mixBlendMode: layer.blendMode,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Image
|
||||
src="/img/mat/fade.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes={`${POSTER_EXPORT_WIDTH}px`}
|
||||
quality={POSTER_IMAGE_QUALITY}
|
||||
className="pointer-events-none object-cover opacity-50"
|
||||
priority={!interactive}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/10 via-transparent to-black/80" />
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[1.35cqw]"
|
||||
style={{ backgroundImage: accentGradient(...topColors) }}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-[1.35cqw]"
|
||||
style={{ backgroundImage: accentGradient(...bottomColors) }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-x-[4%] top-[5%]"
|
||||
style={{
|
||||
fontFamily: "Kanit Poster",
|
||||
fontWeight: 800,
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
className="text-[7cqw] leading-none"
|
||||
style={{ textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}
|
||||
>
|
||||
BASE STATS
|
||||
</h2>
|
||||
<div className="mt-[4%] flex w-[53%] flex-col gap-[1.2cqw]">
|
||||
{Array.from({ length: 4 }, (_, index) => {
|
||||
const stat = stats[index];
|
||||
const iconName = stat ? statIconName(stat.key) : null;
|
||||
return (
|
||||
<div
|
||||
key={stat?.key ?? index}
|
||||
className="flex h-[7cqw] items-center justify-between rounded-[1.5cqw] bg-black/95 px-[4%] text-[3.2cqw] leading-none"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-[1cqw] whitespace-nowrap">
|
||||
{iconName ? (
|
||||
<Image
|
||||
src={`/icon/stats/${iconName}.webp`}
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
quality={POSTER_IMAGE_QUALITY}
|
||||
className="size-[3.5cqw] shrink-0 object-contain brightness-0 invert"
|
||||
/>
|
||||
) : null}
|
||||
<span>{stat ? statLabel(stat.key) : "—"}</span>
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-[2.7cqw]">
|
||||
{stat ? statValue(stat.key, stat.value) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute inset-x-[4%] top-[49%]"
|
||||
style={{
|
||||
fontFamily: "Kanit Poster",
|
||||
fontWeight: 800,
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
className="text-[7cqw] leading-none"
|
||||
style={{ textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}
|
||||
>
|
||||
MATERIALS
|
||||
</h2>
|
||||
<p className="mt-[1%] text-[4.5cqw] leading-none">LV.90</p>
|
||||
<div className="mt-[5%] grid grid-cols-8 gap-[1.2%]">
|
||||
{materials.map((material, index) => {
|
||||
const materialCard = (
|
||||
<>
|
||||
<div className="flex h-[68%] items-center justify-center rounded-br-[1.3cqw] bg-[#c1c1c1] p-[8%]">
|
||||
{material.imageUrl ? (
|
||||
<Image
|
||||
src={material.imageUrl}
|
||||
alt=""
|
||||
width={192}
|
||||
height={192}
|
||||
quality={POSTER_IMAGE_QUALITY}
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex h-[32%] items-center justify-center px-[4%] text-center text-[2.2cqw] leading-none">
|
||||
<span className="whitespace-nowrap text-[1.8cqw]">
|
||||
×{material.amount.toLocaleString("en-US")}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const className =
|
||||
"aspect-[0.72] overflow-hidden rounded-[1.3cqw] bg-black/95 ring-1 ring-white/10";
|
||||
|
||||
return linkMaterials ? (
|
||||
<Link
|
||||
key={`${material.key}-${index}`}
|
||||
href={`https://lunaris.moe/material/${encodeURIComponent(material.key)}`}
|
||||
className={className}
|
||||
aria-label={`เปิดข้อมูล ${material.name} บน Lunaris`}
|
||||
>
|
||||
{materialCard}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={`${material.key}-${index}`} className={className}>
|
||||
{materialCard}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user