364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
|
|
import { BOSS_DEFINITIONS } from "../../game/bossCatalog";
|
|
import { HEALER_ABILITIES } from "../../game/healers";
|
|
import type { AbilitySlotId, ActiveCast, HealerAbilityId, MemberId, PartyMember } from "../../game/types";
|
|
import type {
|
|
PartyDraftCandidate,
|
|
PartyRosterMember,
|
|
RewardChoice,
|
|
RpgRoguelikeAction,
|
|
RpgRoguelikeRunState,
|
|
RunGearItem,
|
|
} from "../../game/rpgRoguelike";
|
|
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT, compareRunGear } from "../../game/rpgRoguelike";
|
|
import type { RpgUiCommand } from "../../game/rpgRoguelike";
|
|
import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
|
|
import { PartyRoleBadge } from "./PartyRoleBadge";
|
|
|
|
export interface RpgRunUiProps {
|
|
readonly run: RpgRoguelikeRunState;
|
|
readonly focusedId?: string | null;
|
|
readonly onFocusChange?: (focusId: string) => void;
|
|
readonly onAction: (action: RpgRoguelikeAction) => void;
|
|
readonly onRestartRun?: () => void;
|
|
readonly onExitRun?: () => void;
|
|
readonly className?: string;
|
|
readonly liveCombat?: RpgLiveCombatUi;
|
|
}
|
|
|
|
export interface RpgLiveCombatUi {
|
|
readonly party: readonly PartyMember[];
|
|
readonly selectedMemberId: MemberId;
|
|
readonly mana: number;
|
|
readonly maxMana: number;
|
|
readonly cooldowns: Readonly<Record<AbilitySlotId, number>>;
|
|
readonly globalCooldownUntil: number;
|
|
readonly time: number;
|
|
readonly activeCast: ActiveCast | null;
|
|
readonly spellResources: {
|
|
readonly verdancy: number;
|
|
readonly tidalSurge: number;
|
|
readonly conviction: number;
|
|
readonly chronoshards: number;
|
|
};
|
|
readonly onSelectMember?: (memberId: MemberId) => void;
|
|
readonly onCastAbility?: (abilitySlotId: AbilitySlotId) => void;
|
|
}
|
|
|
|
export interface RpgRunUiContext extends RpgRunUiProps {
|
|
readonly activeFocusId: string | null;
|
|
}
|
|
|
|
export function createUiContext(props: RpgRunUiProps): RpgRunUiContext {
|
|
return { ...props, activeFocusId: normalizeRpgFocusId(props.run, props.focusedId) };
|
|
}
|
|
|
|
export function runAccentStyle(accent: string): CSSProperties {
|
|
return { "--rpg-accent": accent } as CSSProperties;
|
|
}
|
|
|
|
export function executeUiCommand(context: RpgRunUiContext, command: RpgUiCommand): void {
|
|
if (command.type === "run-action") context.onAction(command.action);
|
|
else if (command.type === "restart-run") context.onRestartRun?.();
|
|
else context.onExitRun?.();
|
|
}
|
|
|
|
interface FocusButtonProps {
|
|
readonly context: RpgRunUiContext;
|
|
readonly focusId: string;
|
|
readonly command: RpgUiCommand;
|
|
readonly className?: string;
|
|
readonly disabled?: boolean;
|
|
readonly pressed?: boolean;
|
|
readonly style?: CSSProperties;
|
|
readonly children: ReactNode;
|
|
readonly label?: string;
|
|
}
|
|
|
|
export function FocusButton({
|
|
context,
|
|
focusId,
|
|
command,
|
|
className = "",
|
|
disabled = false,
|
|
pressed,
|
|
style,
|
|
children,
|
|
label,
|
|
}: FocusButtonProps) {
|
|
const selected = context.activeFocusId === focusId;
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
useEffect(() => {
|
|
const button = buttonRef.current;
|
|
if (!selected || !button) return;
|
|
let parent = button.parentElement;
|
|
while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) {
|
|
const childRect = button.getBoundingClientRect();
|
|
const parentRect = parent.getBoundingClientRect();
|
|
const overflow = getComputedStyle(parent);
|
|
const canScrollY = /^(auto|scroll|overlay)$/.test(overflow.overflowY);
|
|
const canScrollX = /^(auto|scroll|overlay)$/.test(overflow.overflowX);
|
|
if (canScrollY && parent.scrollHeight > parent.clientHeight) {
|
|
if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top;
|
|
else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom;
|
|
}
|
|
if (canScrollX && parent.scrollWidth > parent.clientWidth) {
|
|
if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left;
|
|
else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right;
|
|
}
|
|
parent = parent.parentElement;
|
|
}
|
|
}, [selected]);
|
|
return (
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
className={`${className} ${selected ? "is-controller-selected" : ""}`.trim()}
|
|
data-rpg-focus={focusId}
|
|
aria-current={selected ? "true" : undefined}
|
|
aria-pressed={pressed}
|
|
aria-label={label}
|
|
disabled={disabled}
|
|
style={style}
|
|
onFocus={() => context.onFocusChange?.(focusId)}
|
|
onPointerEnter={() => !disabled && context.onFocusChange?.(focusId)}
|
|
onClick={() => executeUiCommand(context, command)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function phaseLabel(run: RpgRoguelikeRunState): string {
|
|
switch (run.phase) {
|
|
case "party-draft": return "Party Draft";
|
|
case "spell-draft": return "Spell Draft";
|
|
case "challenge-briefing": return "Hallway Challenge";
|
|
case "challenge-active": return "Challenge Active";
|
|
case "boss-briefing": return run.bossIndex === TOTAL_BOSS_COUNT - 1 ? "Final Boss" : "Boss Door";
|
|
case "boss-combat": return run.bossIndex === TOTAL_BOSS_COUNT - 1 ? "Final Battle" : "Boss Battle";
|
|
case "boss-cleared": return "Room Cleared";
|
|
case "reward": return "Reward Chest";
|
|
case "shop": return `Act ${run.shop?.act ?? 1} Intermission`;
|
|
case "victory": return "Run Complete";
|
|
case "defeat": return "Party Fallen";
|
|
}
|
|
}
|
|
|
|
export function routeLabel(run: RpgRoguelikeRunState): string {
|
|
if (run.bossIndex >= TOTAL_BOSS_COUNT - 1) return "Finale";
|
|
return `Act ${Math.floor(run.bossIndex / BOSSES_PER_ACT) + 1} · Room ${(run.bossIndex % BOSSES_PER_ACT) + 1}`;
|
|
}
|
|
|
|
export function currentBoss(run: RpgRoguelikeRunState) {
|
|
const bossId = run.bossRoute[run.bossIndex];
|
|
return bossId ? BOSS_DEFINITIONS[bossId] : null;
|
|
}
|
|
|
|
export function RunHeader({ run, compact = false }: { run: RpgRoguelikeRunState; compact?: boolean }) {
|
|
return (
|
|
<header className={`rpg-run-header ${compact ? "is-compact" : ""}`.trim()}>
|
|
<div>
|
|
<small>{routeLabel(run)}</small>
|
|
<strong>{phaseLabel(run)}</strong>
|
|
</div>
|
|
<div className="rpg-run-resources" aria-label={`${run.currency} gold, ${Math.ceil(run.playerHp)} healer health, ${run.bossesDefeated} bosses defeated`}>
|
|
<span><i>◆</i>{run.currency}</span>
|
|
<span><i>♥</i>{Math.ceil(run.playerHp)}</span>
|
|
<span><i>♛</i>{run.bossesDefeated}/{TOTAL_BOSS_COUNT}</span>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
export function RoutePips({ run }: { run: RpgRoguelikeRunState }) {
|
|
return (
|
|
<div className="rpg-route-pips" aria-label={`${run.bossesDefeated} of ${TOTAL_BOSS_COUNT} bosses defeated`}>
|
|
{run.bossRoute.map((bossId, index) => (
|
|
<i
|
|
key={`${bossId}-${index}`}
|
|
className={`${index < run.bossesDefeated ? "is-cleared" : ""} ${index === run.bossIndex ? "is-current" : ""} ${index === TOTAL_BOSS_COUNT - 1 ? "is-finale" : ""}`.trim()}
|
|
title={BOSS_DEFINITIONS[bossId].name}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function titleCase(value: string): string {
|
|
return value.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
}
|
|
|
|
export function PartyCard({
|
|
member,
|
|
candidate,
|
|
selected = false,
|
|
compact = false,
|
|
action,
|
|
}: {
|
|
readonly member?: PartyRosterMember;
|
|
readonly candidate?: PartyDraftCandidate;
|
|
readonly selected?: boolean;
|
|
readonly compact?: boolean;
|
|
readonly action?: ReactNode;
|
|
}) {
|
|
const entry = member ?? candidate;
|
|
if (!entry) return null;
|
|
const hp = member?.hp ?? entry.stats.maxHp;
|
|
const hpPercent = Math.max(0, Math.min(100, (hp / entry.stats.maxHp) * 100));
|
|
return (
|
|
<article
|
|
className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
|
|
style={runAccentStyle(entry.color)}
|
|
>
|
|
<div className="rpg-card-kicker"><span>{entry.rarity}</span><PartyRoleBadge role={entry.role} /></div>
|
|
<h3>{entry.name}</h3>
|
|
<p>{entry.className}</p>
|
|
{!compact && (
|
|
<div className="rpg-stat-row" aria-label="Combat stats">
|
|
<span><small>HP</small>{entry.stats.maxHp}</span>
|
|
<span><small>ST</small>{entry.stats.singleTarget.toFixed(2)}</span>
|
|
<span><small>AOE</small>{entry.stats.areaDamage.toFixed(2)}</span>
|
|
<span><small>DEF</small>{entry.stats.defense.toFixed(2)}</span>
|
|
</div>
|
|
)}
|
|
{member && (
|
|
<div className="rpg-vital-bar" aria-label={`${Math.round(hp)} of ${entry.stats.maxHp} health`}>
|
|
<i style={{ width: `${hpPercent}%` }} />
|
|
<small>{hp <= 0 ? "Fallen" : `${Math.ceil(hp)} / ${entry.stats.maxHp}`}</small>
|
|
</div>
|
|
)}
|
|
{!compact && entry.traitIds.length > 0 && <footer>{entry.traitIds.map(titleCase).join(" · ")}</footer>}
|
|
{action}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
export function SpellCard({
|
|
spellId,
|
|
rank = 0,
|
|
selected = false,
|
|
compact = false,
|
|
action,
|
|
}: {
|
|
readonly spellId: HealerAbilityId;
|
|
readonly rank?: number;
|
|
readonly selected?: boolean;
|
|
readonly compact?: boolean;
|
|
readonly action?: ReactNode;
|
|
}) {
|
|
const spell = HEALER_ABILITIES[spellId];
|
|
return (
|
|
<article
|
|
className={`rpg-spell-card ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
|
|
style={runAccentStyle(spell.color)}
|
|
>
|
|
<i className="rpg-spell-icon">{spell.icon}</i>
|
|
<div>
|
|
<small>{spell.targeting} · {spell.mana} mana{rank > 0 ? ` · Rank ${rank}` : ""}</small>
|
|
<h3>{spell.name}</h3>
|
|
{!compact && <p>{spell.description}</p>}
|
|
</div>
|
|
{action}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): string {
|
|
if (item.ownerId === "player") return "Healer";
|
|
return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion";
|
|
}
|
|
|
|
export function gearComparisonLabel(run: RpgRoguelikeRunState, item: RunGearItem): string {
|
|
const comparison = compareRunGear(run.equipment, item);
|
|
const ownerName = gearOwnerName(run, item);
|
|
const currentRank = comparison.currentItem ? `plus ${comparison.currentItem.enhancement}` : "none";
|
|
const change = comparison.delta >= 0
|
|
? `Gain ${comparison.delta} percentage points`
|
|
: `Lose ${Math.abs(comparison.delta)} percentage points`;
|
|
return `${ownerName} ${comparison.effectLabel}. Current gear: ${currentRank}, ${comparison.currentValue} percent. Replacement: plus ${item.enhancement}, ${comparison.replacementValue} percent. ${change}.`;
|
|
}
|
|
|
|
export function GearStatComparison({ run, item }: {
|
|
readonly run: RpgRoguelikeRunState;
|
|
readonly item: RunGearItem;
|
|
}) {
|
|
const ownerName = gearOwnerName(run, item);
|
|
const comparison = compareRunGear(run.equipment, item);
|
|
const currentRank = comparison.currentItem ? `+${comparison.currentItem.enhancement}` : "none";
|
|
const delta = `${comparison.delta >= 0 ? "+" : ""}${comparison.delta} pts`;
|
|
return (
|
|
<span
|
|
className="rpg-gear-comparison"
|
|
aria-label={gearComparisonLabel(run, item)}
|
|
>
|
|
<strong>{ownerName} · {comparison.effectLabel}<em>{delta}</em></strong>
|
|
<span>
|
|
<span><small>Current · {currentRank}</small><b>+{comparison.currentValue}%</b></span>
|
|
<i aria-hidden="true">→</i>
|
|
<span><small>Replacement · +{item.enhancement}</small><b>+{comparison.replacementValue}%</b></span>
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function GearCard({ run, item, price, action }: {
|
|
readonly run: RpgRoguelikeRunState;
|
|
readonly item: RunGearItem;
|
|
readonly price?: number;
|
|
readonly action?: ReactNode;
|
|
}) {
|
|
return (
|
|
<article className="rpg-gear-card">
|
|
<i>{item.slotId === "weapon" ? "⚔" : item.slotId === "armor" ? "⬡" : "◇"}</i>
|
|
<div>
|
|
<small>{gearOwnerName(run, item)} · {item.slotId}</small>
|
|
<h3>{item.name}</h3>
|
|
{price !== undefined && <p className="rpg-gear-price">◆ {price}</p>}
|
|
<GearStatComparison run={run} item={item} />
|
|
</div>
|
|
{action}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
export function rewardSummary(choice: RewardChoice): { icon: string; eyebrow: string; detail: string; accent: string } {
|
|
if (choice.kind === "spell-rank") {
|
|
const spell = HEALER_ABILITIES[choice.spellId];
|
|
return { icon: spell.icon, eyebrow: "Spell upgrade", detail: `${spell.name} reaches rank ${choice.nextRank}.`, accent: spell.color };
|
|
}
|
|
if (choice.kind === "member-rarity") {
|
|
return { icon: "♟", eyebrow: "Party upgrade", detail: `Promote companion to ${choice.nextRarity}.`, accent: "#b47aec" };
|
|
}
|
|
if (choice.kind === "run-gear") {
|
|
return { icon: "◇", eyebrow: `Gear +${choice.item.enhancement}`, detail: `Equip ${choice.item.name}; displaced gear moves to bag.`, accent: "#65aef2" };
|
|
}
|
|
return { icon: "◆", eyebrow: "Run currency", detail: `Gain ${choice.amount} gold for this run.`, accent: "#efc858" };
|
|
}
|
|
|
|
export function ChallengeObjective({ run, compact = false }: { run: RpgRoguelikeRunState; compact?: boolean }) {
|
|
const challenge = run.currentChallenge;
|
|
const previous = run.lastChallengeResult;
|
|
if (!challenge && !previous) return null;
|
|
const objective = challenge?.objective ?? previous!.objective;
|
|
const metrics = challenge?.metrics ?? previous!.metrics;
|
|
const current = metrics[objective.metric];
|
|
const progress = Math.max(0, Math.min(100, (current / objective.target) * 100));
|
|
const copy = objective.challengeId === "blockbreaker"
|
|
? `Break ${objective.target} bricks before the board falls.`
|
|
: objective.challengeId === "hockey"
|
|
? `Defeat ${objective.target} ${objective.target === 1 ? "boss" : "bosses"} without conceding.`
|
|
: `Defeat ${objective.target} enemies before the assault ends.`;
|
|
return (
|
|
<section className={`rpg-challenge-objective ${compact ? "is-compact" : ""}`.trim()}>
|
|
<div><small>Repeat tier {objective.repeatIndex + 1}</small><strong>{objective.name}</strong></div>
|
|
{!compact && <p>{copy}</p>}
|
|
<div className="rpg-objective-progress">
|
|
<i style={{ width: `${progress}%` }} />
|
|
<span>{current} / {objective.target}</span>
|
|
</div>
|
|
{!compact && <footer>Success: ◆ {objective.rewardCurrency} · chest quality +{objective.chestQualityBonus}</footer>}
|
|
</section>
|
|
);
|
|
}
|