Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
228 lines
7.9 KiB
TypeScript
228 lines
7.9 KiB
TypeScript
'use client';
|
||
|
||
import Image from 'next/image';
|
||
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
|
||
import { FACTIONS } from '@/game/catalog';
|
||
import { HOMEWORLD_CARDS, type HomeworldCard } from '@/game/homeworld-cards';
|
||
import { Button } from './ui/button';
|
||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
||
import {
|
||
Dialog,
|
||
DialogClose,
|
||
DialogDescription,
|
||
DialogOverlay,
|
||
DialogPortal,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from './ui/dialog';
|
||
|
||
function HomeworldPopulation({
|
||
card,
|
||
side,
|
||
enlarged,
|
||
}: {
|
||
card: HomeworldCard;
|
||
side: 'high' | 'low';
|
||
enlarged: boolean;
|
||
}) {
|
||
const face = card[side];
|
||
return (
|
||
<section
|
||
aria-label={`${side === 'high' ? 'High' : 'Low'} population rules`}
|
||
className="space-y-4"
|
||
>
|
||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#a88b60]/40 pb-3">
|
||
<h4 className="m-0 font-serif text-xl">
|
||
{side === 'high' ? 'High' : 'Low'} population
|
||
</h4>
|
||
<span className="rounded border border-[#a88b60]/60 px-3 py-1 text-base tabular-nums">
|
||
{face.reserves.min}–{face.reserves.max}{' '}
|
||
{card.reserveType === 'sardaukar' ? 'Sardaukar' : 'reserves'}
|
||
</span>
|
||
</div>
|
||
{face.gameplay.map((text) => (
|
||
<p
|
||
key={text}
|
||
className={
|
||
enlarged ? 'm-0 text-xl! leading-8!' : 'm-0 text-base! leading-7!'
|
||
}
|
||
>
|
||
{text}
|
||
</p>
|
||
))}
|
||
<div className="grid gap-2 border-t border-[#a88b60]/40 pt-3 text-sm leading-6 sm:grid-cols-2">
|
||
<p className="m-0">
|
||
<strong className="text-[#edcc8c]">
|
||
+{face.battleStrength} battle strength
|
||
</strong>
|
||
<br />
|
||
Added to the native faction’s dial here.
|
||
</p>
|
||
<p className="m-0">
|
||
<strong className="text-[#edcc8c]">
|
||
{face.battleStrength} explosion losses
|
||
</strong>
|
||
<br />
|
||
Native forces lost to a Lasgun/Shield explosion here.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function HomeworldFace({
|
||
card,
|
||
enlarged = false,
|
||
}: {
|
||
card: HomeworldCard;
|
||
enlarged?: boolean;
|
||
}) {
|
||
const faction = FACTIONS.find((item) => item.id === card.faction)!;
|
||
return (
|
||
<article className="min-w-0 overflow-hidden rounded-xl border border-[#a88b60] bg-[#20271f] text-[#f2e8d3] shadow-lg">
|
||
<header
|
||
className="flex min-h-24 flex-wrap items-center gap-3 border-b border-[#a88b60]/50 px-4 py-4"
|
||
style={{ borderTop: `4px solid ${faction.color}` }}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="m-0 text-sm leading-6 text-[#d1c7b0]">
|
||
{faction.name} · Homeworld
|
||
</p>
|
||
<h3 className="m-0 break-words font-serif text-2xl leading-tight text-[#fff1d4]">
|
||
{card.name}
|
||
</h3>
|
||
</div>
|
||
{card.id === 'salusa_secundus' && (
|
||
<span className="text-sm text-[#edcc8c]">Advanced only</span>
|
||
)}
|
||
</header>
|
||
<Image
|
||
src={`/art/homeworlds/${card.id}-v1.png`}
|
||
alt=""
|
||
width={1536}
|
||
height={768}
|
||
unoptimized
|
||
loading="lazy"
|
||
className="block aspect-[2/1] w-full object-cover"
|
||
/>
|
||
<Tabs defaultValue="high" className="gap-4 p-4">
|
||
<TabsList
|
||
aria-label={`${card.name} card face`}
|
||
className="grid h-auto! w-full grid-cols-2 bg-[#101912] p-1"
|
||
>
|
||
<TabsTrigger
|
||
value="high"
|
||
className="min-h-11 whitespace-normal px-2 text-[#d1c7b0] data-active:bg-[#3f513b] data-active:text-white"
|
||
>
|
||
High face
|
||
</TabsTrigger>
|
||
<TabsTrigger
|
||
value="reverse"
|
||
className="min-h-11 whitespace-normal px-2 text-[#d1c7b0] data-active:bg-[#3f513b] data-active:text-white"
|
||
>
|
||
Low / occupied face
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="high">
|
||
<HomeworldPopulation card={card} side="high" enlarged={enlarged} />
|
||
</TabsContent>
|
||
<TabsContent value="reverse" className="space-y-6">
|
||
<HomeworldPopulation card={card} side="low" enlarged={enlarged} />
|
||
<section
|
||
aria-label="Occupied rules"
|
||
className="space-y-4 border-t-2 border-[#a88b60] pt-4"
|
||
>
|
||
<h4 className="m-0 font-serif text-xl">Occupied</h4>
|
||
{card.occupied.gameplay.map((text) => (
|
||
<p
|
||
key={text}
|
||
className={
|
||
enlarged
|
||
? 'm-0 text-xl! leading-8!'
|
||
: 'm-0 text-base! leading-7!'
|
||
}
|
||
>
|
||
{text}
|
||
</p>
|
||
))}
|
||
<p className="m-0 flex items-center gap-2 text-sm leading-6 text-[#edcc8c]">
|
||
<span aria-hidden="true" className="text-xl">
|
||
{'◆'.repeat(card.occupied.spiceIcons)}
|
||
</span>
|
||
{card.occupied.spiceIcons} bank spice during Spice Collection
|
||
</p>
|
||
<p className="m-0 text-sm leading-6 text-[#d1c7b0]">
|
||
The low-population penalty continues while occupied.
|
||
</p>
|
||
</section>
|
||
</TabsContent>
|
||
</Tabs>
|
||
{card.id === 'salusa_secundus' && (
|
||
<p className="m-0 px-4 pb-4 text-sm leading-6 text-[#d1c7b0]">
|
||
Both printed ranges include 2. The module’s minimum-high rule makes 2
|
||
Sardaukar high population.
|
||
</p>
|
||
)}
|
||
{!enlarged && (
|
||
<div className="px-4 pb-4">
|
||
<Dialog>
|
||
<DialogTrigger
|
||
render={
|
||
<Button
|
||
variant="outline"
|
||
className="min-h-11 w-full whitespace-normal"
|
||
/>
|
||
}
|
||
>
|
||
Inspect {card.name}
|
||
</DialogTrigger>
|
||
<DialogPortal>
|
||
<DialogOverlay className="bg-black/75 motion-reduce:animate-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-2xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border border-[#a88b60] bg-[#171d18] text-[#f2e8d3] shadow-2xl outline-none motion-reduce:animate-none">
|
||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#a88b60]/40 px-4 py-3">
|
||
<DialogTitle className="text-base leading-6">
|
||
{card.name}
|
||
</DialogTitle>
|
||
<DialogClose
|
||
render={
|
||
<Button variant="outline" className="min-h-11 min-w-16" />
|
||
}
|
||
>
|
||
Close
|
||
</DialogClose>
|
||
</div>
|
||
<DialogDescription className="sr-only">
|
||
Both faces of the Homeworld card, including population ranges,
|
||
native battle values and occupied effects.
|
||
</DialogDescription>
|
||
<div className="min-h-0 overflow-y-auto overscroll-contain p-3 sm:p-5">
|
||
<HomeworldFace card={card} enlarged />
|
||
</div>
|
||
</DialogPrimitive.Popup>
|
||
</DialogPortal>
|
||
</Dialog>
|
||
</div>
|
||
)}
|
||
<footer className="border-t border-[#a88b60]/40 px-4 py-3 text-xs leading-5 tracking-wide text-[#c9baa0]">
|
||
Ecaz & Moritani · 1 two-sided card + 1 world token
|
||
</footer>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
export function HomeworldCardGallery() {
|
||
return (
|
||
<section aria-label="Homeworld card collection" className="space-y-4">
|
||
<p className="m-0 text-base leading-7 text-[#d1c7b0]">
|
||
13 worlds · Inspect either face to read its complete gameplay effects.
|
||
Homeworld games are not yet available.
|
||
</p>
|
||
<div className="grid grid-cols-1 items-start gap-5 xl:grid-cols-2">
|
||
{HOMEWORLD_CARDS.map((card) => (
|
||
<HomeworldFace key={card.id} card={card} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|