- Name your hero and choose your look. You'll pick a class later.
+ You'll name your hero and choose their class — and look — once the Convocation's
+ trials have shown you which mind fits.
diff --git a/src/components/assets/CroppedPortrait.tsx b/src/components/assets/CroppedPortrait.tsx
new file mode 100644
index 0000000..65cc4ad
--- /dev/null
+++ b/src/components/assets/CroppedPortrait.tsx
@@ -0,0 +1,63 @@
+import Image from "next/image";
+
+export interface PortraitCrop {
+ src: string;
+ naturalWidth: number;
+ naturalHeight: number;
+ /** Center of the desired crop (face/eyes), in the source image's native pixels. */
+ cropCenterX: number;
+ cropCenterY: number;
+ /** Width of the crop box, in native pixels — the portrait is scaled so this fills `size`. */
+ cropWidth: number;
+}
+
+export interface CroppedPortraitProps {
+ crop: PortraitCrop;
+ alt: string;
+ /** Rendered width and height, in pixels — the crop is always square. */
+ size: number;
+}
+
+/** Zooms into one region of a larger sprite, cropped to a square avatar of `size` px. Flip/mirroring and the `size`x`size` clipping box are the caller's job (wrap this in a fixed-size `overflow-hidden` container). */
+export function CroppedPortrait({ crop, alt, size }: CroppedPortraitProps) {
+ const scale = size / crop.cropWidth;
+ const displayWidth = Math.round(crop.naturalWidth * scale);
+ const displayHeight = Math.round(crop.naturalHeight * scale);
+ const left = size / 2 - crop.cropCenterX * scale;
+ const top = size / 2 - crop.cropCenterY * scale;
+
+ return (
+
+ );
+}
+
+/**
+ * The raw rig `Head.png` parts have no face baked in (eyes/mouth are a
+ * separate "Face" overlay with rig-specific offsets we don't have data for),
+ * so this crops into the already fully-composited flat preset image instead
+ * — the same art `PoseSprite`'s `pose="idle"` renders, just square-cropped
+ * for use as a small HUD avatar (a Wraith preset's native 520x420 aspect
+ * ratio would otherwise stretch a `rounded-full` frame into an oval).
+ */
+export function wraithPortraitCrop(presetId: string): PortraitCrop {
+ const match = presetId.match(/(\d+)$/);
+ if (!match) {
+ throw new Error(`Cannot derive a Wraith number from preset id "${presetId}"`);
+ }
+ const n = match[1].padStart(2, "0");
+ return {
+ src: `/assets/character_creation/presets/wraith-${n}.png`,
+ naturalWidth: 520,
+ naturalHeight: 420,
+ cropCenterX: 247,
+ cropCenterY: 150,
+ cropWidth: 260,
+ };
+}
diff --git a/src/components/assets/PoseSprite.tsx b/src/components/assets/PoseSprite.tsx
new file mode 100644
index 0000000..96dc750
--- /dev/null
+++ b/src/components/assets/PoseSprite.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import Image from "next/image";
+import { useEffect, useState } from "react";
+import { getSpritePreset } from "@/lib/assets";
+
+export interface PoseSpriteProps {
+ presetId: string;
+ /**
+ * "idle" shows the preset's flat chosen-look image; "walking" loops the
+ * walking frame sequence; "hurt"/"dying" play their own frame sequence
+ * once and hold on the last frame — combat resolution, see
+ * `ConvocationBattleStage` and `GuardianBattle`.
+ */
+ pose: "idle" | "walking" | "hurt" | "dying";
+ /** Rendered width in pixels; height follows the preset's own aspect ratio. Defaults to 128. */
+ size?: number;
+ className?: string;
+}
+
+type AnimatedPose = "walking" | "hurt" | "dying";
+
+const FRAME_DURATION_MS = 90;
+/** Poses that loop indefinitely rather than playing once and holding the last frame. */
+const LOOPING_POSES = new Set(["walking"]);
+
+interface PoseFamily {
+ frameCounts: Record;
+ frameSrc: (presetId: string, pose: AnimatedPose, frame: number) => string;
+}
+
+/**
+ * Derives the zero-padded Wraith number (e.g. "01") a preset id like
+ * "wraith-01" maps to, for building raw `PNG Sequences` frame paths.
+ */
+function wraithNumber(presetId: string): string {
+ const match = presetId.match(/(\d+)$/);
+ if (!match) {
+ throw new Error(`Cannot derive a Wraith number from preset id "${presetId}"`);
+ }
+ return match[1].padStart(2, "0");
+}
+
+const WRAITH_POSE_FOLDER: Record = { walking: "Walking", hurt: "Hurt", dying: "Dying" };
+/** The pack's raw frames label "Walking" as "Moving Forward" — folder name and filename label diverge only for this one pose. */
+const WRAITH_POSE_LABEL: Record = {
+ walking: "Moving Forward",
+ hurt: "Hurt",
+ dying: "Dying",
+};
+
+const wraithFamily: PoseFamily = {
+ frameCounts: { walking: 12, hurt: 12, dying: 15 },
+ frameSrc: (presetId, pose, frame) => {
+ const n = wraithNumber(presetId);
+ const folder = WRAITH_POSE_FOLDER[pose];
+ const label = WRAITH_POSE_LABEL[pose];
+ const frameNumber = String(frame).padStart(3, "0");
+ return `/assets/character_creation/PNG/Wraith_${n}/PNG Sequences/${folder}/Wraith_${n}_${label}_${frameNumber}.png`;
+ },
+};
+
+/** Matches a class preset's Idle image path, capturing the `PNG Sequences` base dir and the pack's own filename label (e.g. "Bloody_Alchemist"). */
+const CLASS_IDLE_SRC_PATTERN = /^(.*\/PNG Sequences)\/Idle\/0_(.+)_Idle_\d+\.png$/;
+
+/**
+ * The 7 class art packs (`public/assets/character_creation//...`,
+ * see `SPRITE_PRESETS`) share one naming convention, unlike the Wraith
+ * pack: folder name and filename label always match, and frame files are
+ * prefixed `0_`. The preset's own `imageSrc` (its Idle frame) already
+ * encodes the base dir and label, so it's parsed rather than duplicated
+ * into a second lookup table.
+ */
+const classFamily: PoseFamily = {
+ frameCounts: { walking: 24, hurt: 12, dying: 15 },
+ frameSrc: (presetId, pose, frame) => {
+ const preset = getSpritePreset(presetId);
+ const match = preset.imageSrc.match(CLASS_IDLE_SRC_PATTERN);
+ if (!match) {
+ throw new Error(`Preset "${presetId}" has no class-art Idle frame to derive pose paths from`);
+ }
+ const [, baseDir, label] = match;
+ const folder = pose === "walking" ? "Walking" : pose === "hurt" ? "Hurt" : "Dying";
+ const frameNumber = String(frame).padStart(3, "0");
+ return `${baseDir}/${folder}/0_${label}_${folder}_${frameNumber}.png`;
+ },
+};
+
+function familyFor(presetId: string): PoseFamily {
+ return presetId.startsWith("wraith-") ? wraithFamily : classFamily;
+}
+
+/** A non-looping pose (hurt/dying) starts on its last frame under reduced motion — the defeated end state, not a mid-flinch frame. */
+function initialFrame(pose: PoseSpriteProps["pose"], family: PoseFamily): number {
+ if (pose === "idle") return 0;
+ if (LOOPING_POSES.has(pose)) return 0;
+ const frameCount = family.frameCounts[pose];
+ const reduceMotion = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ return reduceMotion ? frameCount - 1 : 0;
+}
+
+/**
+ * Renders any `SPRITE_PRESETS` entry — Wraith or class — in a combat pose:
+ * `pose="idle"` shows the same flat preset image `CharacterSprite` renders
+ * elsewhere; the other poses cycle a raw frame sequence from the preset's
+ * own art pack. This is the single rendering surface for animated combat
+ * sprites — `ConvocationBattleStage` and `GuardianBattle` both go through
+ * it rather than resolving frame paths themselves.
+ *
+ * Loops "walking"; "hurt"/"dying" play once and hold their last frame.
+ * Under `prefers-reduced-motion`, "walking" holds its first frame and
+ * "hurt"/"dying" jump straight to their last (the meaningful end state — a
+ * defeated pose — rather than freezing mid-flinch).
+ */
+export function PoseSprite({ presetId, pose, size = 128, className }: PoseSpriteProps) {
+ const preset = getSpritePreset(presetId);
+ const family = familyFor(presetId);
+ const [frame, setFrame] = useState(() => initialFrame(pose, family));
+ // Reset the frame whenever `pose` changes, without a dedicated effect —
+ // React's documented pattern for adjusting state during render rather than
+ // via a setState-in-effect (see react-hooks/set-state-in-effect).
+ const [posedFor, setPosedFor] = useState(pose);
+ if (pose !== posedFor) {
+ setPosedFor(pose);
+ setFrame(initialFrame(pose, family));
+ }
+
+ const height = Math.round((size * preset.imageHeight) / preset.imageWidth);
+
+ useEffect(() => {
+ if (pose === "idle") return;
+ const frameCount = family.frameCounts[pose];
+ const loop = LOOPING_POSES.has(pose);
+
+ for (let i = 0; i < frameCount; i++) {
+ const image = new window.Image();
+ image.src = family.frameSrc(presetId, pose, i);
+ }
+
+ const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ if (reduceMotion) return;
+
+ const timer = window.setInterval(() => {
+ setFrame((current) => {
+ if (!loop && current >= frameCount - 1) return current;
+ return (current + 1) % frameCount;
+ });
+ }, FRAME_DURATION_MS);
+ return () => window.clearInterval(timer);
+ }, [pose, presetId, family]);
+
+ const src = pose === "idle" ? preset.imageSrc : family.frameSrc(presetId, pose, frame);
+
+ return (
+
+ );
+}
diff --git a/src/components/assets/index.ts b/src/components/assets/index.ts
index 195a8b5..fa9bda6 100644
--- a/src/components/assets/index.ts
+++ b/src/components/assets/index.ts
@@ -1,7 +1,11 @@
export { CharacterSprite } from "./CharacterSprite";
export type { CharacterSpriteProps } from "./CharacterSprite";
+export { CroppedPortrait, wraithPortraitCrop } from "./CroppedPortrait";
+export type { PortraitCrop, CroppedPortraitProps } from "./CroppedPortrait";
export { EnemySprite } from "./EnemySprite";
export type { EnemySpriteProps } from "./EnemySprite";
+export { PoseSprite } from "./PoseSprite";
+export type { PoseSpriteProps } from "./PoseSprite";
export { PresetPicker } from "./PresetPicker";
export type { PresetPickerProps } from "./PresetPicker";
export { SceneBackground } from "./SceneBackground";
diff --git a/src/components/character/ClassPicker.tsx b/src/components/character/ClassPicker.tsx
index c0f8d05..3d98b8e 100644
--- a/src/components/character/ClassPicker.tsx
+++ b/src/components/character/ClassPicker.tsx
@@ -1,3 +1,4 @@
+import { CharacterSprite } from "@/components/assets/CharacterSprite";
import {
CLASSES,
STARTING_STATS,
@@ -14,14 +15,18 @@ export interface ClassPickerProps {
/**
* Paged class picker: prev/next through all 7 classes, showing the selected
- * class's tagline, 5 stat bars, bound familiar, Ward, and full Lv1-100 spell
- * list with cost + description — per issue #3's acceptance criteria.
+ * class's tagline, 5 stat bars, Ward, and starting (Lv1) spell with cost +
+ * description.
*/
export function ClassPicker({ selectedIndex, onSelectIndex }: ClassPickerProps) {
const classDef = CLASSES[selectedIndex];
const stats = STARTING_STATS[classDef.id];
const maxMana = getStartingMana(stats);
const maxHp = getStartingHp(stats);
+ const startingSpell = classDef.spells.find((spell) => spell.level === 1);
+ if (!startingSpell) {
+ throw new Error(`Class "${classDef.id}" has no level-1 spell`);
+ }
function goTo(index: number) {
onSelectIndex((index + CLASSES.length) % CLASSES.length);
@@ -63,14 +68,11 @@ export function ClassPicker({ selectedIndex, onSelectIndex }: ClassPickerProps)
diff --git a/src/components/character/CreationWizard.tsx b/src/components/character/CreationWizard.tsx
index 89b2aa2..0fa599c 100644
--- a/src/components/character/CreationWizard.tsx
+++ b/src/components/character/CreationWizard.tsx
@@ -1,38 +1,32 @@
"use client";
-import { useState } from "react";
import { useRouter } from "next/navigation";
import { SPRITE_PRESETS } from "@/lib/assets";
import type { CharacterSpriteConfig } from "@/lib/assets";
import { saveCharacterDraft } from "@/lib/character";
import type { CharacterDraft } from "@/lib/character";
-import { SpriteCustomizer } from "./SpriteCustomizer";
-import { NameInput, NAME_MAX_LENGTH } from "./NameInput";
const DEFAULT_SPRITE: CharacterSpriteConfig = {
presetId: SPRITE_PRESETS[0].id,
};
/**
- * Orchestrates the name+appearance portion of Character Creation (issue
- * #3): both shown together on one page. Class selection is deferred to
- * just before the Threshold Guardian rather than happening here — so
- * confirming saves a `CharacterDraft` (`@/lib/character`), not a full
- * `CharacterRecord`, and moves straight on to the Convocation map. See
- * docs/adr/0002-character-creation.md.
+ * Character Creation is now just the gate into the Convocation — name and
+ * appearance both happen later, together, once the trials are behind the
+ * player: the class they choose comes with its own look, which becomes
+ * their hero's permanent skin through the Threshold Guardian (see
+ * `ConvocationClassChoice`). So there's nothing to fill in here; confirming
+ * just saves a placeholder `CharacterDraft` — an empty name and one of the
+ * tutorial-phase Wraith looks (only ever seen during the Convocation
+ * trials themselves) — and moves on. See docs/adr/0005.
*/
export function CreationWizard() {
const router = useRouter();
- const [sprite, setSprite] = useState(DEFAULT_SPRITE);
- const [name, setName] = useState("");
- const trimmedName = name.trim();
- const nameValid = trimmedName.length > 0 && trimmedName.length <= NAME_MAX_LENGTH;
-
- function confirmDraft() {
+ function begin() {
const draft: CharacterDraft = {
- name: trimmedName,
- sprite,
+ name: "",
+ sprite: DEFAULT_SPRITE,
createdAt: new Date().toISOString(),
};
saveCharacterDraft(draft);
@@ -40,26 +34,14 @@ export function CreationWizard() {
}
return (
-
-
-
-
-
-
-
Appearance
-
-
-
-
-
-
+
+
);
}
diff --git a/src/components/character/SpriteCustomizer.tsx b/src/components/character/SpriteCustomizer.tsx
deleted file mode 100644
index 355e47a..0000000
--- a/src/components/character/SpriteCustomizer.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { SPRITE_PRESETS } from "@/lib/assets";
-import type { CharacterSpriteConfig } from "@/lib/assets";
-import { CharacterSprite } from "@/components/assets/CharacterSprite";
-import { PresetPicker } from "@/components/assets/PresetPicker";
-
-export interface SpriteCustomizerProps {
- config: CharacterSpriteConfig;
- onChange: (config: CharacterSpriteConfig) => void;
-}
-
-/**
- * The "choose your look" step of Character Creation. Before a class is
- * chosen there's no class-specific art yet, so the player picks one of the
- * tutorial-phase presets from `public/assets/character_creation` — see
- * docs/adr/0005-character-creation-tutorial-sprite.md.
- */
-export function SpriteCustomizer({ config, onChange }: SpriteCustomizerProps) {
- return (
-
-
-
-
-
- onChange({ presetId: id })}
- />
-
-
- );
-}
diff --git a/src/components/character/index.ts b/src/components/character/index.ts
index c8848c1..d70225c 100644
--- a/src/components/character/index.ts
+++ b/src/components/character/index.ts
@@ -2,8 +2,6 @@ export { StatBar } from "./StatBar";
export type { StatBarProps } from "./StatBar";
export { NameInput, NAME_MAX_LENGTH } from "./NameInput";
export type { NameInputProps } from "./NameInput";
-export { SpriteCustomizer } from "./SpriteCustomizer";
-export type { SpriteCustomizerProps } from "./SpriteCustomizer";
export { ClassPicker } from "./ClassPicker";
export type { ClassPickerProps } from "./ClassPicker";
export { CreationWizard } from "./CreationWizard";
diff --git a/src/components/combat/CombatHud.tsx b/src/components/combat/CombatHud.tsx
new file mode 100644
index 0000000..3f6a1e4
--- /dev/null
+++ b/src/components/combat/CombatHud.tsx
@@ -0,0 +1,84 @@
+/** Rendered width/height of a `CombatHud` portrait circle, in pixels. */
+export const COMBAT_HUD_PORTRAIT_SIZE = 48;
+
+export interface CombatMeterSpec {
+ label: string;
+ /** Fill color, e.g. `"bg-emerald-400"`. */
+ tone: string;
+ /** Fraction (0-1) of the bar to fill. Ignored when `depleted` is set. */
+ fraction: number;
+ /** Drains the bar to empty (combat resolution) instead of showing `fraction`. */
+ depleted?: boolean;
+ /** Renders a `value/max` readout beside the label. */
+ showValue?: boolean;
+ value?: number;
+ max?: number;
+}
+
+function Meter({ label, tone, fraction, depleted = false, showValue = false, value, max }: CombatMeterSpec) {
+ const widthPercent = depleted ? 0 : Math.round(Math.min(1, Math.max(0, fraction)) * 100);
+ return (
+
+
+
{label}
+ {showValue && (
+
+ {value}/{max}
+
+ )}
+
+
+
+
+
+ );
+}
+
+export interface CombatHudProps {
+ /** Already-sized portrait element (`CroppedPortrait` or `PoseSprite`) — sprite-type selection stays the caller's job. */
+ portrait: React.ReactNode;
+ /** Text under the portrait: a level, a name, or a role label. */
+ caption: string;
+ /** Controls which side the portrait sits on at `sm` and up (`sm:flex-row-reverse`). */
+ align: "left" | "right";
+ /** Mirrors the portrait so both actors face each other. */
+ flip?: boolean;
+ meters: CombatMeterSpec[];
+ /** Exposed so a parent can measure this card's rendered edge (see `usePanelInsets`). */
+ hudRef?: React.Ref;
+}
+
+/**
+ * Shared actor HUD card — portrait, caption, and a meter column — used by
+ * every combat screen. Position (`fixed` to the viewport vs. `absolute`
+ * within a battle stage) and z-index stay the caller's job since screens
+ * differ in surrounding layout; this component only owns the card itself.
+ */
+export function CombatHud({ portrait, caption, align, flip = false, meters, hudRef }: CombatHudProps) {
+ return (
+
+
+
+ {portrait}
+
+
{caption}
+
+
+ {meters.map((meter) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/combat/Pill.tsx b/src/components/combat/Pill.tsx
new file mode 100644
index 0000000..7bf881a
--- /dev/null
+++ b/src/components/combat/Pill.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { createPortal } from "react-dom";
+
+export type PillTone = "neutral" | "emerald" | "cyan" | "amber" | "red";
+
+const TONE_CLASSES: Record = {
+ neutral: "border-white/20 bg-white/5 text-white/70",
+ emerald: "border-emerald-300/40 bg-emerald-950/30 text-emerald-200",
+ cyan: "border-cyan-300/50 bg-cyan-950/50 text-cyan-100",
+ amber: "border-amber-300/50 bg-amber-950/25 text-amber-100",
+ red: "border-red-400/50 bg-red-950/30 text-red-100",
+};
+
+export interface PillProps {
+ tone?: PillTone;
+ /** Hover tooltip content, portaled to `document.body` so a panel's `overflow-hidden` (needed for the liquid-glass edge) doesn't clip it. */
+ tooltip?: string;
+ children: React.ReactNode;
+}
+
+/** Shared rounded-pill tag — backs family/preview/status labels across every combat screen. */
+export function Pill({ tone = "neutral", tooltip, children }: PillProps) {
+ const ref = useRef(null);
+ const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null);
+
+ function showTooltip() {
+ if (!tooltip) return;
+ const rect = ref.current?.getBoundingClientRect();
+ if (rect) setTooltipPos({ top: rect.top, left: rect.left + rect.width / 2 });
+ }
+
+ return (
+ setTooltipPos(null)}
+ className={`shrink-0 whitespace-nowrap rounded-full border px-2.5 py-0.5 text-[9px] tracking-wide uppercase ${TONE_CLASSES[tone]}`}
+ >
+ {children}
+ {tooltipPos &&
+ typeof document !== "undefined" &&
+ createPortal(
+
+ {tooltip}
+ ,
+ document.body,
+ )}
+
+ );
+}
diff --git a/src/components/combat/PuzzlePanelHeader.tsx b/src/components/combat/PuzzlePanelHeader.tsx
new file mode 100644
index 0000000..660a27a
--- /dev/null
+++ b/src/components/combat/PuzzlePanelHeader.tsx
@@ -0,0 +1,73 @@
+export interface PuzzlePanelProps {
+ titleId: string;
+ /** Sizing overrides (`max-h-*`, `max-w-*`) — panels genuinely differ in how much content they hold. */
+ className?: string;
+ children: React.ReactNode;
+}
+
+/** Shared puzzle-panel chrome: glass card, rounded corners, dialog semantics, header/body grid. */
+export function PuzzlePanel({ titleId, className = "", children }: PuzzlePanelProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export interface PuzzlePanelHeaderProps {
+ titleId: string;
+ /** e.g. Guardian's "Turn N · Phase" — shown above the title. */
+ eyebrow?: string;
+ title: string;
+ /** Renders the pill Exit button only when provided — not every combat screen has a "leave early" action. */
+ onClose?: () => void;
+ closeDisabled?: boolean;
+ /** The family/preview/status pill row — compose `Pill` elements here. */
+ pills?: React.ReactNode;
+ hint?: string;
+}
+
+/** Shared puzzle-panel header: title row, optional Exit button, and a full-bleed pill strip. */
+export function PuzzlePanelHeader({
+ titleId,
+ eyebrow,
+ title,
+ onClose,
+ closeDisabled,
+ pills,
+ hint,
+}: PuzzlePanelHeaderProps) {
+ return (
+
+
+
+ {eyebrow &&
{eyebrow}
}
+
+ {title}
+
+
+ {onClose && (
+
+ )}
+
+ {pills && (
+
+ {pills}
+
+ )}
+ {hint &&
{hint}
}
+
+ );
+}
diff --git a/src/components/combat/index.ts b/src/components/combat/index.ts
new file mode 100644
index 0000000..4261b29
--- /dev/null
+++ b/src/components/combat/index.ts
@@ -0,0 +1,8 @@
+export { Pill } from "./Pill";
+export type { PillProps, PillTone } from "./Pill";
+export { CombatHud, COMBAT_HUD_PORTRAIT_SIZE } from "./CombatHud";
+export type { CombatHudProps, CombatMeterSpec } from "./CombatHud";
+export { usePanelInsets } from "./usePanelInsets";
+export type { PanelInsets } from "./usePanelInsets";
+export { PuzzlePanel, PuzzlePanelHeader } from "./PuzzlePanelHeader";
+export type { PuzzlePanelProps, PuzzlePanelHeaderProps } from "./PuzzlePanelHeader";
diff --git a/src/components/combat/usePanelInsets.ts b/src/components/combat/usePanelInsets.ts
new file mode 100644
index 0000000..89df615
--- /dev/null
+++ b/src/components/combat/usePanelInsets.ts
@@ -0,0 +1,54 @@
+"use client";
+
+import { useLayoutEffect, useState, type DependencyList, type RefObject } from "react";
+
+export interface PanelInsets {
+ left: number;
+ right: number;
+}
+
+/**
+ * Keeps a puzzle panel pinned `gapPx` from each HUD card's actual rendered
+ * edge — measured live via `ResizeObserver` rather than guessed from a
+ * hardcoded footprint constant, so it stays correct across breakpoints and
+ * content-driven width changes (e.g. a longer character name).
+ */
+export function usePanelInsets(
+ containerRef: RefObject,
+ leftHudRef: RefObject,
+ rightHudRef: RefObject,
+ gapPx: number,
+ deps: DependencyList,
+): PanelInsets {
+ const [insets, setInsets] = useState({ left: 0, right: 0 });
+
+ useLayoutEffect(() => {
+ const container = containerRef.current;
+ const leftHud = leftHudRef.current;
+ const rightHud = rightHudRef.current;
+ if (!container || !leftHud || !rightHud) return;
+
+ function measure() {
+ const containerRect = container!.getBoundingClientRect();
+ const leftRect = leftHud!.getBoundingClientRect();
+ const rightRect = rightHud!.getBoundingClientRect();
+ setInsets({
+ left: leftRect.right - containerRect.left + gapPx,
+ right: containerRect.right - rightRect.left + gapPx,
+ });
+ }
+
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(leftHud);
+ observer.observe(rightHud);
+ window.addEventListener("resize", measure);
+ return () => {
+ observer.disconnect();
+ window.removeEventListener("resize", measure);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `deps` is caller-supplied; this hook forwards it verbatim.
+ }, deps);
+
+ return insets;
+}
diff --git a/src/components/convocation/ConvocationBattleStage.tsx b/src/components/convocation/ConvocationBattleStage.tsx
index b5e81a7..1b3b413 100644
--- a/src/components/convocation/ConvocationBattleStage.tsx
+++ b/src/components/convocation/ConvocationBattleStage.tsx
@@ -2,10 +2,9 @@
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
-import { EnemySprite, SceneBackground } from "@/components/assets";
+import { EnemySprite, PoseSprite, SceneBackground } from "@/components/assets";
import { getBattlegroundForStop } from "@/lib/convocation/battleground";
import type { Outcome } from "@/lib/combat/types";
-import { WraithEntranceSprite } from "./WraithEntranceSprite";
export interface ConvocationBattleStageProps {
stopId: number;
@@ -25,7 +24,7 @@ export interface ConvocationBattleStageProps {
const ENTRANCE_DURATION_MS = 900;
const ACTOR_SIZE = 160;
-/** Matches the sprite components' own frame counts/pacing (`HURT_FRAME_COUNT` etc. in enemy-presets.ts and `POSE_SEQUENCE` in WraithEntranceSprite) so the hurt→dying handoff lands right as the hurt loop finishes. */
+/** Matches the sprite components' own frame counts/pacing (`HURT_FRAME_COUNT` etc. in enemy-presets.ts and the Wraith pose family in `PoseSprite`) so the hurt→dying handoff lands right as the hurt loop finishes. */
const HURT_FRAME_COUNT = 12;
const POSE_FRAME_DURATION_MS = 90;
const HURT_DURATION_MS = HURT_FRAME_COUNT * POSE_FRAME_DURATION_MS;
@@ -87,7 +86,7 @@ export function ConvocationBattleStage({
// Tracks which side `defeatPose` was last set for, so a fresh `outcome`
// (or reduced motion jumping straight to "dying") can be applied during
// render rather than via a synchronous setState-in-effect — see the same
- // pattern in `WraithEntranceSprite`/`EnemySprite`'s `posedFor`.
+ // pattern in `PoseSprite`/`EnemySprite`'s `posedFor`.
const [resolvedFor, setResolvedFor] = useState<"left" | "right" | null>(null);
useEffect(() => {
@@ -130,7 +129,7 @@ export function ConvocationBattleStage({
-
+
diff --git a/src/components/convocation/ConvocationClassChoice.tsx b/src/components/convocation/ConvocationClassChoice.tsx
index 14e8681..579c56f 100644
--- a/src/components/convocation/ConvocationClassChoice.tsx
+++ b/src/components/convocation/ConvocationClassChoice.tsx
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
-import { ClassPicker } from "@/components/character";
+import { ClassPicker, NameInput, NAME_MAX_LENGTH } from "@/components/character";
import {
CLASSES,
clearCharacterDraft,
@@ -17,19 +17,32 @@ import { applyXpGain } from "@/lib/xp";
export function ConvocationClassChoice({ totalXp }: { totalXp: number }) {
const router = useRouter();
const [selectedIndex, setSelectedIndex] = useState(0);
+ const [name, setName] = useState(() => getCharacterDraft()?.name ?? "");
const [error, setError] = useState(null);
+ const trimmedName = name.trim();
+ const nameValid = trimmedName.length > 0 && trimmedName.length <= NAME_MAX_LENGTH;
+
function confirmClass() {
const draft = getCharacterDraft();
if (!draft) {
setError("Your hero draft is missing. Return to character creation to continue.");
return;
}
+ if (!nameValid) {
+ setError("Name your hero before binding a familiar.");
+ return;
+ }
const classId = CLASSES[selectedIndex].id;
const leveled = applyXpGain({ level: 1, xp: 0 }, totalXp, classId);
const record: CharacterRecord = {
...draft,
+ name: trimmedName,
+ // The class's own preset becomes the hero's permanent look from here
+ // on — supersedes the tutorial-phase Wraith look picked (invisibly)
+ // for the Convocation trials. See `SPRITE_PRESETS` in `@/lib/assets`.
+ sprite: { presetId: classId },
classId,
stats: leveled.stats,
startingHp: getStartingHp(leveled.stats),
@@ -52,11 +65,14 @@ export function ConvocationClassChoice({ totalXp }: { totalXp: number }) {
Bind your familiar
- The eight trials are behind you. Choose the class whose strengths match the mind you
- want to carry through the final gate.
+ The eight trials are behind you. Name your hero, then choose the class whose
+ strengths match the mind you want to carry through the final gate — that class’s own
+ look becomes your hero’s, all the way to the Threshold Guardian.
+
+
{error &&
{error}
}
@@ -64,7 +80,8 @@ export function ConvocationClassChoice({ totalXp }: { totalXp: number }) {
diff --git a/src/components/convocation/ConvocationEncounter.tsx b/src/components/convocation/ConvocationEncounter.tsx
index 86589cd..9623842 100644
--- a/src/components/convocation/ConvocationEncounter.tsx
+++ b/src/components/convocation/ConvocationEncounter.tsx
@@ -1,11 +1,10 @@
"use client";
-import { useRef, useState } from "react";
-import { createPortal } from "react-dom";
+import { useState } from "react";
import type { ConvocationStop } from "@/lib/convocation/stops";
import type { ConvocationCastResponse } from "@/lib/convocation/encounter";
import type { Outcome } from "@/lib/combat/types";
-import { HUD_FOOTPRINT_PX } from "./ConvocationHud";
+import { Pill, PuzzlePanel, PuzzlePanelHeader, type PanelInsets } from "@/components/combat";
interface ConvocationEncounterProps {
stop: ConvocationStop;
@@ -13,6 +12,8 @@ interface ConvocationEncounterProps {
onClose: () => void;
/** Fires the moment the Judge's outcome is known, before the player reads it — drives the sprites' combat reaction (see `ConvocationBattleStage`) and the HUD's health drain. */
onResolved?: (outcome: Outcome) => void;
+ /** Live-measured clearance from each `ConvocationHud` card — see `usePanelInsets`, computed by `ConvocationMap`. */
+ insets: PanelInsets;
}
const OUTCOME_COPY: Record = {
@@ -30,64 +31,12 @@ const OUTCOME_TONE: Record = {
/** Tutorial-phase prompts are capped short — the Convocation is a diagnostic, not a full cast. */
const WORD_LIMIT = 12;
-/** Clearance the puzzle panel keeps from each `ConvocationHud` block. */
-const PANEL_HUD_GAP_PX = 3;
-const PANEL_INSET_STYLE = {
- "--panel-inset-x": `${HUD_FOOTPRINT_PX.base + PANEL_HUD_GAP_PX}px`,
- "--panel-inset-x-sm": `${HUD_FOOTPRINT_PX.sm + PANEL_HUD_GAP_PX}px`,
-} as React.CSSProperties;
-
function countWords(text: string): number {
const trimmed = text.trim();
return trimmed === "" ? 0 : trimmed.split(/\s+/).length;
}
-interface FamilyPillProps {
- family: string;
- probeReveal: string;
-}
-
-/**
- * The puzzle-type pill, with the probe hint shown on hover. Portaled to
- * `document.body` rather than positioned as a normal absolute descendant —
- * the panel's `overflow-hidden` (needed for the liquid-glass edge) would
- * otherwise clip a tooltip that pops out above the pill.
- */
-function FamilyPill({ family, probeReveal }: FamilyPillProps) {
- const ref = useRef(null);
- const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null);
-
- function showTooltip() {
- const rect = ref.current?.getBoundingClientRect();
- if (rect) {
- setTooltipPos({ top: rect.top, left: rect.left + rect.width / 2 });
- }
- }
-
- return (
- setTooltipPos(null)}
- className="shrink-0 whitespace-nowrap rounded-full border border-emerald-300/40 bg-emerald-950/30 px-2.5 py-0.5 text-[9px] tracking-wide text-emerald-200 uppercase"
- >
- {family}
- {tooltipPos &&
- typeof document !== "undefined" &&
- createPortal(
-
- {probeReveal}
- ,
- document.body,
- )}
-
- );
-}
-
-interface JudgeDialogProps {
+interface JudgeAnalysisProps {
loading: boolean;
error: string | null;
result: ConvocationCastResponse | null;
@@ -95,32 +44,30 @@ interface JudgeDialogProps {
}
/**
- * A second dialog, stacked below the puzzle panel, that owns the cast's
- * outcome — separate from the panel (which only ever shows the puzzle
- * itself) so the panel doesn't grow/shrink as a cast resolves. Walks
- * loading -> error | result; renders nothing before the first cast.
+ * The cast's outcome, rendered inline in the puzzle panel's body (swapped
+ * in for the puzzle text via the panel's view toggle) rather than as its
+ * own stacked container — keeps a single glass panel on screen instead of
+ * two, which is most of the vertical space this saves. Walks loading ->
+ * error | result.
*/
-function JudgeDialog({ loading, error, result, onContinue }: JudgeDialogProps) {
+function JudgeAnalysis({ loading, error, result, onContinue }: JudgeAnalysisProps) {
return (
-
+
diff --git a/src/components/convocation/ConvocationHud.tsx b/src/components/convocation/ConvocationHud.tsx
index 4e38a79..47667a2 100644
--- a/src/components/convocation/ConvocationHud.tsx
+++ b/src/components/convocation/ConvocationHud.tsx
@@ -1,4 +1,5 @@
-import Image from "next/image";
+import { CroppedPortrait, wraithPortraitCrop, type PortraitCrop } from "@/components/assets";
+import { CombatHud, COMBAT_HUD_PORTRAIT_SIZE, type CombatMeterSpec } from "@/components/combat";
import type { Outcome } from "@/lib/combat/types";
export interface ConvocationHudProps {
@@ -6,79 +7,21 @@ export interface ConvocationHudProps {
enemyPresetId: string;
/** The Judge's resolved outcome for the current cast, once known — drains the losing side's Health meter. */
outcome: Outcome | null;
-}
-
-interface MeterProps {
- label: string;
- tone: string;
- /** Drains the bar to empty (combat resolution) instead of showing it full. */
- depleted?: boolean;
-}
-
-function Meter({ label, tone, depleted = false }: MeterProps) {
- return (
-
-
{label}
-
-
-
-
- );
-}
-
-const PORTRAIT_SIZE = 48;
-
-/**
- * Rendered width of one `ActorHud` block, corner offset included.
- *
- * Below `sm`, the block stacks the portrait above the meter column (see
- * `ActorHud`) so its width is just the wider of the two — portrait
- * (`PORTRAIT_SIZE`) vs. the `Meter` column's `min-w-16` — plus padding and
- * the block's `left`/`right` corner offset. At `sm` and up it goes back to
- * a single row: portrait + `gap-3` + `min-w-36` meter column + padding +
- * offset. Exported so `ConvocationEncounter` can keep the puzzle panel
- * clear of both HUD blocks without guessing at their size.
- */
-export const HUD_FOOTPRINT_PX = {
- base: 12 /* left-3 */ + Math.max(48 /* portrait */, 64) /* min-w-16 */ + 8 * 2 /* px-2 */,
- sm: 24 /* sm:left-6 */ + (48 /* portrait */ + 12 /* gap-3 */ + 144) /* sm:min-w-36 */ + 16 * 2 /* sm:px-4 */,
-} as const;
-
-interface PortraitCrop {
- src: string;
- naturalWidth: number;
- naturalHeight: number;
- /** Center of the desired crop (face/eyes), in the source image's native pixels. */
- cropCenterX: number;
- cropCenterY: number;
- /** Width of the crop box, in native pixels — the portrait is scaled so this fills PORTRAIT_SIZE. */
- cropWidth: number;
+ /** Fraction (0-1) of the way to level 1 — see the doc comment on `ConvocationHud` below. */
+ xpFraction: number;
+ /** Exposed so `ConvocationMap` can measure each HUD card's rendered edge for `usePanelInsets`. */
+ leftHudRef?: React.Ref;
+ rightHudRef?: React.Ref;
}
/**
* The raw rig `Head.png` parts have no face baked in (eyes/mouth are a
* separate "Face" overlay with rig-specific offsets we don't have data for),
* so the portrait crops instead zoom into the already fully-composited art:
- * the Wraith's flat preset image, and the Minotaur's first Idle frame.
+ * the Wraith's flat preset image (`wraithPortraitCrop`, shared with
+ * `GuardianBattle`'s Guardian portrait since it's also a Wraith preset), and
+ * the Minotaur's first Idle frame.
*/
-function wraithPortrait(presetId: string): PortraitCrop {
- const match = presetId.match(/(\d+)$/);
- if (!match) {
- throw new Error(`Cannot derive a Wraith number from preset id "${presetId}"`);
- }
- const n = match[1].padStart(2, "0");
- return {
- src: `/assets/character_creation/presets/wraith-${n}.png`,
- naturalWidth: 520,
- naturalHeight: 420,
- cropCenterX: 247,
- cropCenterY: 150,
- cropWidth: 260,
- };
-}
-
function minotaurPortrait(presetId: string): PortraitCrop {
const match = presetId.match(/(\d+)$/);
if (!match) {
@@ -94,76 +37,15 @@ function minotaurPortrait(presetId: string): PortraitCrop {
};
}
-/** Zooms into one region of a larger sprite, cropped to a square avatar. Flip is applied by the caller, to the wrapper. */
-function Portrait({ crop, alt }: { crop: PortraitCrop; alt: string }) {
- const scale = PORTRAIT_SIZE / crop.cropWidth;
- const displayWidth = Math.round(crop.naturalWidth * scale);
- const displayHeight = Math.round(crop.naturalHeight * scale);
- const left = PORTRAIT_SIZE / 2 - crop.cropCenterX * scale;
- const top = PORTRAIT_SIZE / 2 - crop.cropCenterY * scale;
-
- return (
-
- );
-}
-
-interface ActorHudProps {
- crop: PortraitCrop;
- headAlt: string;
- align: "left" | "right";
- /** Mirrors the portrait so both actors visually face each other, matching the battle stage. */
- flip?: boolean;
- /** Drains this actor's Health meter (combat resolution). */
- defeated?: boolean;
-}
-
-/**
- * Below `sm` the meter column sits under the portrait instead of beside it
- * — a row layout at phone widths would sum portrait + meter widths and
- * leave the puzzle panel (pinned between the two `ActorHud` blocks, see
- * `HUD_FOOTPRINT_PX`) too narrow to hold anything. Stacking caps the
- * block's width at whichever of the two is wider.
- */
-function ActorHud({ crop, headAlt, align, flip = false, defeated = false }: ActorHudProps) {
- return (
-
-
-
-
-
-
Lv. 0
-
-
-
-
-
-
- );
-}
-
/**
* Decorative health/mana HUD for both combatants, pinned to the top corners
- * of the Convocation battle stage — one block per actor (player top-left,
- * Minotaur top-right, mirroring their entrance sides) with a head portrait
- * so it's unambiguous whose bars are whose, and a "Lv. 0" placeholder since
- * no level system exists yet at this point in onboarding. Static full bars
- * only, not wired to any real value — no character stats exist yet either
- * (`CharacterDraft` has no HP or mana; those only exist on `CharacterRecord`,
- * created after class choice).
+ * of the Convocation battle stage — one `CombatHud` card per actor (player
+ * top-left, Minotaur top-right, mirroring their entrance sides) with a head
+ * portrait so it's unambiguous whose bars are whose, and a "Lv. 0"
+ * placeholder since no level system exists yet at this point in onboarding.
+ * Static full bars only, not wired to any real value — no character stats
+ * exist yet either (`CharacterDraft` has no HP or mana; those only exist on
+ * `CharacterRecord`, created after class choice).
*
* The Minotaur portrait is mirrored so both actors face each other, same as
* on the battle stage (`ConvocationBattleStage` mirrors the Minotaur sprite
@@ -173,28 +55,68 @@ function ActorHud({ crop, headAlt, align, flip = false, defeated = false }: Acto
* meter (the player's cast connected), a "miss"/"fail" drains the Wraith's
* (mirroring `ConvocationBattleStage`'s hurt/dying sprite reaction — this is
* the same event, just reflected in the HUD).
+ *
+ * `xpFraction` fills the player's third meter — during the Convocation
+ * tutorial this is `completedThrough / CONVOCATION_STOPS.length`, so it
+ * fills by 1/8 per stop and reaches full (level 1) exactly when the last
+ * tutorial stop is done, right before the boss battle. The Minotaur's third
+ * meter is a static full bar, same treatment as its Health/Mana — the
+ * player's `CombatHud` is the standard layout every combatant's HUD follows,
+ * not a special case.
*/
-export function ConvocationHud({ playerSpritePresetId, enemyPresetId, outcome }: ConvocationHudProps) {
+export function ConvocationHud({
+ playerSpritePresetId,
+ enemyPresetId,
+ outcome,
+ xpFraction,
+ leftHudRef,
+ rightHudRef,
+}: ConvocationHudProps) {
const playerDefeated = outcome === "miss" || outcome === "fail";
const enemyDefeated = outcome === "hit";
+ const playerMeters: CombatMeterSpec[] = [
+ { label: "Health", tone: "bg-emerald-400", fraction: 1, depleted: playerDefeated },
+ { label: "Mana", tone: "bg-cyan-300", fraction: 1 },
+ { label: "XP", tone: "bg-amber-300", fraction: xpFraction },
+ ];
+ const enemyMeters: CombatMeterSpec[] = [
+ { label: "Health", tone: "bg-emerald-400", fraction: 1, depleted: enemyDefeated },
+ { label: "Mana", tone: "bg-cyan-300", fraction: 1 },
+ { label: "XP", tone: "bg-amber-300", fraction: 1 },
+ ];
+
return (
<>
-
{battle.phase === "victory" ? "The shield goes dark." : "The gate rejects the run."}
+
+ {battle.phase === "victory"
+ ? `The Guardian falls. ${battle.totalXpGained} XP was earned in the fight.`
+ : "Begin a fresh generated run to challenge the gate again."}
+