new rpg mode

This commit is contained in:
Warren H
2026-07-17 12:47:42 -04:00
parent de7b59ce77
commit 10ff124466
180 changed files with 20863 additions and 1014 deletions
@@ -0,0 +1,325 @@
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 } from "../../game/rpgRoguelike";
import type { RpgUiCommand } from "../../game/rpgRoguelike";
import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
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();
if (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 (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><b>{entry.role}</b></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 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>
<p>+{item.statValue} {titleCase(item.statId)}{price !== undefined ? ` · ◆ ${price}` : ""}</p>
</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>
);
}