import { animate, utils } from "animejs";
import { useEffect, useRef, useState } from "preact/hooks";
import archiveArt from "../assets/icons/ffffff/transparent/1x1/lorc/white-tower.svg";
import enemyAttackArt from "../assets/icons/ffffff/transparent/1x1/lorc/sword-clash.svg";
import enemyBlockArt from "../assets/icons/ffffff/transparent/1x1/lorc/magic-shield.svg";
import enemyBuffArt from "../assets/icons/ffffff/transparent/1x1/lorc/aura.svg";
import enemyHexArt from "../assets/icons/ffffff/transparent/1x1/lorc/poison-gas.svg";
import keepArt from "../assets/icons/ffffff/transparent/1x1/delapouite/castle.svg";
import gateArt from "../assets/icons/ffffff/transparent/1x1/delapouite/medieval-gate.svg";
import sanctumArt from "../assets/icons/ffffff/transparent/1x1/delapouite/viking-church.svg";
import {
BOSS_TEMPLATES,
type CardInstance,
CLASSES,
type ClassId,
criticalChance,
ELITE_TEMPLATES,
ENEMY_TEMPLATES,
type EnemyMoveDef,
type EnemyTemplate,
MAP_FLOORS,
type MapNode,
maxEnergy,
maxVigor,
scaleDamage,
scaleHp,
} from "../lib/game.ts";
import type { Hero } from "../lib/heroes.ts";
import {
type HeroProgression,
levelUpCost,
progressionPoints,
type SkillId,
skillRankCost,
SKILLS,
} from "../lib/progression.ts";
import {
ArcanaHallScreen,
cardDef,
CardFace,
DeckBuilderScreen,
DeckScreen,
NODE_GLYPH,
NODE_LABEL,
PackScreen,
ProgressionScreen,
SanctumScreen,
} from "./game/ui.tsx";
type Screen =
| "city"
| "keep"
| "arcana"
| "deck"
| "deckBuilder"
| "pack"
| "sanctum"
| "map"
| "combat"
| "summary";
type Outcome = "victory" | "defeat" | "retreat";
interface CombatEnemy {
key: number;
name: string;
art: string;
hp: number;
maxHp: number;
block: number;
strength: number;
poison: number;
weak: number;
moves: EnemyMoveDef[];
moveIdx: number;
}
interface CombatState {
heroHp: number;
heroMaxHp: number;
block: number;
strength: number;
poison: number;
evade: number;
retaliate: number;
energy: number;
maxEnergy: number;
handSize: number;
hand: CardInstance[];
draw: CardInstance[];
discard: CardInstance[];
enemies: CombatEnemy[];
turn: number;
log: string[];
over: null | "win" | "lose";
}
interface RunState {
floors: MapNode[][];
floorIdx: number;
heroHp: number;
animaGained: number;
}
interface CombatEffect {
id: number;
art: string;
type: "physical" | "defense" | "poison" | "arcane";
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
targetSelector: string;
aoe: boolean;
}
const ENEMY_MOVE_VISUALS: Record<
EnemyMoveDef["kind"],
{ art: string; type: CombatEffect["type"] }
> = {
attack: { art: enemyAttackArt, type: "physical" },
block: { art: enemyBlockArt, type: "defense" },
buff: { art: enemyBuffArt, type: "arcane" },
hex: { art: enemyHexArt, type: "poison" },
};
let uid = 0;
let enemyKey = 0;
let combatEffectKey = 0;
function prefersReducedMotion(): boolean {
return globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function shuffle<T>(arr: T[]): T[] {
const out = [...arr];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
function buildDrawPile(deckIds: string[]): CardInstance[] {
const cards: CardInstance[] = [];
for (const id of deckIds) {
cards.push({ uid: ++uid, def: cardDef(id), upgraded: false });
}
return cards;
}
function enemyFromTemplate(t: EnemyTemplate, depth: number): CombatEnemy {
return {
key: ++enemyKey,
name: t.name,
art: t.art,
hp: scaleHp(t.hp, depth),
maxHp: scaleHp(t.hp, depth),
block: 0,
strength: 0,
poison: 0,
weak: 0,
moves: t.moves.map((m) => ({
...m,
damage: m.damage === undefined ? undefined : scaleDamage(m.damage, depth),
})),
moveIdx: 0,
};
}
function rollEnemyGroup(depth: number): EnemyTemplate[] {
const pool = Object.values(ENEMY_TEMPLATES);
const count = depth >= 3 && Math.random() < 0.4 ? 2 : 1;
return Array.from({ length: count }, () => pick(pool));
}
function generateFloors(): MapNode[][] {
const floors: MapNode[][] = [];
for (let i = 0; i < MAP_FLOORS; i++) {
if (i === MAP_FLOORS - 1) {
floors.push([{ kind: "boss", done: false }]);
continue;
}
const options: MapNode[] = [];
for (let b = 0; b < 2; b++) {
const roll = Math.random();
let kind: MapNode["kind"];
if (roll < 0.55) kind = "fight";
else if (roll < 0.72) kind = "treasure";
else if (roll < 0.87) kind = "rest";
else kind = i >= 3 ? "elite" : "fight";
options.push({ kind, done: false });
}
floors.push(options);
}
if (!floors.flat().some((n) => n.kind === "rest")) {
floors[MAP_FLOORS - 2][0] = { kind: "rest", done: false };
}
return floors;
}
export default function Game(
{ hero: initialHero, isAdmin }: { hero: Hero; isAdmin: boolean },
) {
const klass = CLASSES[initialHero.class as ClassId];
const [deck, setDeck] = useState<string[]>(initialHero.deck);
const [collection, setCollection] = useState(initialHero.collection);
const [anima, setAnima] = useState(initialHero.anima);
const [level, setLevel] = useState(initialHero.level);
const [progression, setProgression] = useState(initialHero.progression);
const [screen, setScreen] = useState<Screen>("city");
const [displayScreen, setDisplayScreen] = useState<Screen>("city");
const [leaving, setLeaving] = useState(false);
const [run, setRun] = useState<RunState | null>(null);
const [combat, setCombat] = useState<CombatState | null>(null);
const [rewardAnima, setRewardAnima] = useState(0);
const [rewardOpen, setRewardOpen] = useState(false);
const [outcome, setOutcome] = useState<Outcome>("retreat");
const [target, setTarget] = useState(0);
const [openedPack, setOpenedPack] = useState<string[] | null>(null);
const [buyingPack, setBuyingPack] = useState(false);
const [savingDeck, setSavingDeck] = useState(false);
const [featureError, setFeatureError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [combatEffects, setCombatEffects] = useState<CombatEffect[]>([]);
const [enemyTurn, setEnemyTurn] = useState(false);
const combatEffectAnimations = useRef<
Map<number, ReturnType<typeof animate>[]>
>(new Map());
const combatFinishTimer = useRef<
ReturnType<typeof globalThis.setTimeout> | null
>(null);
const savedProgress = useRef({
anima: initialHero.anima,
level: initialHero.level,
progression: initialHero.progression,
deck: initialHero.deck,
});
const saveQueue = useRef<Promise<void>>(Promise.resolve());
const stageRef = useRef<HTMLDivElement | null>(null);
const screenAnimation = useRef<ReturnType<typeof animate> | null>(null);
const rewardClosing = useRef(false);
const handPositions = useRef(new Map<number, { x: number; y: number }>());
const handAnimations = useRef<ReturnType<typeof animate>[]>([]);
const maxHp = maxVigor(klass.maxHp, progression);
const heroMaxEnergy = maxEnergy(klass.energy, progression);
const critChance = criticalChance(progression);
const points = progressionPoints(level, progression);
const levelCost = levelUpCost(level);
const upgradeCost = 20 + 10 * level;
useEffect(() => {
if (prefersReducedMotion()) {
return;
}
if (displayScreen !== "keep") return;
const animation = animate(".passive-icon", {
translateY: [0, -4],
rotate: [-2, 2],
duration: 2400,
ease: "inOutSine",
loop: true,
alternate: true,
});
return () => {
animation.revert();
};
}, [displayScreen]);
// Screens never swap instantly: the outgoing one dims and sinks away, then
// the incoming one rises into place.
useEffect(() => {
if (screen === displayScreen) return;
const stage = stageRef.current;
if (!stage || prefersReducedMotion()) {
setDisplayScreen(screen);
return;
}
setLeaving(true);
screenAnimation.current?.revert();
screenAnimation.current = animate(stage, {
opacity: [1, 0],
scale: [1, 0.985],
translateY: [0, -14],
duration: 200,
ease: "in(2)",
onComplete: () => {
screenAnimation.current = null;
setDisplayScreen(screen);
},
});
}, [screen, displayScreen]);
useEffect(() => {
setLeaving(false);
const stage = stageRef.current;
if (!stage || prefersReducedMotion()) return;
const entrance = animate(stage, {
opacity: [0, 1],
scale: [0.99, 1],
translateY: [16, 0],
duration: 340,
ease: "out(3)",
onComplete: (animation) => utils.cleanInlineStyles(animation),
});
screenAnimation.current = entrance;
return () => {
if (screenAnimation.current === entrance) screenAnimation.current = null;
entrance.revert();
};
}, [displayScreen]);
// The map keeps the same screen between floors, so the fresh branching paths
// get their own arrival beat.
useEffect(() => {
if (displayScreen !== "map" || prefersReducedMotion()) return;
const animation = animate(".path-choice", {
opacity: [0, 1],
translateY: [18, 0],
duration: 420,
delay: (_target, index) => (index ?? 0) * 90,
ease: "out(3)",
onComplete: (item) => utils.cleanInlineStyles(item),
});
return () => {
animation.revert();
};
}, [displayScreen, run?.floorIdx]);
// Playing or drawing a card re-fans the whole hand. Without this the cards
// teleport to their new slots; here they glide there from where they were.
useEffect(() => {
for (const animation of handAnimations.current) animation.revert();
handAnimations.current = [];
const slots = Array.from(
document.querySelectorAll<HTMLElement>("[data-card-uid]"),
);
for (const slot of slots) slot.style.transform = "";
const previous = handPositions.current;
const next = new Map<number, { x: number; y: number }>();
const moves: { slot: HTMLElement; dx: number; dy: number }[] = [];
for (const slot of slots) {
const uid = Number(slot.dataset.cardUid);
const rect = slot.getBoundingClientRect();
next.set(uid, { x: rect.left, y: rect.top });
const old = previous.get(uid);
if (!old) continue;
const dx = old.x - rect.left;
const dy = old.y - rect.top;
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue;
moves.push({ slot, dx, dy });
}
handPositions.current = next;
if (prefersReducedMotion()) return;
for (const move of moves) {
handAnimations.current.push(
animate(move.slot, {
translateX: [move.dx, 0],
translateY: [move.dy, 0],
duration: 300,
ease: "out(3)",
onComplete: (animation) => utils.cleanInlineStyles(animation),
}),
);
}
}, [displayScreen, combat?.hand]);
useEffect(() => {
if (!rewardOpen || prefersReducedMotion()) return;
const box = document.querySelector("[data-reward-box]");
if (!box) return;
const animation = animate(box, {
opacity: [0, 1],
scale: [0.88, 1],
translateY: [24, 0],
duration: 460,
ease: "out(4)",
onComplete: (item) => utils.cleanInlineStyles(item),
});
return () => {
animation.revert();
};
}, [rewardOpen]);
useEffect(() => {
if (prefersReducedMotion()) {
if (combatEffects.length > 0) setCombatEffects([]);
return;
}
for (const effect of combatEffects) {
if (combatEffectAnimations.current.has(effect.id)) continue;
const visual = document.querySelector(
`[data-combat-effect="${effect.id}"]`,
);
if (!visual) continue;
const target = document.querySelector(effect.targetSelector);
const travel = animate(visual, {
translateX: [0, effect.targetX - effect.sourceX],
translateY: [0, effect.targetY - effect.sourceY],
rotate: effect.type === "physical" ? [0, 520] : [-18, 18],
scale: effect.aoe ? [0.65, 1.65] : [0.65, 1.15],
opacity: [1, 1, 0],
duration: 620,
ease: effect.type === "physical" ? "inOut(3)" : "out(4)",
onComplete: () => {
combatEffectAnimations.current.delete(effect.id);
setCombatEffects((current) =>
current.filter((item) => item.id !== effect.id)
);
},
});
const impact = target
? animate(target, {
scale: [1, effect.aoe ? 1.08 : 1.045, 1],
filter: ["brightness(1)", "brightness(1.45)", "brightness(1)"],
delay: 410,
duration: 260,
ease: "outBack",
onComplete: (animation) => utils.cleanInlineStyles(animation),
})
: null;
combatEffectAnimations.current.set(
effect.id,
impact ? [travel, impact] : [travel],
);
}
}, [combatEffects]);
useEffect(() => () => {
for (const animations of combatEffectAnimations.current.values()) {
for (const animation of animations) animation.revert();
}
combatEffectAnimations.current.clear();
for (const animation of handAnimations.current) animation.revert();
handAnimations.current = [];
if (combatFinishTimer.current !== null) {
globalThis.clearTimeout(combatFinishTimer.current);
}
}, []);
function persist(next: {
anima: number;
level: number;
progression: HeroProgression;
deck: string[];
}) {
saveQueue.current = saveQueue.current.then(async () => {
const expected = savedProgress.current;
const response = await fetch("/api/game/save", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...next,
expectedAnima: expected.anima,
expectedLevel: expected.level,
expectedProgression: expected.progression,
expectedDeck: expected.deck,
}),
});
if (!response.ok) {
if (response.status === 409) globalThis.location.reload();
throw new Error("Could not save game progress.");
}
savedProgress.current = next;
}).catch(() => {});
}
function levelUp() {
if (anima < levelCost || level >= 999) return;
const nextAnima = anima - levelCost;
const nextLevel = level + 1;
setAnima(nextAnima);
setLevel(nextLevel);
setMessage(
`${klass.name} ascends to level ${nextLevel}: +1 attribute point.`,
);
persist({ anima: nextAnima, level: nextLevel, progression, deck });
}
function improveVigor() {
if (points.attribute === 0) return;
const next = { ...progression, vigor: progression.vigor + 1 };
setProgression(next);
setMessage(`Vigor rises by 8.`);
persist({ anima, level, progression: next, deck });
}
function learnSkill(skillId: SkillId) {
const skill = SKILLS[skillId];
const cost = skillRankCost(progression.skills[skillId]);
if (
anima < cost || progression.skills[skillId] >= skill.maxRank
) return;
const next = {
...progression,
skills: {
...progression.skills,
[skillId]: progression.skills[skillId] + 1,
},
};
const nextAnima = anima - cost;
setAnima(nextAnima);
setProgression(next);
setMessage(`${skill.name} grows stronger for ${cost} Anima.`);
persist({ anima: nextAnima, level, progression: next, deck });
}
function upgradeCard(index: number) {
if (anima < upgradeCost) return;
const cardId = deck[index];
if (!cardId || cardId.startsWith("+")) return;
const def = cardDef(cardId);
const nextDeck = [...deck];
nextDeck[index] = `+${cardId}`;
const nextAnima = anima - upgradeCost;
setAnima(nextAnima);
setDeck(nextDeck);
setCollection((current) => ({
...current,
[cardId]: Math.max(0, (current[cardId] ?? 0) - 1),
[`+${cardId}`]: (current[`+${cardId}`] ?? 0) + 1,
}));
setMessage(`${def.name} gleams brighter.`);
persist({ anima: nextAnima, level, progression, deck: nextDeck });
}
function startRun() {
setRun({
floors: generateFloors(),
floorIdx: 0,
heroHp: maxHp,
animaGained: 0,
});
setScreen("map");
}
async function buyPack() {
setBuyingPack(true);
setFeatureError(null);
try {
await saveQueue.current;
const response = await fetch("/api/cards/pack", { method: "POST" });
const data = await response.json() as {
cards?: string[];
anima?: number;
error?: string;
};
if (!response.ok || !data.cards || typeof data.anima !== "number") {
throw new Error(data.error ?? "Could not open the pack.");
}
setAnima(data.anima);
savedProgress.current = { ...savedProgress.current, anima: data.anima };
setOpenedPack(data.cards);
setCollection((current) => {
const next = { ...current };
for (const id of data.cards!) next[id] = (next[id] ?? 0) + 1;
return next;
});
} catch (error) {
setFeatureError(
error instanceof Error ? error.message : "Could not open the pack.",
);
} finally {
setBuyingPack(false);
}
}
async function saveDeck(nextDeck: string[]) {
setSavingDeck(true);
setFeatureError(null);
try {
await saveQueue.current;
const response = await fetch("/api/cards/deck", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ deck: nextDeck }),
});
const data = await response.json() as { error?: string };
if (!response.ok) {
throw new Error(data.error ?? "Could not save the deck.");
}
setDeck(nextDeck);
savedProgress.current = { ...savedProgress.current, deck: nextDeck };
setScreen("arcana");
setMessage("Your active deck has been set.");
} catch (error) {
setFeatureError(
error instanceof Error ? error.message : "Could not save the deck.",
);
} finally {
setSavingDeck(false);
}
}
function endRun(final: Outcome) {
const gained = run ? run.animaGained : 0;
const nextAnima = anima + gained;
setAnima(nextAnima);
persist({ anima: nextAnima, level, progression, deck });
setOutcome(final);
setRewardOpen(false);
// Run and combat state stay put so the screen we are leaving can finish
// its exit animation with its contents intact; the next run replaces them.
setScreen("summary");
}
function advanceFloor() {
if (!run) return;
const nextIdx = run.floorIdx + 1;
if (nextIdx >= run.floors.length) {
endRun("victory");
return;
}
setRun((r) =>
r
? {
...r,
floors: r.floors.map((f, i) =>
i === r.floorIdx ? f.map((n) => ({ ...n, done: true })) : f
),
floorIdx: nextIdx,
}
: r
);
setScreen("map");
}
function enterNode(node: MapNode) {
if (!run) return;
const depth = run.floorIdx;
if (
node.kind === "fight" || node.kind === "elite" || node.kind === "boss"
) {
const templates = node.kind === "boss"
? [pick(BOSS_TEMPLATES)]
: node.kind === "elite"
? [pick(ELITE_TEMPLATES)]
: rollEnemyGroup(depth);
startCombat(templates, depth, node.kind);
} else if (node.kind === "treasure") {
const amount = 15 + 5 * depth;
setRun({ ...run, animaGained: run.animaGained + amount });
setMessage(`The vault opens: +${amount} Anima.`);
advanceFloor();
} else if (node.kind === "rest") {
const heal = Math.round(maxHp * 0.35);
setRun({ ...run, heroHp: Math.min(maxHp, run.heroHp + heal) });
setMessage(`The sanctum restores ${heal} vitality.`);
advanceFloor();
}
}
function startCombat(
templates: EnemyTemplate[],
depth: number,
kind: MapNode["kind"],
) {
if (!run) return;
const enemies = templates.map((t) => enemyFromTemplate(t, depth));
const draw = shuffle(buildDrawPile(deck));
const hand = draw.splice(0, klass.handSize);
setCombat({
heroHp: run.heroHp,
heroMaxHp: maxHp,
block: 0,
strength: progression.skills.strength,
poison: 0,
evade: 0,
retaliate: 0,
energy: heroMaxEnergy,
maxEnergy: heroMaxEnergy,
handSize: klass.handSize,
hand,
draw,
discard: [],
enemies,
turn: 1,
log: [
kind === "boss"
? "The Sovereign descends from her gilded throne."
: "Steel meets shadow.",
],
over: null,
});
setTarget(0);
setCombatEffects([]);
setScreen("combat");
}
function drawCards(state: CombatState, n: number): CombatState {
const draw = [...state.draw];
const discard = [...state.discard];
const hand = [...state.hand];
for (let i = 0; i < n; i++) {
if (draw.length === 0) {
if (discard.length === 0) break;
draw.push(...shuffle(discard));
discard.length = 0;
}
const card = draw.pop();
if (card) hand.push(card);
}
return { ...state, draw, discard, hand };
}
function animateCardPlay(
def: ReturnType<typeof cardDef>,
enemyIndex: number,
) {
if (prefersReducedMotion()) {
return;
}
const hostile = Boolean(
def.effects.damage || def.effects.poison || def.effects.weak,
);
const aoe = hostile && Boolean(def.effects.aoe);
const source = document.querySelector("[data-combat-hero]")
?.getBoundingClientRect();
const targetSelector = hostile
? aoe ? ".combat-enemies" : `[data-enemy-index="${enemyIndex}"]`
: "[data-combat-hero]";
const targetRect = document.querySelector(targetSelector)
?.getBoundingClientRect();
const sourceX = (source?.left ?? globalThis.innerWidth / 2) +
(source?.width ?? 0) / 2 - 32;
const sourceY = (source?.top ?? globalThis.innerHeight - 120) +
(source?.height ?? 0) / 2 - 32;
const targetX = (targetRect?.left ?? globalThis.innerWidth / 2) +
(targetRect?.width ?? 0) / 2 - 32;
const targetY = (targetRect?.top ?? globalThis.innerHeight / 3) +
(targetRect?.height ?? 0) / 2 - 32;
setCombatEffects((current) =>
[
...current,
{
id: ++combatEffectKey,
art: def.art,
type: def.type,
sourceX,
sourceY,
targetX,
targetY,
targetSelector,
aoe,
},
].slice(-8)
);
}
function playCard(cardIndex: number) {
if (!combat || combat.over) return;
const card = combat.hand[cardIndex];
if (!card) return;
const def = cardDef(card.def.id);
if (def.cost > combat.energy) return;
const eff = def.effects;
const log: string[] = [];
const enemies = combat.enemies.map((e) => ({ ...e }));
let heroHp = combat.heroHp;
let block = combat.block;
let strength = combat.strength;
let evade = combat.evade;
let retaliate = combat.retaliate;
let energy = combat.energy - def.cost;
let idx = Math.min(Math.max(target, 0), enemies.length - 1);
if (!enemies[idx] || enemies[idx].hp <= 0) {
const live = enemies.findIndex((e) => e.hp > 0);
if (live !== -1) idx = live;
}
animateCardPlay(def, idx);
const hit = (e: CombatEnemy, raw: number) => {
const critical = critChance > 0 && Math.random() < critChance;
const dmg = Math.max(1, critical ? raw * 2 : raw);
const absorbed = Math.min(e.block, dmg);
e.block -= absorbed;
e.hp -= dmg - absorbed;
log.push(
`${def.name}${critical ? " critically" : ""} hits ${e.name} for ${
dmg - absorbed
}.`,
);
};
if (eff.damage) {
const hits = eff.hits ?? 1;
for (let h = 0; h < hits; h++) {
if (eff.aoe) {
enemies
.filter((e) => e.hp > 0)
.forEach((e) => hit(e, eff.damage! + strength));
} else {
const e = enemies[idx];
if (e && e.hp > 0) hit(e, eff.damage + strength);
}
}
}
if (eff.block) {
block += eff.block;
log.push(`+${eff.block} block.`);
}
if (eff.heal) {
heroHp = Math.min(combat.heroMaxHp, heroHp + eff.heal);
log.push(`Restored ${eff.heal} vitality.`);
}
if (eff.poison) {
const e = enemies[idx];
const poisonApplied = eff.poison + progression.skills.poison;
if (e && e.hp > 0) e.poison += poisonApplied;
log.push(`${poisonApplied} poison seeps in.`);
}
if (eff.weak) {
const e = enemies[idx];
if (e && e.hp > 0) {
e.weak += eff.weak;
log.push(`${e.name} is weakened.`);
}
}
if (eff.strength) {
strength += eff.strength;
log.push(`+${eff.strength} strength.`);
}
if (eff.evade) {
evade += eff.evade;
log.push("She melts from sight.");
}
if (eff.retaliate) {
retaliate += eff.retaliate;
log.push("Blades stand ready to answer.");
}
if (eff.anima && run) {
setRun({ ...run, animaGained: run.animaGained + eff.anima });
log.push(`+${eff.anima} Anima drawn from the ether.`);
}
let state: CombatState = { ...combat };
if (eff.draw) state = drawCards(state, eff.draw);
if (eff.energyGain) energy += eff.energyGain;
enemies.forEach((e) => {
if (e.hp <= 0) log.push(`${e.name} falls!`);
});
const hand = state.hand.filter((_, i) => i !== cardIndex);
const over = enemies.every((e) => e.hp <= 0) ? "win" : null;
setCombat({
...state,
hand,
discard: [...state.discard, card],
enemies,
heroHp,
block,
strength,
evade,
retaliate,
energy,
log: [...state.log, ...log].slice(-6),
over,
});
if (over) {
if (prefersReducedMotion()) {
finishCombat(true, heroHp);
} else {
combatFinishTimer.current = globalThis.setTimeout(() => {
combatFinishTimer.current = null;
finishCombat(true, heroHp);
}, 650);
}
} else if (enemies[idx]?.hp <= 0) {
const live = enemies.findIndex((e) => e.hp > 0);
if (live !== -1) setTarget(live);
}
}
function resolveEnemyMove(
state: CombatState,
enemyIndex: number,
): CombatState {
const log: string[] = [];
const enemies = state.enemies.map((e) => ({ ...e }));
let heroHp = state.heroHp;
let block = state.block;
let evade = state.evade;
const retaliate = state.retaliate;
let poison = state.poison;
const e = enemies[enemyIndex];
if (e?.hp > 0) {
const move = e.moves[e.moveIdx % e.moves.length];
if (move.kind === "attack" && move.damage) {
const hits = move.hits ?? 1;
for (let h = 0; h < hits; h++) {
if (evade > 0) {
evade--;
log.push(`${e.name}'s ${move.label} finds only air.`);
continue;
}
const raw = Math.max(
1,
Math.round((move.damage + e.strength) * (e.weak > 0 ? 0.75 : 1)),
);
const absorbed = Math.min(block, raw);
block -= absorbed;
heroHp -= raw - absorbed;
log.push(`${e.name} uses ${move.label} for ${raw}.`);
if (retaliate > 0 && e.hp > 0) {
e.hp -= retaliate;
log.push(`Riposte wounds ${e.name} for ${retaliate}.`);
}
if (heroHp <= 0) break;
}
} else if (move.kind === "block" && move.block) {
e.block += move.block;
log.push(`${e.name} hardens (+${move.block} block).`);
} else if (move.kind === "buff" && move.strength) {
e.strength += move.strength;
log.push(`${e.name} swells with power.`);
} else if (move.kind === "hex" && move.poison) {
poison += move.poison;
log.push(`${e.name} hexes you: ${move.poison} poison.`);
}
e.moveIdx = (e.moveIdx + 1) % e.moves.length;
}
const over: null | "win" | "lose" = heroHp <= 0
? "lose"
: enemies.every((enemy) => enemy.hp <= 0)
? "win"
: null;
return {
...state,
enemies,
heroHp: Math.max(0, heroHp),
block,
evade,
poison,
log: [...state.log, ...log].slice(-6),
over,
};
}
function resolveEndOfEnemyTurn(state: CombatState): CombatState {
const log: string[] = [];
const enemies = state.enemies.map((e) => ({ ...e }));
let heroHp = state.heroHp;
let poison = state.poison;
for (const e of enemies) {
if (e.poison > 0 && e.hp > 0) {
e.hp -= e.poison;
log.push(`${e.name} suffers ${e.poison} poison.`);
e.poison -= 1;
if (e.hp <= 0) log.push(`${e.name} succumbs.`);
}
}
if (poison > 0 && heroHp > 0) {
heroHp -= poison;
log.push(`Poison burns for ${poison}.`);
poison -= 1;
}
const over: null | "win" | "lose" = heroHp <= 0
? "lose"
: enemies.every((e) => e.hp <= 0)
? "win"
: null;
return {
...state,
enemies,
heroHp: Math.max(0, heroHp),
poison,
retaliate: 0,
log: [...state.log, ...log].slice(-6),
over,
};
}
function animateEnemyMove(enemyIndex: number, move: EnemyMoveDef) {
if (prefersReducedMotion()) {
return Promise.resolve();
}
const enemySelector = `[data-enemy-index="${enemyIndex}"]`;
const targetSelector = move.kind === "attack" || move.kind === "hex"
? "[data-combat-hero]"
: enemySelector;
const source = document.querySelector(enemySelector)
?.getBoundingClientRect();
const targetRect = document.querySelector(targetSelector)
?.getBoundingClientRect();
const visual = ENEMY_MOVE_VISUALS[move.kind];
if (source && targetRect) {
setCombatEffects((current) =>
[
...current,
{
id: ++combatEffectKey,
art: visual.art,
type: visual.type,
sourceX: source.left + source.width / 2 - 32,
sourceY: source.top + source.height / 2 - 32,
targetX: targetRect.left + targetRect.width / 2 - 32,
targetY: targetRect.top + targetRect.height / 2 - 32,
targetSelector,
aoe: false,
},
].slice(-8)
);
}
const enemy = document.querySelector(enemySelector);
if (!enemy) return Promise.resolve();
const horizontalDirection = targetRect && source
? Math.sign(targetRect.left - source.left) || -1
: -1;
return new Promise<void>((resolve) => {
animate(enemy, {
translateX: move.kind === "attack"
? [0, horizontalDirection * 30, 0]
: [0, 0],
translateY: move.kind === "hex" ? [0, -10, 0] : [0, 0],
rotate: move.kind === "block"
? [0, -3, 3, 0]
: move.kind === "buff"
? [0, -2, 2, 0]
: [0, 0],
scale: move.kind === "block" || move.kind === "buff"
? [1, 1.08, 1]
: [1, 1.03, 1],
filter: ["brightness(1)", "brightness(1.45)", "brightness(1)"],
duration: 620,
ease: "inOut(3)",
onComplete: (animation) => {
utils.cleanInlineStyles(animation);
resolve();
},
});
});
}
async function endTurn() {
if (!combat || combat.over || enemyTurn) return;
setEnemyTurn(true);
let state: CombatState = {
...combat,
hand: [],
discard: [...combat.discard, ...combat.hand],
enemies: combat.enemies.map((enemy) => ({ ...enemy, block: 0 })),
};
setCombat(state);
for (let i = 0; i < state.enemies.length; i++) {
const enemy = state.enemies[i];
if (enemy.hp <= 0) continue;
const move = enemy.moves[enemy.moveIdx % enemy.moves.length];
await animateEnemyMove(i, move);
state = resolveEnemyMove(state, i);
setCombat(state);
if (state.over) break;
}
if (!state.over) {
state = resolveEndOfEnemyTurn(state);
setCombat(state);
}
if (state.over) {
setCombat({ ...state, energy: 0 });
setEnemyTurn(false);
finishCombat(state.over === "win", state.heroHp);
return;
}
state = {
...state,
turn: state.turn + 1,
energy: state.maxEnergy,
block: 0,
};
state = drawCards(state, state.handSize);
setCombat(state);
setTarget(0);
setEnemyTurn(false);
}
function finishCombat(won: boolean, heroHp: number) {
if (!run) return;
if (!won) {
endRun("defeat");
return;
}
const depth = run.floorIdx;
const kind = run.floors[run.floorIdx][0].kind;
const base = kind === "boss"
? 40 + 10 * depth
: kind === "elite"
? 25 + 6 * depth
: 10 + 4 * depth;
// Wounds carry over to the next battle; only rests restore vitality.
setRun((r) =>
r
? {
...r,
animaGained: r.animaGained + base,
heroHp: Math.max(0, Math.min(maxHp, heroHp)),
}
: r
);
setRewardAnima(base);
setRewardOpen(true);
}
function takeReward() {
if (rewardClosing.current) return;
const box = document.querySelector("[data-reward-box]");
if (!box || prefersReducedMotion()) {
setRewardOpen(false);
advanceFloor();
return;
}
rewardClosing.current = true;
animate(box, {
opacity: [1, 0],
scale: [1, 0.94],
translateY: [0, -20],
duration: 220,
ease: "in(2)",
onComplete: () => {
rewardClosing.current = false;
setRewardOpen(false);
advanceFloor();
},
});
}
const hpBar = (hp: number, max: number) => (
<progress
class={`progress w-full ${
hp / max < 0.3 ? "progress-error" : "progress-success"
}`}
value={hp}
max={max}
/>
);
const inTown = [
"city",
"keep",
"arcana",
"deck",
"deckBuilder",
"pack",
"sanctum",
].includes(displayScreen);
const inDungeon = displayScreen === "map" || displayScreen === "combat";
const nav = (
<div class="navbar rivets min-h-16 border-b-0 px-3 sm:px-4">
<div class="navbar-start">
<span class="font-gothic tracking-[0.18em] text-accent text-lg sm:text-xl">
Profectus
</span>
</div>
<div class="navbar-center">
{inTown
? (
<div class="hero-bar flex items-center gap-2 sm:gap-3">
<div class="avatar">
<div class="shield-mount size-10 sm:size-12">
<img
src={klass.art}
alt=""
class={`shield mystic-class-icon mystic-class-${klass.id}`}
/>
</div>
</div>
<div class="min-w-0 pr-1">
<p class="font-display truncate text-sm tracking-[0.08em] text-accent sm:text-base">
{initialHero.name}
</p>
<p class="text-[0.5rem] uppercase tracking-[0.18em] text-base-content/60">
Level {level}
</p>
</div>
<span class="pillar-rule h-8" aria-hidden="true" />
<div class="hidden items-center gap-3 sm:flex">
<div class="text-center">
<p class="font-display text-sm text-primary">{maxHp}</p>
<p class="text-[0.45rem] uppercase tracking-wider text-base-content/55">
Vigor
</p>
</div>
<div class="text-center">
<p class="font-display text-sm text-secondary">
{heroMaxEnergy}
</p>
<p class="text-[0.45rem] uppercase tracking-wider text-base-content/55">
Energy
</p>
</div>
<div class="text-center">
<p class="font-display text-sm text-accent">{deck.length}</p>
<p class="text-[0.45rem] uppercase tracking-wider text-base-content/55">
Cards
</p>
</div>
</div>
</div>
)
: run && inDungeon && (
<span class="text-xs uppercase tracking-[0.3em] text-base-content/65">
{klass.name} · Floor {run.floorIdx + 1}/{MAP_FLOORS}
</span>
)}
</div>
<div class="navbar-end gap-2">
<span class="badge badge-sm anima-coin font-bold whitespace-nowrap">
{anima} ✦
</span>
{isAdmin && (
<a
href="/admin"
class="btn btn-ghost btn-sm hidden sm:inline-flex"
title="Administration"
>
Admin
</a>
)}
<a href="/logout" class="btn btn-ghost btn-sm hidden sm:inline-flex">
Log out
</a>
</div>
</div>
);
function renderScreen(current: Screen) {
if (current === "city") {
return (
<div class="h-dvh overflow-hidden flex flex-col">
{nav}
<main class="city-screen tapestry flex min-h-0 flex-1 flex-col px-3 py-2 sm:px-5 sm:py-3">
{message && (
<div
role="status"
class="alert alert-success alert-soft mx-auto max-w-md py-2 text-sm"
>
<span>{message}</span>
</div>
)}
<div class="cityscape stone-wall mx-auto min-h-0 w-full max-w-6xl flex-1">
<div class="city-title" aria-hidden="true">
<strong>Aetherkeep</strong>
<span class="city-title-rule" />
</div>
<button
type="button"
class="city-building city-keep"
onClick={() => setScreen("keep")}
aria-label="Enter Ascendant Keep"
>
<img src={keepArt} alt="" />
<span>Ascendant Keep</span>
{points.attribute > 0 && (
<i class="badge badge-secondary badge-sm pennant">
{points.attribute}
</i>
)}
</button>
<button
type="button"
class="city-building city-arcana"
onClick={() => setScreen("arcana")}
aria-label="Enter Arcana Hall"
>
<img src={archiveArt} alt="" />
<span>Arcana Hall</span>
</button>
<button
type="button"
class="city-building city-sanctum"
onClick={() => setScreen("sanctum")}
aria-label="Enter Moon Sanctum"
>
<img src={sanctumArt} alt="" />
<span>Moon Sanctum</span>
</button>
<button
type="button"
class="city-building city-gate"
onClick={startRun}
aria-label="Leave through the Dungeon Gate"
>
<img src={gateArt} alt="" />
<span>Dungeon Gate</span>
</button>
</div>
</main>
</div>
);
}
if (current === "keep") {
return (
<div class="h-dvh flex flex-col overflow-hidden">
{nav}
<ProgressionScreen
anima={anima}
level={level}
levelCost={levelCost}
progression={progression}
onLevelUp={levelUp}
onImproveVigor={improveVigor}
onLearnSkill={learnSkill}
onClose={() => setScreen("city")}
/>
</div>
);
}
if (current === "arcana") {
return (
<div class="h-dvh flex flex-col overflow-hidden">
{nav}
<ArcanaHallScreen
deckSize={deck.length}
collectionSize={Object.values(collection).reduce(
(total, quantity) => total + quantity,
0,
)}
onViewDeck={() => setScreen("deck")}
onBuildDeck={() => {
setFeatureError(null);
setScreen("deckBuilder");
}}
onBuyPack={() => {
setFeatureError(null);
setOpenedPack(null);
setScreen("pack");
}}
onClose={() => setScreen("city")}
/>
</div>
);
}
if (current === "deck") {
return (
<div class="h-dvh flex flex-col overflow-hidden">
{nav}
<DeckScreen deck={deck} onClose={() => setScreen("arcana")} />
</div>
);
}
if (current === "deckBuilder") {
return (
<div class="h-dvh flex flex-col overflow-hidden">
{nav}
<DeckBuilderScreen
deck={deck}
collection={collection}
saving={savingDeck}
error={featureError}
onSave={saveDeck}
onClose={() => setScreen("arcana")}
/>
</div>
);
}
if (current === "pack") {
return (
<div class="h-dvh flex flex-col overflow-hidden">
{nav}
<PackScreen
anima={anima}
opened={openedPack}
buying={buyingPack}
error={featureError}
onBuy={buyPack}
onClose={() => setScreen("arcana")}
/>
</div>
);
}
if (current === "sanctum") {
return (
<div class="h-dvh flex flex-col overflow-hidden">
{nav}
<SanctumScreen
deck={deck}
cost={upgradeCost}
affordable={anima >= upgradeCost}
onUpgrade={upgradeCard}
onClose={() => setScreen("city")}
/>
</div>
);
}
if (current === "map" && run) {
const floor = run.floors[run.floorIdx];
return (
<div class="tapestry min-h-screen flex flex-col">
{nav}
<main class="relative z-1 flex-1 flex flex-col items-center justify-center px-4 py-10">
<p class="text-xs uppercase tracking-[0.4em] text-base-content/60">
The branching path
</p>
<div class="fleuron-rule my-5 w-64">
<span aria-hidden="true">☾ ✦ ☽</span>
</div>
<div class="flex flex-wrap items-center justify-center gap-3 mb-8">
<span class="font-heading text-sm uppercase tracking-[0.2em] text-base-content/70">
Vitality
</span>
<div class="gauge-frame iron-band w-40">
{hpBar(run.heroHp, maxHp)}
</div>
<span class="text-sm text-accent font-bold">{run.heroHp}</span>
{run.animaGained > 0 && (
<span class="badge badge-secondary badge-sm pennant">
+{run.animaGained} anima this run
</span>
)}
</div>
<div class="grid gap-6 sm:grid-cols-2 max-w-2xl w-full">
{floor.map((node, i) => (
<button
type="button"
key={i}
class="path-choice arch-panel stone-wall card transition-all hover:-translate-y-1"
onClick={() => enterNode(node)}
>
<div class="corner-fleurons card-body items-center text-center py-8">
<span class="shield node-medallion" aria-hidden="true">
{NODE_GLYPH[node.kind]}
</span>
<h2 class="font-heading uppercase tracking-[0.25em] mt-2 text-accent">
{NODE_LABEL[node.kind]}
</h2>
<p class="text-xs text-base-content/70">
{node.kind === "fight" &&
"A card battle against dungeon denizens."}
{node.kind === "elite" &&
"A greater foe — greater spoils."}
{node.kind === "treasure" && "Anima waits in the dark."}
{node.kind === "rest" && "Restore your vitality."}
{node.kind === "boss" && "The capstone of this delve."}
</p>
</div>
</button>
))}
</div>
<button
type="button"
class="btn btn-ghost btn-sm mt-10 text-base-content/60"
onClick={() => endRun("retreat")}
>
Retreat to the city (keep all spoils)
</button>
</main>
</div>
);
}
if (current === "combat" && combat && run) {
const intentLabel = (e: CombatEnemy) => {
const move = e.moves[e.moveIdx % e.moves.length];
if (move.kind === "attack" && move.damage) {
const dmg = Math.max(
1,
Math.round((move.damage + e.strength) * (e.weak > 0 ? 0.75 : 1)),
);
return `${move.label} · ${dmg}${
(move.hits ?? 1) > 1 ? `×${move.hits}` : ""
}`;
}
return move.label;
};
return (
<div class="tapestry h-dvh overflow-hidden flex flex-col">
{nav}
<main class="flex-1 min-h-0 flex flex-col">
<div class="combat-effects" aria-hidden="true">
{combatEffects.map((effect) => (
<div
key={effect.id}
data-combat-effect={effect.id}
class={`combat-effect combat-effect-${effect.type} ${
effect.aoe ? "combat-effect-aoe" : ""
}`}
style={`left:${effect.sourceX}px;top:${effect.sourceY}px`}
>
<span />
<img src={effect.art} alt="" />
</div>
))}
</div>
<div class="flex-1 min-h-0 flex flex-col items-stretch gap-3 px-4 py-4">
<div class="battlefield flex min-w-0 min-h-0 flex-1 flex-col items-center justify-center gap-6 md:flex-row md:gap-0">
<span class="sconce left-8 top-1/3" aria-hidden="true" />
<span class="sconce right-8 top-1/3" aria-hidden="true" />
<div class="flex min-w-0 flex-1 justify-center md:justify-end md:pr-10">
<div
data-combat-hero
class="combat-hero flex flex-col items-center gap-2 w-52"
>
<span class="shield-mount w-24">
<img
src={klass.art}
alt=""
class={`shield mystic-class-icon mystic-class-${klass.id}`}
/>
</span>
<h3 class="font-heading text-sm uppercase tracking-[0.2em] text-accent">
{initialHero.name}
</h3>
<div class="gauge-frame iron-band w-full">
{hpBar(combat.heroHp, combat.heroMaxHp)}
</div>
<div class="status-badges flex flex-wrap content-start justify-center gap-1">
<span class="badge badge-sm badge-success">
{combat.heroHp}/{combat.heroMaxHp}
</span>
{combat.block > 0 && (
<span class="badge badge-sm badge-info">
🛡 {combat.block}
</span>
)}
{combat.strength > 0 && (
<span class="badge badge-sm badge-warning">
↑ {combat.strength}
</span>
)}
{combat.poison > 0 && (
<span class="badge badge-sm badge-secondary">
☠ {combat.poison}
</span>
)}
{combat.evade > 0 && (
<span class="badge badge-sm badge-ghost">
~ {combat.evade}
</span>
)}
{combat.retaliate > 0 && (
<span class="badge badge-sm badge-error">
⇄ {combat.retaliate}
</span>
)}
</div>
</div>
</div>
<div class="flex min-w-0 flex-1 justify-center md:justify-start md:pl-10">
<div class="combat-enemies flex flex-row flex-wrap justify-center gap-6">
{combat.enemies.map((e, i) => (
<button
type="button"
key={e.key}
data-enemy-index={i}
disabled={e.hp <= 0}
onClick={() => setTarget(i)}
class={`enemy-plate flex flex-col items-center gap-2 w-44 p-3 border transition-all ${
e.hp <= 0
? "opacity-30 border-base-content/10"
: i === target
? "enemy-plate-target"
: "border-base-content/25 hover:border-accent/60"
}`}
>
<img
src={e.art}
alt=""
class={`enemy-art h-16 w-16 ${
e.hp <= 0 ? "opacity-40" : ""
}`}
/>
<h4 class="font-heading text-xs uppercase tracking-[0.2em]">
{e.name}
</h4>
<div class="gauge-frame iron-band w-full">
{hpBar(e.hp, e.maxHp)}
</div>
<div class="status-badges flex flex-wrap content-start justify-center gap-1">
<span class="badge badge-sm badge-success">
{Math.max(0, e.hp)}/{e.maxHp}
</span>
{e.block > 0 && (
<span class="badge badge-sm badge-info">
🛡 {e.block}
</span>
)}
{e.poison > 0 && (
<span class="badge badge-sm badge-secondary">
☠ {e.poison}
</span>
)}
{e.weak > 0 && (
<span class="badge badge-sm badge-ghost">
↓ {e.weak}
</span>
)}
</div>
<span
class={`badge badge-sm badge-outline h-auto gap-1 border-accent/50 px-2 py-1 text-[0.6rem] text-accent/90 ${
e.hp <= 0 ? "invisible" : ""
}`}
>
<img
src={ENEMY_MOVE_VISUALS[
e.moves[e.moveIdx % e.moves.length].kind
].art}
alt=""
class="h-3.5 w-3.5"
/>
<span>{intentLabel(e)}</span>
</span>
</button>
))}
</div>
</div>
</div>
<aside class="chronicle shrink-0 mx-auto w-full max-w-3xl px-3 py-1.5">
<div class="flex items-center justify-between gap-3">
<h3 class="font-heading text-[0.6rem] uppercase tracking-[0.18em] text-accent">
Fight history
</h3>
<span class="text-[0.55rem] uppercase tracking-[0.16em] text-base-content/50">
Earlier → Latest
</span>
</div>
<details class="collapse collapse-arrow min-h-0">
<summary class="collapse-title min-h-0 px-0 py-1 text-[0.6rem] text-base-content/50">
{combat.log.length > 2
? `${combat.log.length - 2} earlier events`
: "No earlier events"}
</summary>
<div class="collapse-content overflow-x-auto px-0 pb-1">
<div class="flex min-w-max items-center gap-2">
{combat.log.slice(0, -2).map((entry, index) => (
<div
key={`${index}-${entry}`}
class="flex items-center gap-2"
>
<span class="badge badge-ghost badge-xs">
{index + 1}
</span>
<span class="max-w-52 text-[0.65rem] text-base-content/60">
{entry}
</span>
<span class="text-primary/45" aria-hidden="true">
→
</span>
</div>
))}
</div>
</div>
</details>
<div
class="chronicle-latest flex items-center justify-end gap-2 overflow-hidden py-1"
aria-live="polite"
>
{combat.log.slice(-2).map((entry, index) => {
const eventNumber = combat.log.length -
Math.min(2, combat.log.length) + index + 1;
return (
<div
key={`${eventNumber}-${entry}`}
class="flex min-w-0 items-center gap-2"
>
{index > 0 && (
<span
class="shrink-0 text-primary/45"
aria-hidden="true"
>
→
</span>
)}
<span class="badge badge-primary badge-xs shrink-0">
{eventNumber}
</span>
<span class="max-w-64 text-[0.65rem] leading-tight text-base-content/65">
{entry}
</span>
</div>
);
})}
</div>
</aside>
</div>
<div class="hand-rail rivets shrink-0 px-4 pt-3 pb-3">
<div class="flex items-center justify-between max-w-4xl mx-auto mb-1">
<div class="flex items-center gap-2">
<span
class="radial-progress text-accent text-xs"
style={`--value:${
Math.round(
(combat.energy / combat.maxEnergy) * 100,
)
}; --size:2.6rem; --thickness:3px`}
>
{combat.energy}
</span>
<span class="text-[0.65rem] uppercase tracking-[0.25em] text-base-content/65">
Energy · Turn {combat.turn}
</span>
</div>
<button
type="button"
class="btn btn-primary iron-band btn-sm tracking-[0.2em] uppercase font-heading"
disabled={combat.over !== null || enemyTurn}
onClick={endTurn}
>
{enemyTurn ? "Enemies act..." : "End turn"}
</button>
</div>
<div class="card-fan max-w-4xl mx-auto">
{combat.hand.map((card, i) => {
const mid = (combat.hand.length - 1) / 2;
const offset = i - mid;
return (
<div
key={card.uid}
data-card-uid={card.uid}
class="card-fan-flip"
>
<div
class="card-fan-slot combat-card-slot"
style={`--rot:${offset * 5}deg; --lift:${
Math.abs(offset) * 6
}px`}
>
<CardFace
id={card.def.id}
extraClass={cardDef(card.def.id).cost <= combat.energy
? ""
: "opacity-50"}
onClick={enemyTurn ? undefined : () => playCard(i)}
/>
</div>
</div>
);
})}
</div>
<div class="flex justify-center gap-6 mt-1 text-[0.6rem] uppercase tracking-[0.25em] text-base-content/55">
<span>Draw pile {combat.draw.length}</span>
<span>Discard pile {combat.discard.length}</span>
</div>
</div>
</main>
{rewardOpen && (
<dialog class="modal modal-open modal-middle reward-modal">
<div
data-reward-box
class="modal-box reward-scroll vellum w-full max-w-md text-center"
>
<p class="text-xs uppercase tracking-[0.4em] text-base-content/60">
Spoils of battle
</p>
<h3 class="illuminated font-gothic text-4xl tracking-[0.1em] text-accent mt-3">
+{rewardAnima} ANIMA
</h3>
<div class="fleuron-rule my-5">
<span aria-hidden="true">☾ ✦ ☽</span>
</div>
<p class="text-sm text-base-content/75">
Cards are won only from packs in the city. Onward.
</p>
<div class="modal-action justify-center">
<button
type="button"
class="btn btn-primary iron-band tracking-[0.2em] uppercase font-heading"
onClick={takeReward}
>
Continue
</button>
</div>
</div>
<div class="modal-backdrop" />
</dialog>
)}
</div>
);
}
if (current === "summary") {
return (
<div class="tapestry margin-vines min-h-screen flex flex-col">
{nav}
<main class="relative z-1 flex-1 flex flex-col items-center justify-center px-4 py-10">
<div class="arch-panel vellum w-full max-w-xl px-6 pb-8 flex flex-col items-center text-center">
<p class="text-xs uppercase tracking-[0.4em] text-base-content/60">
Return to the city
</p>
<h1 class="illuminated font-gothic text-4xl tracking-[0.1em] text-accent mt-3">
{outcome === "victory"
? "TRIUMPH"
: outcome === "defeat"
? "THE DARK CLAIMS YOU — FOR NOW"
: "A PRUDENT RETREAT"}
</h1>
<div class="fleuron-rule my-6 w-full">
<span aria-hidden="true">☾ ✦ ☽</span>
</div>
<p class="text-sm text-base-content/75 max-w-md text-center">
{outcome === "defeat"
? "No penalty, no loss. Your spoils are safe — grow stronger and return."
: "Your spoils are banked. The city always welcomes a returning hero."}
</p>
<button
type="button"
class="btn btn-primary iron-band mt-8 tracking-[0.2em] uppercase font-heading"
onClick={() => {
setScreen("city");
setOutcome("retreat");
}}
>
Enter the city
</button>
</div>
</main>
</div>
);
}
return null;
}
return (
<div
ref={stageRef}
class={`screen-stage${leaving ? " is-leaving" : ""}`}
>
{renderScreen(displayScreen)}
</div>
);
}