258 lines
10 KiB
TypeScript
258 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import Image from "next/image";
|
||
import Link from "next/link";
|
||
import { useRef, type PointerEvent } from "react";
|
||
|
||
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 (key.includes("%") && ["hp", "atk", "def"].includes(normalized)) return `${normalized}-percent`;
|
||
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,
|
||
artworkRotation,
|
||
topColors,
|
||
bottomColors,
|
||
interactive = false,
|
||
linkMaterials = false,
|
||
onArtworkMove,
|
||
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;
|
||
artworkRotation: number;
|
||
bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; blendMode: PosterBlendMode }>;
|
||
}) {
|
||
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
|
||
const stats = character.baseStats.slice(0, 5);
|
||
const statBoxCount = Math.max(4, stats.length);
|
||
const hasFiveStats = statBoxCount === 5;
|
||
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%)`;
|
||
|
||
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
|
||
if (!interactive || !characterArtUrl || !onArtworkMove) return;
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
dragStart.current = {
|
||
pointerId: event.pointerId,
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
offsetX: artworkOffsetX,
|
||
offsetY: artworkOffsetY,
|
||
};
|
||
}
|
||
|
||
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
|
||
if (!dragStart.current || dragStart.current.pointerId !== event.pointerId || !onArtworkMove) return;
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
onArtworkMove({
|
||
x: dragStart.current.offsetX + ((event.clientX - dragStart.current.x) / rect.width) * 100,
|
||
y: dragStart.current.offsetY + ((event.clientY - dragStart.current.y) / rect.height) * 100,
|
||
});
|
||
}
|
||
|
||
function handlePointerUp(event: PointerEvent<HTMLDivElement>) {
|
||
if (dragStart.current?.pointerId === event.pointerId) dragStart.current = null;
|
||
}
|
||
|
||
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 }}
|
||
onPointerDown={handlePointerDown}
|
||
onPointerMove={handlePointerMove}
|
||
onPointerUp={handlePointerUp}
|
||
onPointerCancel={handlePointerUp}
|
||
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}) rotate(${artworkRotation}deg)` }}
|
||
>
|
||
<Image
|
||
src={characterArtUrl}
|
||
alt={character.name}
|
||
fill
|
||
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
|
||
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-linear-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: statBoxCount }, (_, 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={cn("absolute inset-x-[4%]", hasFiveStats ? "top-[57%]" : "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>
|
||
);
|
||
}
|