Ir al contenido
Evaluar mi proyecto
Componentes · Mundos de estiloDisponible

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
HP
80
MP
50
XP
30

Inventario

Aldeano

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

mundo-pixel.tsx119 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/mundo-pixel

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
className?stringOpcional.
style?CSSPropertiesOpcional.

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)