Mundo pixel art: menú de RPG
Ficha de personaje con barras de vida, inventario de casillas y un cuadro de diálogo que escribe su texto letra a letra.
Héroe nv 7
Aldea del sur
Oro: 240
Oro: 240
HP80
MP50
XP30
Inventario
Aldeano
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useState, useSyncExternalStore, type CSSProperties } from "react";
/** Mundo pixel art: menú de RPG con ficha del personaje, barras de vida, inventario de casillas que se eligen con el teclado o el puntero y un cuadro de diálogo que escribe su texto letra a letra. React + TypeScript, sin dependencias. */
export type MundoPixelProps = {
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcmpx-root { box-sizing: border-box; container-type: inline-size; width: 100%; padding: clamp(14px, 4cqi, 32px); background: #1b1034; color: #fff; font: 700 12px/1.5 "Press Start 2P", ui-monospace, "SF Mono", Menlo, Consolas, monospace; text-transform: uppercase; letter-spacing: .04em; image-rendering: pixelated; }
.gcmpx-root *, .gcmpx-root *::before, .gcmpx-root *::after { box-sizing: border-box; }
.gcmpx-root button { font: inherit; letter-spacing: inherit; text-transform: inherit; }
.gcmpx-root :focus-visible { outline: 3px dashed #ffd23f; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcmpx-root * { animation: none !important; } }
.gcmpx-box { background: #2a1950; box-shadow: 0 -4px 0 0 #fff, 0 4px 0 0 #fff, -4px 0 0 0 #fff, 4px 0 0 0 #fff, 0 -8px 0 -2px #0b0517, 0 8px 0 -2px #0b0517, -8px 0 0 -2px #0b0517, 8px 0 0 -2px #0b0517; padding: 18px; }
.gcmpx-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; max-width: 640px; margin: 0 auto 28px; }
.gcmpx-h { margin: 0 0 14px; color: #ffd23f; font-size: 12px; font-weight: 700; }
.gcmpx-hero { display: flex; gap: 14px; align-items: center; margin-bottom: 14px; }
.gcmpx-sprite { flex: none; width: 56px; height: 56px; background: #0b0517; display: grid; place-items: center; box-shadow: 0 0 0 3px #fff; }
.gcmpx-row { display: grid; grid-template-columns: 28px 1fr 34px; align-items: center; gap: 8px; margin-top: 8px; }
.gcmpx-bar { display: grid; grid-template-columns: repeat(10, 1fr); gap: 2px; height: 14px; padding: 2px; background: #0b0517; }
.gcmpx-bar i { background: #3a2468; }
.gcmpx-bar i[data-on="true"] { background: var(--c); box-shadow: inset 0 -3px 0 rgba(0, 0, 0, .3); }
.gcmpx-inv { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.gcmpx-slot { aspect-ratio: 1; display: grid; place-items: center; padding: 0; border: 0; background: #0b0517; color: #fff; cursor: pointer; box-shadow: 0 0 0 2px #5a3f9c; }
.gcmpx-slot:hover { box-shadow: 0 0 0 2px #fff; }
.gcmpx-slot[aria-pressed="true"] { box-shadow: 0 0 0 3px #ffd23f; background: #3a2468; }
.gcmpx-slot svg { width: 60%; height: 60%; shape-rendering: crispEdges; }
.gcmpx-dialog { max-width: 640px; margin: 0 auto; min-height: 110px; position: relative; }
.gcmpx-name { position: absolute; top: -18px; left: 14px; padding: 4px 10px; background: #ffd23f; color: #1b1034; font-size: 11px; }
.gcmpx-text { margin: 6px 0 0; min-height: 52px; line-height: 1.9; text-transform: none; }
.gcmpx-next { position: absolute; right: 16px; bottom: 12px; color: #ffd23f; animation: gcmpx-blink .8s steps(2, jump-none) infinite; }
@keyframes gcmpx-blink { 50% { opacity: 0; } }
@container (max-width: 560px) { .gcmpx-grid { grid-template-columns: minmax(0, 1fr); } }`;
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function useReduced() {
return useSyncExternalStore(
(notify) => {
const query = window.matchMedia(REDUCED_QUERY);
query.addEventListener("change", notify);
return () => query.removeEventListener("change", notify);
},
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
const ITEMS = [
{ n: "Poción", d: "Recupera 30 de vida.", c: "#ff5a5a" },
{ n: "Espada", d: "Una espada de hierro. Corta bien.", c: "#c9d3e8" },
{ n: "Llave", d: "Abre la puerta del sótano.", c: "#ffd23f" },
{ n: "Mapa", d: "Muestra el camino al pueblo.", c: "#d6a15a" },
{ n: "Gema", d: "Brilla con luz propia.", c: "#4ddc9a" },
{ n: "Escudo", d: "Reduce el daño recibido.", c: "#5aa8ff" },
{ n: "Pan", d: "Recupera 10 de vida.", c: "#e8b86a" },
{ n: "Arco", d: "Para atacar a distancia.", c: "#b88a5a" },
];
export function MundoPixel({ className = "", style }: MundoPixelProps) {
const reduced = useReduced();
const [sel, setSel] = useState(0);
const [n, setN] = useState(0);
const text = `${ITEMS[sel].n}: ${ITEMS[sel].d}`;
const pick = (next: number) => {
setSel(next);
setN(0);
};
useEffect(() => {
if (reduced) return;
const id = window.setInterval(() => setN((v) => (v >= text.length ? (window.clearInterval(id), v) : v + 1)), 28);
return () => window.clearInterval(id);
}, [text, reduced]);
const bar = (v: number, c: string, label: string) => (
<div className="gcmpx-bar" role="progressbar" aria-label={label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={v} style={{ "--c": c } as CSSProperties}>
{Array.from({ length: 10 }, (_, i) => (
<i key={i} data-on={i < v / 10} />
))}
</div>
);
return (
<div className={`gcmpx-root ${className}`} style={style}>
<style>{CSS}</style>
<div className="gcmpx-grid">
<section className="gcmpx-box" aria-label="Personaje">
<h3 className="gcmpx-h">Héroe nv 7</h3>
<div className="gcmpx-hero">
<span className="gcmpx-sprite" aria-hidden="true">
<svg viewBox="0 0 8 8" width="40" height="40" shapeRendering="crispEdges"><path fill="#ffd8b0" d="M2 1h4v3H2z" /><path fill="#c43d3d" d="M2 0h4v1H2zM1 1h1v1H1zM6 1h1v1H6z" /><path fill="#1b1034" d="M3 2h1v1H3zM5 2h1v1H5z" /><path fill="#3d6fd1" d="M2 4h4v3H2z" /><path fill="#6b3a1c" d="M2 7h1v1H2zM5 7h1v1H5z" /></svg>
</span>
<span>Aldea del sur<br />Oro: 240</span>
</div>
<div className="gcmpx-row"><span>HP</span>{bar(80, "#4ddc6a", "Vida")}<span>80</span></div>
<div className="gcmpx-row"><span>MP</span>{bar(50, "#5aa8ff", "Magia")}<span>50</span></div>
<div className="gcmpx-row"><span>XP</span>{bar(30, "#ffd23f", "Experiencia")}<span>30</span></div>
</section>
<section className="gcmpx-box" aria-label="Inventario">
<h3 className="gcmpx-h">Inventario</h3>
<div className="gcmpx-inv" role="group">
{ITEMS.map((item, i) => (
<button key={item.n} className="gcmpx-slot" type="button" aria-pressed={sel === i} aria-label={item.n} onClick={() => pick(i)} onKeyDown={(e) => { if (e.key === "ArrowRight") pick((sel + 1) % ITEMS.length); if (e.key === "ArrowLeft") pick((sel - 1 + ITEMS.length) % ITEMS.length); }}>
<svg viewBox="0 0 8 8" aria-hidden="true"><rect x="1" y="1" width="6" height="6" fill={item.c} /><rect x="2" y="2" width="2" height="2" fill="#fff" opacity=".6" /></svg>
</button>
))}
</div>
</section>
</div>
<div className="gcmpx-box gcmpx-dialog" role="status" aria-live="polite">
<span className="gcmpx-name">Aldeano</span>
<p className="gcmpx-text">{reduced ? text : text.slice(0, n)}</p>
<span className="gcmpx-next" aria-hidden="true">▼</span>
</div>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/mundo-pixelEjemplo de uso de Mundo pixel art: menú de RPG
Después de copiar mundo-pixel.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { MundoPixel } from "./mundo-pixel";
export default function Ejemplo() {
return <MundoPixel />;
}Propiedades de MundoPixel
MundoPixel acepta 2 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 119 líneas (7 KB) y define 1 animación con @keyframes. Esto es lo que hace Mundo pixel art: menú de RPG por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: progressbar, group, status.
- Responde al teclado, no solo al ratón.
- Lee preferencias del navegador con useSyncExternalStore, sin parpadeos en la hidratación.
- Es un componente de cliente de Next.js: incluye la directiva "use client".