73 lines
2.4 KiB
TypeScript
73 lines
2.4 KiB
TypeScript
import { classById, raceById } from "../app/characterCatalog";
|
|
import type { CharacterProfile } from "../app/types";
|
|
|
|
export interface PlayerFrameMeter {
|
|
current: number;
|
|
maximum: number;
|
|
label: string;
|
|
tone: string;
|
|
}
|
|
|
|
export interface PlayerFrameProps {
|
|
character: CharacterProfile | null;
|
|
health: PlayerFrameMeter;
|
|
resource: PlayerFrameMeter;
|
|
shield?: number;
|
|
dead?: boolean;
|
|
}
|
|
|
|
function safePercent(current: number, maximum: number): number {
|
|
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
|
|
return Math.max(0, Math.min(100, (current / maximum) * 100));
|
|
}
|
|
|
|
function Meter({ current, maximum, label, tone }: PlayerFrameMeter) {
|
|
const percent = safePercent(current, maximum);
|
|
return (
|
|
<div className={`player-frame__meter player-frame__meter--${tone}`}>
|
|
<div className="player-frame__meter-label">
|
|
<span>{label}</span>
|
|
<strong>{Math.max(0, Math.round(current))} / {Math.max(0, Math.round(maximum))}</strong>
|
|
</div>
|
|
<div
|
|
className="player-frame__meter-track"
|
|
role="progressbar"
|
|
aria-label={`${label}: ${Math.round(percent)} percent`}
|
|
aria-valuemin={0}
|
|
aria-valuemax={Math.max(0, Math.round(maximum))}
|
|
aria-valuenow={Math.max(0, Math.round(current))}
|
|
>
|
|
<span style={{ width: `${percent}%` }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PlayerFrame({ character, health, resource, shield = 0, dead = false }: PlayerFrameProps) {
|
|
const race = character ? raceById(character.raceId) : null;
|
|
const characterClass = character ? classById(character.classId) : null;
|
|
|
|
return (
|
|
<section className={`player-frame ${dead ? "player-frame--dead" : ""}`} aria-label="Player status">
|
|
<div
|
|
className="player-frame__portrait"
|
|
style={{ borderColor: characterClass?.color ?? race?.accent }}
|
|
aria-hidden="true"
|
|
>
|
|
<span>{race?.sigil ?? "HM"}</span>
|
|
<b>{character?.level ?? 1}</b>
|
|
</div>
|
|
<div className="player-frame__body">
|
|
<header>
|
|
<strong>{character?.name ?? "Adventurer"}</strong>
|
|
<span>{characterClass?.name ?? "Explorer"}</span>
|
|
</header>
|
|
<Meter {...health} />
|
|
<Meter {...resource} />
|
|
{shield > 0 && <small className="player-frame__shield">+{Math.round(shield)} absorb</small>}
|
|
{dead && <small className="player-frame__dead-label">Defeated</small>}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|