Files

244 lines
9.1 KiB
TypeScript

'use client';
import { useId, useState } from 'react';
import Image from 'next/image';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Eye, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import type { GameView } from '@/game/engine';
import { DUKE_VIDAL_RULES } from '@/game/duke-vidal';
import { specialLeaderArt } from '@/game/special-leader-art';
/** Original Ecaz medallion; identity and strength have equivalent HTML text. */
function VidalDisc({ large = false }: { large?: boolean }) {
const gradient = useId();
return (
<svg
viewBox="0 0 160 160"
className={large ? 'mx-auto my-6 size-48 max-w-full' : 'size-20 shrink-0'}
aria-hidden="true"
focusable="false"
>
<defs>
<radialGradient id={gradient} cx="35%" cy="25%" r="80%">
<stop offset="0" stopColor="#795577" />
<stop offset="1" stopColor="#251b31" />
</radialGradient>
</defs>
<circle cx="80" cy="83" r="74" fill="#100f15" />
<circle
cx="80"
cy="79"
r="73"
fill={`url(#${gradient})`}
stroke="#d6bc80"
strokeWidth="2"
/>
<circle
cx="80"
cy="79"
r="65"
fill="none"
stroke="#c9b385"
strokeOpacity="0.65"
/>
<path
d="M 80 18 L 86 27 L 80 36 L 74 27 Z M 32 79 Q 32 39 63 30 M 128 79 Q 128 39 97 30 M 32 91 Q 41 125 64 133 M 128 91 Q 119 125 96 133"
fill="none"
stroke="#ddc892"
strokeWidth="2"
/>
<path
d="M 55 48 L 67 59 L 80 43 L 93 59 L 105 48 L 98 69 H 62 Z"
fill="none"
stroke="#f0dca7"
strokeWidth="2"
/>
<text
x="80"
y="121"
textAnchor="middle"
fill="#fff1c9"
fontFamily="Georgia, serif"
fontSize="56"
>
6
</text>
</svg>
);
}
/** Uses only the shared disc identity supplied by the public projection. */
function VidalPortrait({
id,
name,
large = false,
}: {
id?: string;
name: string;
large?: boolean;
}) {
const [failedSource, setFailedSource] = useState<string | null>(null);
const art = specialLeaderArt(id, name);
if (!art || failedSource === art.src) return <VidalDisc large={large} />;
return (
<span
className={`relative block shrink-0 ${large ? 'mx-auto my-6 size-48 max-w-full' : 'size-20'}`}
>
<span className="absolute inset-0 overflow-hidden rounded-full border-2 border-[#d6bc80] bg-[#251b31]">
<Image
src={art.src}
alt=""
width={1254}
height={1254}
sizes={large ? '192px' : '80px'}
loading="lazy"
decoding="async"
onError={() => setFailedSource(art.src)}
className="h-full w-full object-cover"
style={{ objectPosition: art.objectPosition }}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-1 rounded-full border border-[#e6ce99]/40"
/>
</span>
</span>
);
}
/** Read-only shared component. Custody comes exclusively from the public projection. */
export function DukeVidal({ game }: { game: GameView }) {
const duke = game.dukeVidal;
if (!duke) return null;
const controller = duke.controller
? game.players.find((player) => player.id === duke.controller)
: undefined;
const status = duke.leader.dead
? 'In the Tanks'
: duke.controller
? `Controlled by ${controller?.name ?? 'another player'}`
: 'Set aside';
return (
<section
aria-label="Duke Prad Vidal shared leader"
className="min-w-0 rounded-xl border border-[#75617a] bg-[#241f29] p-4 text-[#eee7de]"
>
<div className="flex flex-wrap items-center gap-4">
<VidalPortrait id={duke.leader.id} name={duke.leader.name} />
<div className="min-w-0 flex-1 basis-40">
<p className="m-0 text-xs font-semibold tracking-[0.16em] text-[#d5b888] uppercase">
Ecaz · shared leader · strength 6
</p>
<h3 className="mt-1 mb-2 break-words font-serif text-xl text-[#fff0d4]">
Duke Prad Vidal
</h3>
<p className="m-0 break-words text-sm">{status}</p>
<p className="mt-1 mb-0 text-sm text-[#cbbbcc]">No Traitor Card</p>
</div>
<Dialog>
<DialogTrigger
render={
<Button
variant="outline"
className="min-h-11 min-w-11 motion-reduce:transition-none"
/>
}
aria-label="Inspect Duke Prad Vidal"
>
<Eye aria-hidden="true" /> Inspect Duke
</DialogTrigger>
<DialogPortal>
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#9c815c] bg-[#19161d] text-[#eee7de] shadow-2xl outline-none motion-reduce:animate-none motion-reduce:transition-none">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#4c3b51] px-4 py-3">
<span className="text-xs font-semibold tracking-[0.16em] text-[#dec18b] uppercase">
Shared leader inspection
</span>
<DialogClose
render={
<Button
variant="outline"
className="min-h-11 min-w-11 motion-reduce:transition-none"
/>
}
>
<X aria-hidden="true" /> Close
</DialogClose>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<article className="min-w-0 rounded-xl border border-[#a08967] bg-gradient-to-br from-[#4a334e] to-[#221d28] p-5">
<p className="m-0 text-sm tracking-wider text-[#e5cea6] uppercase">
House Ecaz
</p>
<VidalPortrait
id={duke.leader.id}
name={duke.leader.name}
large
/>
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff1d5]">
{DUKE_VIDAL_RULES.name}
</DialogTitle>
<dl className="my-5 space-y-3 border-y border-[#a08967]/50 py-4">
<div>
<dt className="text-sm text-[#d6c2cc]">
Leader strength
</dt>
<dd className="m-0 font-serif text-4xl">6</dd>
</div>
<div>
<dt className="text-sm text-[#d6c2cc]">
Current state
</dt>
<dd className="m-0 break-words text-base">{status}</dd>
</div>
</dl>
<p className="mb-0 text-sm leading-6">
One shared disc. No Traitor Card.
</p>
</article>
<div className="min-w-0 space-y-5">
<DialogDescription className="m-0 text-base leading-7 text-[#ece0ce]">
{DUKE_VIDAL_RULES.summary}
</DialogDescription>
{DUKE_VIDAL_RULES.gameplay.map((paragraph) => (
<p
key={paragraph}
className="m-0 text-base leading-7 text-[#ddd4df]"
>
{paragraph}
</p>
))}
<p className="m-0 rounded-lg border border-[#675071] bg-[#2c2232] p-4 text-sm leading-6 text-[#e5d3df]">
Current support covers Moritani acquisition, direct Ecaz
Ambassador acquisition or consensual alliance, battle use
and death, and Ecaz-only paid or Ghola revival to living
set-aside custody. Native ordinary-disc skills may coexist
with the separate Duke without assigning him a skill.
Unused Ecaz custody survives the turn; unused Moritani
custody expires. Duke loans, reacquisition while already
controlled, shared skill assignment and exceptional capture
remain unfinished. Full Ecaz and Moritani games are
unavailable. Inspection does not select a battle leader.
</p>
</div>
</div>
</div>
</DialogPrimitive.Popup>
</DialogPortal>
</Dialog>
</div>
</section>
);
}