Files
buzz-sheet/components/public/material-poster.tsx
T
gunshiz d3fb065da3
CI / Verify (push) Successful in 1m11s
CI / Build immutable images and deploy (push) Successful in 1m20s
feat : update
2026-09-06 20:00:46 +07:00

232 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Image from "next/image";
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 (["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,
onArtworkMove,
className,
bleeding = [],
}: {
character: PosterCharacter;
characterArtUrl: string | null;
artworkOffsetX: number;
artworkOffsetY: number;
topColors: [string, string];
bottomColors: [string, string];
interactive?: boolean;
onArtworkMove?: (offset: { x: number; y: number }) => void;
className?: string;
artworkScale: 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, 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%)`;
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})` }}
>
<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-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) => (
<div key={`${material.key}-${index}`} className="aspect-[0.72] overflow-hidden rounded-[1.3cqw] bg-black/95 ring-1 ring-white/10">
<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>
</div>
))}
</div>
</div>
</div>
);
}