new rpg mode
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user