import { type CardDef, CARDS, upgradeEffect } from "../../lib/game.ts";
import { useState } from "preact/hooks";
import { MAX_DECK_SIZE, MIN_DECK_SIZE, PACK_COST } from "../../lib/cards.ts";
import {
type HeroProgression,
progressionPoints,
SKILL_IDS,
type SkillId,
skillRankCost,
SKILLS,
VIGOR_PER_RANK,
} from "../../lib/progression.ts";
export const NODE_GLYPH: Record<string, string> = {
fight: "⚔",
elite: "✷",
treasure: "❖",
rest: "☾",
boss: "♛",
};
export const NODE_LABEL: Record<string, string> = {
fight: "Skirmish",
elite: "Elite",
treasure: "Treasury",
rest: "Sanctum",
boss: "Sovereign",
};
export function cardDef(id: string): CardDef {
const def = CARDS[id];
if (def) return def;
const baseId = id.startsWith("+") ? id.slice(1) : id;
const base = CARDS[baseId];
return {
...base,
id,
name: `${base.name} ✦`,
effects: upgradeEffect(base.effects),
};
}
export function cardSummary(
id: string,
): { label: string; color: string }[] {
const def = cardDef(id);
const eff = def.effects;
const parts: { label: string; color: string }[] = [];
if (eff.damage) {
parts.push({
label: `${eff.damage}${eff.hits ? `×${eff.hits}` : ""} dmg`,
color: "badge-error",
});
}
if (eff.block) {
parts.push({ label: `${eff.block} block`, color: "badge-info" });
}
if (eff.poison) {
parts.push({ label: `${eff.poison} poison`, color: "badge-success" });
}
if (eff.weak) {
parts.push({ label: `${eff.weak} weak`, color: "badge-warning" });
}
if (eff.heal) {
parts.push({ label: `heal ${eff.heal}`, color: "badge-success" });
}
if (eff.strength) {
parts.push({ label: `+${eff.strength} strength`, color: "badge-warning" });
}
if (eff.evade) {
parts.push({ label: `${eff.evade} evade`, color: "badge-secondary" });
}
if (eff.retaliate) {
parts.push({ label: `retaliate ${eff.retaliate}`, color: "badge-error" });
}
if (eff.draw) parts.push({ label: `draw ${eff.draw}`, color: "badge-info" });
if (eff.energyGain) {
parts.push({ label: `+${eff.energyGain} energy`, color: "badge-primary" });
}
if (eff.anima) {
parts.push({ label: `+${eff.anima} anima`, color: "badge-accent" });
}
if (eff.aoe) parts.push({ label: "all foes", color: "badge-neutral" });
return parts;
}
export function CardFace(
{ id, onClick, extraClass = "" }: {
id: string;
onClick?: () => void;
extraClass?: string;
},
) {
const def = cardDef(id);
const Tag = onClick ? "button" : "div";
return (
<Tag
onClick={onClick}
class={`card-fan-card game-card game-card-${def.type} card card-border bg-base-100 ${
onClick ? "cursor-pointer" : ""
} ${extraClass}`}
>
<figure
class={`card-art-frame arch-frame card-art-${def.type} ${
def.rare ? "card-art-rare" : ""
} relative overflow-hidden`}
>
<img
src={def.art}
alt=""
class="aspect-video w-full object-contain p-3"
draggable={false}
/>
{def.rare && <span class="sr-only">Rare card</span>}
</figure>
<div class="corner-fleurons card-body p-2.5 gap-1">
<div class="flex items-center justify-between gap-2">
<span
class={`badge badge-sm ${
def.cost === 0 ? "badge-secondary" : "badge-primary"
} font-bold`}
>
{def.cost}
</span>
<span class="font-heading text-[0.7rem] uppercase tracking-widest text-right">
{def.name}
</span>
</div>
<div class="flex flex-wrap gap-1 pt-0.5">
{cardSummary(id).map((detail) => (
<span
key={detail.label}
class={`badge badge-xs badge-soft ${detail.color} whitespace-nowrap`}
>
{detail.label}
</span>
))}
</div>
</div>
</Tag>
);
}
export function DeckScreen(
{ deck, onClose }: { deck: string[]; onClose: () => void },
) {
return (
<main class="room-screen room-archive flex-1 min-h-0 overflow-y-auto p-4 sm:p-8">
<div class="mx-auto max-w-4xl">
<div class="arch-panel vellum mx-auto w-fit max-w-full px-8 pb-3">
<h3 class="illuminated font-gothic text-2xl tracking-[0.14em] text-accent text-center">
Your deck · {deck.length} cards
</h3>
<div class="fleuron-rule mt-1">
<span aria-hidden="true">☾ ✧ ☽</span>
</div>
</div>
<div class="mt-5 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{deck.map((id, i) => <CardFace key={`${id}-${i}`} id={id} />)}
</div>
<div class="mt-6 flex justify-center">
<button
type="button"
class="btn btn-primary iron-band"
onClick={onClose}
>
Close
</button>
</div>
</div>
</main>
);
}
export function ArcanaHallScreen(
{
deckSize,
collectionSize,
onViewDeck,
onBuildDeck,
onBuyPack,
onClose,
}: {
deckSize: number;
collectionSize: number;
onViewDeck: () => void;
onBuildDeck: () => void;
onBuyPack: () => void;
onClose: () => void;
},
) {
return (
<main class="room-screen room-archive flex flex-1 min-h-0 items-center overflow-y-auto p-4 sm:p-8">
<div class="mx-auto w-full max-w-5xl">
<p class="text-center text-xs uppercase tracking-[0.4em] text-base-content/60">
Cards & collection
</p>
<h3 class="illuminated font-gothic text-3xl tracking-[0.14em] text-accent text-center mt-2">
Arcana Hall
</h3>
<div class="fleuron-rule my-4">
<span aria-hidden="true">☾ ✦ ☽</span>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<button
type="button"
class="arch-panel vellum card text-left"
onClick={onViewDeck}
>
<div class="corner-fleurons card-body">
<span class="text-3xl text-info" aria-hidden="true">▤</span>
<h4 class="card-title font-heading">Active Deck</h4>
<p class="text-sm text-base-content/70">
Review the {deckSize} cards you carry.
</p>
</div>
</button>
<button
type="button"
class="arch-panel vellum card text-left"
onClick={onBuildDeck}
>
<div class="corner-fleurons card-body">
<span class="text-3xl text-secondary" aria-hidden="true">✥</span>
<h4 class="card-title font-heading">Collection</h4>
<p class="text-sm text-base-content/70">
Build from {collectionSize} owned cards.
</p>
</div>
</button>
<button
type="button"
class="arch-panel vellum card text-left"
onClick={onBuyPack}
>
<div class="corner-fleurons card-body">
<span class="text-3xl text-accent" aria-hidden="true">✦</span>
<h4 class="card-title font-heading">Card Packs</h4>
<p class="text-sm text-base-content/70">
Acquire new arcana for your collection.
</p>
</div>
</button>
</div>
<div class="mt-6 flex justify-center">
<button type="button" class="btn btn-ghost" onClick={onClose}>
Return to the city
</button>
</div>
</div>
</main>
);
}
export function ProgressionScreen(
{
level,
levelCost,
anima,
progression,
onLevelUp,
onImproveVigor,
onLearnSkill,
onClose,
}: {
level: number;
levelCost: number;
anima: number;
progression: HeroProgression;
onLevelUp: () => void;
onImproveVigor: () => void;
onLearnSkill: (skill: SkillId) => void;
onClose: () => void;
},
) {
const points = progressionPoints(level, progression);
return (
<main class="room-screen room-keep flex-1 min-h-0 overflow-y-auto p-4 sm:p-8">
<div class="mx-auto max-w-6xl">
<p class="text-center text-xs uppercase tracking-[0.4em] text-base-content/60">
Ascendant constellation
</p>
<h3 class="illuminated font-gothic text-3xl tracking-[0.14em] text-accent text-center mt-2">
Shape Your Power
</h3>
<div class="fleuron-rule my-4">
<span aria-hidden="true">☾ ✦ ☽</span>
</div>
<div class="passive-board">
<div class="passive-level">
<span class="passive-level-number">{level}</span>
<div>
<p>Hero Level</p>
<small>Ascend to earn an attribute point</small>
</div>
<button
type="button"
class="btn btn-primary iron-band btn-sm"
disabled={anima < levelCost || level >= 999}
onClick={onLevelUp}
>
{level >= 999 ? "Mastered" : `Ascend · ${levelCost} Anima`}
</button>
</div>
<div class="passive-grid">
<button
type="button"
class="passive-tile passive-vigor"
disabled={points.attribute === 0}
onClick={onImproveVigor}
>
<span class="passive-icon" aria-hidden="true">♥</span>
<div class="passive-copy">
<strong>Vigor</strong>
<small>+{VIGOR_PER_RANK} maximum vitality per rank</small>
</div>
<span class="passive-rank">Rank {progression.vigor}</span>
<em>
{points.attribute > 0
? `${points.attribute} point available`
: "Ascend for points"}
</em>
</button>
{SKILL_IDS.map((id) => {
const skill = SKILLS[id];
const current = progression.skills[id];
const cost = skillRankCost(current);
const mastered = current >= skill.maxRank;
return (
<button
type="button"
key={id}
class={`passive-tile passive-${id}`}
disabled={anima < cost || mastered}
onClick={() => onLearnSkill(id)}
>
<span class="passive-icon" aria-hidden="true">
{skill.glyph}
</span>
<div class="passive-copy">
<strong>{skill.name}</strong>
<small>{skill.effect}</small>
</div>
<span
class="passive-pips"
aria-label={`${current} of ${skill.maxRank} ranks`}
>
{Array.from(
{ length: skill.maxRank },
(_, rank) => (
<i
class={rank < current ? "is-filled" : ""}
key={rank}
/>
),
)}
</span>
<em>{mastered ? "Mastered" : `${cost} Anima`}</em>
</button>
);
})}
</div>
</div>
<div class="mt-6 flex justify-center">
<button
type="button"
class="btn btn-primary iron-band"
onClick={onClose}
>
Return to the city
</button>
</div>
</div>
</main>
);
}
export function PackScreen(
{ anima, opened, buying, error, onBuy, onClose }: {
anima: number;
opened: string[] | null;
buying: boolean;
error: string | null;
onBuy: () => void;
onClose: () => void;
},
) {
return (
<main class="room-screen room-atelier flex flex-1 min-h-0 items-center overflow-y-auto p-4 sm:p-8">
<div class="mx-auto w-full max-w-5xl">
<p class="text-center text-xs uppercase tracking-[0.4em] text-base-content/60">
The Gilded Atelier
</p>
<h3 class="illuminated font-gothic text-3xl tracking-[0.14em] text-accent text-center mt-2">
Arcana Pack
</h3>
<div class="fleuron-rule my-4">
<span aria-hidden="true">☾ ✦ ☽</span>
</div>
<button
type="button"
class="arcana-pack card mx-auto"
disabled={buying || anima < PACK_COST}
onClick={onBuy}
>
<span class="arcana-pack-seal" aria-hidden="true" />
<div class="arcana-pack-face">
<p class="arcana-pack-maker font-heading">The Gilded Atelier</p>
<div class="arcana-pack-sigil wax-seal" aria-hidden="true">
<span>☾</span>
<strong>✦</strong>
<span>☽</span>
</div>
<div>
<h4 class="font-display">Arcana</h4>
<p class="font-heading">Five-card pack</p>
</div>
<span class="arcana-pack-rarity">
One rare card guaranteed
</span>
<span class="badge badge-accent badge-lg arcana-pack-price">
{PACK_COST} Anima
</span>
<small>{buying ? "Opening..." : "Click to open"}</small>
</div>
<span class="arcana-pack-seal" aria-hidden="true" />
</button>
{opened && (
<div class="mt-6">
<p class="text-center text-sm text-base-content/70 mb-5">
Five cards joined your collection. Click the pack again to open
another.
</p>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{opened.map((id, index) => (
<CardFace
key={`${id}-${index}`}
id={id}
extraClass="w-full"
/>
))}
</div>
</div>
)}
{error && (
<div role="alert" class="alert alert-error alert-soft mt-4">
<span>{error}</span>
</div>
)}
<div class="mt-6 flex justify-center gap-2">
<button type="button" class="btn btn-ghost" onClick={onClose}>
{opened ? "Return to Arcana Hall" : "Leave"}
</button>
</div>
<p class="text-center text-[0.65rem] text-base-content/45 mt-4">
Card icons by Lorc and Delapouite via{" "}
<a
class="link"
href="https://game-icons.net/"
target="_blank"
rel="noreferrer"
>
Game-icons.net
</a>{" "}
· CC BY 3.0
</p>
</div>
</main>
);
}
export function DeckBuilderScreen(
{ deck, collection, saving, error, onSave, onClose }: {
deck: string[];
collection: Record<string, number>;
saving: boolean;
error: string | null;
onSave: (deck: string[]) => void;
onClose: () => void;
},
) {
const [draft, setDraft] = useState(deck);
const used = draft.reduce<Record<string, number>>((counts, id) => {
counts[id] = (counts[id] ?? 0) + 1;
return counts;
}, {});
const owned = Object.entries(collection)
.filter(([id, quantity]) =>
quantity > 0 && (CARDS[id] || CARDS[id.slice(1)])
)
.sort(([a], [b]) => cardDef(a).name.localeCompare(cardDef(b).name));
const valid = draft.length >= MIN_DECK_SIZE && draft.length <= MAX_DECK_SIZE;
return (
<main class="room-screen room-archive flex-1 min-h-0 overflow-y-auto p-4 sm:p-6">
<div class="mx-auto max-w-7xl">
<div class="flex flex-col sm:flex-row items-center justify-between gap-3">
<div>
<p class="text-xs uppercase tracking-[0.35em] text-base-content/60">
The Archive
</p>
<h3 class="illuminated font-gothic text-3xl tracking-[0.12em] text-accent mt-1">
Deck Builder
</h3>
</div>
<span
class={`badge badge-lg pennant mr-2 ${
valid ? "badge-success" : "badge-warning"
}`}
>
{draft.length} / {MAX_DECK_SIZE} cards
</span>
</div>
<div class="fleuron-rule my-4">
<span aria-hidden="true">☾ ✧ ☽</span>
</div>
<div class="grid lg:grid-cols-2 gap-6">
<section class="frame-panel vellum px-3 pb-4 pt-4 sm:px-4">
<h4 class="font-heading text-sm uppercase tracking-[0.2em] text-center mb-3">
Collection · select to add
</h4>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-[58vh] overflow-y-auto pr-1">
{owned.map(([id, quantity]) => {
const remaining = quantity - (used[id] ?? 0);
return (
<div class="relative" key={id}>
<CardFace
id={id}
extraClass={`w-full ${
remaining <= 0 ? "opacity-45" : ""
}`}
onClick={remaining > 0 && draft.length < MAX_DECK_SIZE
? () => setDraft([...draft, id])
: undefined}
/>
<span class="badge badge-neutral badge-sm pennant absolute left-1 top-1">
{remaining} left
</span>
</div>
);
})}
</div>
</section>
<section class="frame-panel vellum px-3 pb-4 pt-4 sm:px-4">
<h4 class="font-heading text-sm uppercase tracking-[0.2em] text-center mb-3">
Active deck · select to remove
</h4>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-[58vh] overflow-y-auto pr-1">
{draft.map((id, index) => (
<CardFace
key={`${id}-${index}`}
id={id}
extraClass="w-full"
onClick={() => setDraft(draft.filter((_, i) => i !== index))}
/>
))}
</div>
</section>
</div>
{!valid && (
<div role="alert" class="alert alert-warning alert-soft mt-4">
<span>An active deck needs at least {MIN_DECK_SIZE} cards.</span>
</div>
)}
{error && (
<div role="alert" class="alert alert-error alert-soft mt-4">
<span>{error}</span>
</div>
)}
<div class="mt-5 flex justify-end gap-2">
<button type="button" class="btn btn-ghost" onClick={onClose}>
Cancel
</button>
<button
type="button"
class="btn btn-primary iron-band uppercase tracking-[0.18em] font-heading"
disabled={!valid || saving}
onClick={() => onSave(draft)}
>
{saving ? "Saving..." : "Set active deck"}
</button>
</div>
</div>
</main>
);
}
export function SanctumScreen(
{ deck, cost, affordable, onUpgrade, onClose }: {
deck: string[];
cost: number;
affordable: boolean;
onUpgrade: (index: number) => void;
onClose: () => void;
},
) {
return (
<main class="room-screen room-sanctum flex min-h-0 flex-1 overflow-hidden p-4 sm:p-6">
<div class="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
<div class="shrink-0">
<div class="arch-panel vellum mx-auto w-fit max-w-full px-8 pb-3">
<h3 class="illuminated font-gothic text-2xl tracking-[0.14em] text-accent text-center">
The Sanctum
</h3>
<p class="text-center text-xs uppercase tracking-[0.3em] text-base-content/65">
Refine a card · {cost} anima
</p>
<div class="fleuron-rule mt-1">
<span aria-hidden="true">☾ ✧ ☽</span>
</div>
</div>
</div>
<div class="mt-4 grid min-h-0 flex-1 content-start grid-cols-2 gap-3 overflow-y-auto sm:grid-cols-3 lg:grid-cols-5">
{deck.map((id, i) => (
<CardFace
key={`${id}-${i}`}
id={id}
onClick={affordable && !id.startsWith("+")
? () => onUpgrade(i)
: undefined}
/>
))}
</div>
{!affordable && (
<p class="text-center text-error text-sm mt-3">
Not enough Anima.
</p>
)}
<div class="mt-4 flex shrink-0 justify-center">
<button
type="button"
class="btn btn-primary iron-band"
onClick={onClose}
>
Leave
</button>
</div>
</div>
</main>
);
}