Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Pause, Play, RotateCcw, Shield, Skull, Trophy, Volume2, VolumeX, Wrench, Zap } from "lucide-react";
import { cn } from "@/lib/utils";
import { DARK_PALETTE, DrifterEngine, LIGHT_PALETTE, WORLD_W, type DrifterPalette, type SoundKind, type Status } from "./engine";
import { keyIsForGame, readBest, useBlips, useCanvasFit, useIsDark, usePauseOnBlur, writeBest } from "./kit";

export type { DrifterPalette } from "./engine";

export interface StarDrifterProps {
  /** Wave to start on. Every 5th wave is a boss. Later starts begin with a stronger weapon. */
  initialLevel?: number;
  seed?: number;
  onGameOver?: (score: number) => void;
  onWave?: (wave: number) => void;
  theme?: Partial<DrifterPalette>;
  storageKey?: string;
  title?: string;
  className?: string;
}

interface Hud {
  status: Status;
  score: number;
  wave: number;
  hull: number;
  shield: number;
  weapon: number;
  kills: number;
  boss: number | null;
}

const MOVE_KEYS: Record<string, [number, number]> = {
  ArrowLeft: [-1, 0],
  KeyA: [-1, 0],
  ArrowRight: [1, 0],
  KeyD: [1, 0],
  ArrowUp: [0, -1],
  KeyW: [0, -1],
  ArrowDown: [0, 1],
  KeyS: [0, 1],
};

const BESTIARY = [
  { name: "Dart", pts: 100, note: "Sways in lines, takes potshots", color: "#ec4899", path: "M12 21 L21 5 L12 9 L3 5 Z" },
  { name: "Weaver", pts: 150, note: "Spins across in V-flocks", color: "#10b981", path: "M12 2 L16 12 L12 22 L8 12 Z M2 12 L12 9 L22 12 L12 15 Z" },
  { name: "Diver", pts: 180, note: "Locks on, then rams", color: "#f97316", path: "M12 22 L20 7 L16 2 L12 8 L8 2 L4 7 Z" },
  { name: "Turret", pts: 400, note: "Parks up top, fires triples", color: "#8b5cf6", path: "M12 2 L21 7 L21 17 L12 22 L3 17 L3 7 Z" },
];

export function StarDrifter({ initialLevel = 1, seed, onGameOver, onWave, theme, storageKey = "star-drifter:best", title = "Star Drifter", className }: StarDrifterProps) {
  const reduce = useReducedMotion() ?? false;
  const dark = useIsDark();
  const rootRef = React.useRef<HTMLDivElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const sizeRef = useCanvasFit(canvasRef);
  const engineRef = React.useRef<DrifterEngine | null>(null);
  const runRef = React.useRef(0);
  const [muted, setMuted] = React.useState(true);
  const [best, setBest] = React.useState(0);
  const [newBest, setNewBest] = React.useState(false);
  const blip = useBlips(muted);
  const [hud, setHud] = React.useState<Hud>({ status: "ready", score: 0, wave: initialLevel, hull: 3, shield: 1, weapon: 1, kills: 0, boss: null });

  const palette = React.useMemo(() => ({ ...(dark ? DARK_PALETTE : LIGHT_PALETTE), ...theme }), [dark, theme]);
  const paletteRef = React.useRef(palette);
  React.useEffect(() => {
    paletteRef.current = palette;
  }, [palette]);
  const cbRef = React.useRef({ onGameOver, onWave, blip, storageKey });
  React.useEffect(() => {
    cbRef.current = { onGameOver, onWave, blip, storageKey };
  }, [onGameOver, onWave, blip, storageKey]);
  React.useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate from localStorage after mount
    setBest(readBest(storageKey));
  }, [storageKey]);

  const sound = React.useCallback((k: SoundKind) => {
    const b = cbRef.current.blip;
    const map: Record<SoundKind, [number, number, OscillatorType, number, number]> = {
      shoot: [900, 0.03, "square", 0.012, -300],
      hit: [300, 0.03, "square", 0.02, 0],
      boom: [140, 0.18, "sawtooth", 0.04, -80],
      bigboom: [90, 0.7, "sawtooth", 0.07, -50],
      power: [520, 0.25, "triangle", 0.05, 520],
      hurt: [200, 0.3, "sawtooth", 0.06, -120],
      shield: [700, 0.2, "sine", 0.05, -400],
      wave: [440, 0.3, "triangle", 0.04, 220],
      boss: [110, 0.8, "square", 0.04, 60],
    };
    b(...map[k]);
  }, []);

  const makeEngine = React.useCallback(() => {
    runRef.current += 1;
    engineRef.current = new DrifterEngine({
      seed: seed !== undefined ? seed + runRef.current : Math.floor(Math.random() * 2 ** 31),
      wave: Math.max(1, initialLevel),
      reduced: reduce,
      onSound: sound,
      onGameOver: (score) => {
        const key = cbRef.current.storageKey;
        if (score > readBest(key)) {
          writeBest(key, score);
          setBest(score);
          setNewBest(true);
        } else setNewBest(false);
        cbRef.current.onGameOver?.(score);
      },
    });
  }, [initialLevel, reduce, seed, sound]);

  React.useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    if (!canvas || !ctx) return;
    let raf = 0;
    let last = performance.now();
    let lastHud = "";
    let lastWave = 0;
    const frame = (now: number) => {
      const dt = Math.min(50, now - last);
      last = now;
      if (!engineRef.current) makeEngine();
      const e = engineRef.current!;
      const { w, h, dpr } = sizeRef.current;
      e.setHeight((WORLD_W * h) / Math.max(1, w));
      e.update(dt);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      e.render(ctx, w, h, paletteRef.current);
      if (e.status === "playing" && e.wave !== lastWave) {
        lastWave = e.wave;
        cbRef.current.onWave?.(e.wave);
      }
      const boss = e.boss;
      const next: Hud = { status: e.status, score: e.score, wave: e.wave, hull: e.hull, shield: e.shield, weapon: e.weapon, kills: e.kills, boss: boss && boss.y > -20 ? Math.round((boss.hp / boss.maxHp) * 100) : null };
      const key = JSON.stringify(next);
      if (key !== lastHud) {
        lastHud = key;
        setHud(next);
      }
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [makeEngine, sizeRef]);

  const start = React.useCallback(() => {
    const e = engineRef.current;
    if (!e) return;
    if (e.status === "over") {
      makeEngine();
      engineRef.current!.start();
      setNewBest(false);
    } else if (e.status === "ready") e.start();
    else if (e.status === "paused") e.status = "playing";
    rootRef.current?.focus({ preventScroll: true });
  }, [makeEngine]);

  const restart = React.useCallback(() => {
    makeEngine();
    engineRef.current!.start();
    setNewBest(false);
    rootRef.current?.focus({ preventScroll: true });
  }, [makeEngine]);

  const togglePause = React.useCallback(() => {
    const e = engineRef.current;
    if (!e) return;
    if (e.status === "playing") e.status = "paused";
    else if (e.status === "paused") e.status = "playing";
  }, []);

  usePauseOnBlur(
    React.useCallback(() => {
      const e = engineRef.current;
      if (e?.status === "playing") {
        e.status = "paused";
        e.input = { x: 0, y: 0 };
        e.drag = null;
      }
    }, []),
  );

  React.useEffect(() => {
    const held = new Set<string>();
    const sync = () => {
      const e = engineRef.current;
      if (!e) return;
      let x = 0;
      let y = 0;
      for (const k of held) {
        const v = MOVE_KEYS[k];
        if (v) {
          x += v[0];
          y += v[1];
        }
      }
      e.input = { x: Math.sign(x), y: Math.sign(y) };
      if (x || y) e.drag = null;
    };
    const down = (ev: KeyboardEvent) => {
      if (!keyIsForGame(ev, rootRef.current)) return;
      if (MOVE_KEYS[ev.code]) {
        ev.preventDefault();
        held.add(ev.code);
        sync();
        if (engineRef.current?.status === "ready") start();
        return;
      }
      const onButton = ev.target instanceof HTMLButtonElement;
      if (ev.code === "Space" || ev.code === "Enter") {
        if (onButton) return;
        ev.preventDefault();
        if (engineRef.current?.status === "playing") togglePause();
        else start();
      } else if (ev.code === "KeyP" || ev.code === "Escape") {
        ev.preventDefault();
        togglePause();
      } else if (ev.code === "KeyM") setMuted((m) => !m);
      else if (ev.code === "KeyR") restart();
    };
    const up = (ev: KeyboardEvent) => {
      held.delete(ev.code);
      sync();
    };
    window.addEventListener("keydown", down);
    window.addEventListener("keyup", up);
    return () => {
      window.removeEventListener("keydown", down);
      window.removeEventListener("keyup", up);
    };
  }, [restart, start, togglePause]);

  // drag anywhere to steer (relative), ship keeps firing automatically
  const dragRef = React.useRef<{ px: number; py: number; sx: number; sy: number; id: number } | null>(null);
  const onPointerDown = (ev: React.PointerEvent<HTMLDivElement>) => {
    if (ev.target instanceof Element && ev.target.closest("button")) return;
    const e = engineRef.current;
    if (!e) return;
    if (e.status === "ready" || e.status === "over") {
      start();
      return;
    }
    if (e.status === "paused") e.status = "playing";
    ev.currentTarget.setPointerCapture(ev.pointerId);
    dragRef.current = { px: ev.clientX, py: ev.clientY, sx: e.x, sy: e.y, id: ev.pointerId };
    e.drag = { tx: e.x, ty: e.y };
  };
  const onPointerMove = (ev: React.PointerEvent<HTMLDivElement>) => {
    const d = dragRef.current;
    const e = engineRef.current;
    if (!d || !e || d.id !== ev.pointerId) return;
    const sc = sizeRef.current.w / WORLD_W;
    e.drag = { tx: d.sx + (ev.clientX - d.px) / sc, ty: d.sy + (ev.clientY - d.py) / sc };
  };
  const endDrag = () => {
    dragRef.current = null;
    if (engineRef.current) engineRef.current.drag = null;
  };

  const { status } = hud;
  const playing = status === "playing";
  const bossWave = hud.wave % 5 === 0;

  return (
    <div
      ref={rootRef}
      tabIndex={0}
      data-status={status}
      aria-label={`${title} game`}
      className={cn("relative flex h-[700px] w-full flex-col overflow-hidden bg-background text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-inset", className)}
    >
      <header className="relative z-10 flex h-14 shrink-0 items-center gap-3 border-b bg-background/80 px-3 backdrop-blur sm:px-5">
        <DrifterMark className="size-8 shrink-0" />
        <div className="min-w-0 leading-none">
          <p className="truncate text-[15px] font-extrabold tracking-[0.12em] uppercase">
            <span className="bg-gradient-to-r from-sky-400 to-violet-500 bg-clip-text text-transparent">{title}</span>
          </p>
          <p className={cn("mt-1 text-[11px] font-medium", bossWave && playing ? "text-rose-500" : "text-muted-foreground")}>
            Wave {hud.wave}
            {bossWave ? " · Boss" : ""}
          </p>
        </div>
        <div className="ml-auto flex items-center gap-2 sm:gap-5">
          <Stat label="Score" value={hud.score} />
          <Stat label="Best" value={Math.max(best, hud.score)} className="hidden sm:flex" />
          <div className="flex items-center gap-1">
            <IconButton label={playing ? "Pause (P)" : "Resume (P)"} onClick={playing ? togglePause : start} disabled={status === "dying"}>
              {playing ? <Pause className="size-4" /> : <Play className="size-4" />}
            </IconButton>
            <IconButton label={muted ? "Unmute (M)" : "Mute (M)"} onClick={() => setMuted((m) => !m)} pressed={!muted}>
              {muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
            </IconButton>
            <IconButton label="Restart (R)" onClick={restart}>
              <RotateCcw className="size-4" />
            </IconButton>
          </div>
        </div>
      </header>

      <div className="flex min-h-0 flex-1">
        {/* ship status */}
        <aside className="hidden w-64 shrink-0 flex-col gap-5 overflow-y-auto border-r bg-muted/30 p-5 lg:flex">
          <h3 className="text-[11px] font-bold tracking-[0.18em] text-muted-foreground uppercase">Ship status</h3>
          <Meter label="Hull" icon={<Wrench className="size-3.5" />} value={hud.hull} max={3} tone="from-emerald-400 to-teal-500" />
          <Meter label="Shield" icon={<Shield className="size-3.5" />} value={hud.shield} max={3} tone="from-sky-400 to-blue-500" />
          <Meter label="Weapon" icon={<Zap className="size-3.5" />} value={hud.weapon} max={5} tone="from-amber-400 to-orange-500" suffix={hud.weapon >= 5 ? "MAX" : `Lv ${hud.weapon}`} />
          <div className="grid grid-cols-2 gap-2">
            <Tile label="Wave" value={hud.wave} />
            <Tile label="Kills" value={hud.kills} />
          </div>
          <div className="mt-auto rounded-xl border bg-card p-3 text-xs text-muted-foreground">
            <p className="flex items-center gap-1.5 font-semibold text-foreground">
              <Skull className="size-3.5 text-rose-500" aria-hidden /> Next boss
            </p>
            <p className="mt-1">Wave {Math.ceil(hud.wave / 5) * 5}. Clear a wave without a scratch for a flawless bonus.</p>
          </div>
        </aside>

        {/* playfield */}
        <div
          className="relative flex min-h-0 min-w-0 flex-1 justify-center"
          style={{
            backgroundImage: "radial-gradient(ellipse at 50% 40%, color-mix(in oklab, #8b5cf6 16%, transparent), transparent 65%), radial-gradient(color-mix(in oklab, currentColor 14%, transparent) 1px, transparent 1.4px)",
            backgroundSize: "100% 100%, 22px 22px",
          }}
        >
          <div className="relative h-full w-full max-w-[460px] touch-none select-none sm:border-x sm:shadow-[0_0_80px_-20px_rgba(139,92,246,0.55)] lg:max-w-[440px]" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
            <canvas ref={canvasRef} className="absolute inset-0 block" role="img" aria-label={`Space. Wave ${hud.wave}, hull ${hud.hull}, shield ${hud.shield}, score ${hud.score}.`} />
            {/* compact HUD (mobile / tablet) */}
            <div className="pointer-events-none absolute top-3 left-3 flex gap-2 lg:hidden">
              <Pips icon={<Wrench className="size-3" />} value={hud.hull} max={3} className="bg-emerald-500" />
              <Pips icon={<Shield className="size-3" />} value={hud.shield} max={3} className="bg-sky-500" />
              <span className="flex items-center gap-1 rounded-full border bg-background/80 px-2 py-1 text-[10px] font-bold backdrop-blur">
                <Zap className="size-3 text-amber-500" aria-hidden />
                {hud.weapon >= 5 ? "MAX" : `LV${hud.weapon}`}
              </span>
            </div>
            <AnimatePresence>
              {hud.boss !== null && playing && (
                <motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute inset-x-3 top-12 lg:top-3">
                  <div className="flex items-center justify-between text-[10px] font-bold tracking-[0.2em] text-rose-500 uppercase">
                    <span>Dreadnought</span>
                    <span className="tabular-nums">{hud.boss}%</span>
                  </div>
                  <div className="mt-1 h-2 overflow-hidden rounded-full bg-rose-500/15">
                    <motion.div className="h-full rounded-full bg-gradient-to-r from-rose-500 to-orange-400" animate={{ width: `${hud.boss}%` }} transition={{ duration: 0.15 }} />
                  </div>
                </motion.div>
              )}
            </AnimatePresence>
            <AnimatePresence>
              {(status === "ready" || status === "paused" || status === "over") && (
                <motion.div
                  key={status}
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                  transition={{ duration: reduce ? 0 : 0.2 }}
                  className="absolute inset-0 z-10 grid place-items-center bg-background/50 p-6 backdrop-blur-[2px]"
                >
                  <motion.div initial={reduce ? false : { y: 16 }} animate={{ y: 0 }} transition={{ type: "spring", stiffness: 280, damping: 24 }} className="w-full max-w-xs text-center">
                    {status === "ready" && (
                      <>
                        <DrifterMark className="mx-auto mb-3 size-14" />
                        <h2 className="bg-gradient-to-b from-sky-400 to-violet-600 bg-clip-text text-4xl font-black tracking-[0.1em] text-transparent uppercase">{title}</h2>
                        <p className="mt-2 text-sm text-muted-foreground">Drift through the belt, break the waves, and topple the dreadnought every fifth wave.</p>
                        <PrimaryButton onClick={start}>
                          <Play className="size-4" /> Launch
                        </PrimaryButton>
                        <p className="mt-4 text-[11px] text-muted-foreground">
                          <span className="hidden sm:inline">Arrows / WASD or drag to fly · auto-fire · P pause</span>
                          <span className="sm:hidden">Drag anywhere to fly · auto-fire</span>
                        </p>
                      </>
                    )}
                    {status === "paused" && (
                      <>
                        <h2 className="text-4xl font-black tracking-[0.1em] uppercase">Paused</h2>
                        <p className="mt-1 text-sm text-muted-foreground">Wave {hud.wave}</p>
                        <div className="mt-6 flex justify-center gap-2">
                          <PrimaryButton onClick={start} className="mt-0">
                            <Play className="size-4" /> Resume
                          </PrimaryButton>
                          <button type="button" onClick={restart} className="inline-flex h-11 items-center gap-2 rounded-full border bg-background px-5 text-sm font-semibold hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none">
                            <RotateCcw className="size-4" /> Restart
                          </button>
                        </div>
                      </>
                    )}
                    {status === "over" && (
                      <>
                        <p className="text-xs font-bold tracking-[0.25em] text-rose-500 uppercase">Hull breached</p>
                        <h2 className="mt-2 text-5xl font-black tabular-nums">{hud.score}</h2>
                        {newBest ? (
                          <p className="mx-auto mt-3 inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r from-sky-500 to-violet-500 px-3 py-1 text-xs font-bold text-white">
                            <Trophy className="size-3.5" /> New best!
                          </p>
                        ) : (
                          <p className="mt-3 text-xs text-muted-foreground">Best {best}</p>
                        )}
                        <div className="mt-4 grid grid-cols-2 gap-2">
                          <Tile label="Wave" value={hud.wave} />
                          <Tile label="Kills" value={hud.kills} />
                        </div>
                        <PrimaryButton onClick={start}>
                          <RotateCcw className="size-4" /> Fly again
                        </PrimaryButton>
                      </>
                    )}
                  </motion.div>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        </div>

        {/* bestiary */}
        <aside className="hidden w-64 shrink-0 flex-col gap-4 overflow-y-auto border-l bg-muted/30 p-5 xl:flex">
          <h3 className="text-[11px] font-bold tracking-[0.18em] text-muted-foreground uppercase">Hostiles</h3>
          <ul className="space-y-2">
            {BESTIARY.map((b) => (
              <li key={b.name} className="flex items-center gap-3 rounded-xl border bg-card p-2.5">
                <svg viewBox="0 0 24 24" className="size-8 shrink-0" aria-hidden>
                  <path d={b.path} fill={b.color} stroke="white" strokeOpacity="0.6" strokeWidth="0.8" />
                </svg>
                <div className="min-w-0 flex-1">
                  <p className="flex justify-between text-sm font-semibold">
                    {b.name} <span className="font-mono text-xs text-muted-foreground">{b.pts}</span>
                  </p>
                  <p className="truncate text-xs text-muted-foreground">{b.note}</p>
                </div>
              </li>
            ))}
          </ul>
          <h3 className="mt-2 text-[11px] font-bold tracking-[0.18em] text-muted-foreground uppercase">Salvage</h3>
          <ul className="grid grid-cols-3 gap-2 text-center text-[11px]">
            {[
              { k: "P", n: "Power", c: "bg-orange-500" },
              { k: "S", n: "Shield", c: "bg-sky-500" },
              { k: "+", n: "Repair", c: "bg-emerald-500" },
            ].map((d) => (
              <li key={d.k} className="rounded-xl border bg-card p-2">
                <span className={cn("mx-auto grid size-7 place-items-center rounded-lg text-xs font-black text-white", d.c)} style={{ clipPath: "polygon(25% 5%, 75% 5%, 100% 50%, 75% 95%, 25% 95%, 0 50%)" }}>
                  {d.k}
                </span>
                <span className="mt-1 block text-muted-foreground">{d.n}</span>
              </li>
            ))}
          </ul>
          <p className="mt-auto text-xs text-muted-foreground">Asteroids split when shot. Taking hull damage drops your weapon one level.</p>
        </aside>
      </div>
      <p className="sr-only" aria-live="polite">
        {status === "over" ? `Game over. Score ${hud.score}.` : status === "paused" ? "Paused" : hud.boss !== null ? "Boss incoming" : ""}
      </p>
    </div>
  );
}

function Meter({ label, icon, value, max, tone, suffix }: { label: string; icon: React.ReactNode; value: number; max: number; tone: string; suffix?: string }) {
  return (
    <div>
      <div className="mb-1.5 flex items-center justify-between text-xs">
        <span className="flex items-center gap-1.5 font-semibold">
          {icon}
          {label}
        </span>
        <span className="font-mono text-muted-foreground">{suffix ?? `${value}/${max}`}</span>
      </div>
      <div className="flex gap-1" role="meter" aria-label={label} aria-valuenow={value} aria-valuemin={0} aria-valuemax={max}>
        {Array.from({ length: max }, (_, i) => (
          <span key={i} className={cn("h-2.5 flex-1 rounded-full transition-colors duration-300", i < value ? cn("bg-gradient-to-r", tone) : "bg-muted")} />
        ))}
      </div>
    </div>
  );
}

function Pips({ icon, value, max, className }: { icon: React.ReactNode; value: number; max: number; className: string }) {
  return (
    <span className="flex items-center gap-1 rounded-full border bg-background/80 px-2 py-1 backdrop-blur">
      {icon}
      {Array.from({ length: max }, (_, i) => (
        <span key={i} className={cn("size-1.5 rounded-full", i < value ? className : "bg-muted-foreground/30")} />
      ))}
    </span>
  );
}

function Tile({ label, value }: { label: string; value: number }) {
  return (
    <div className="rounded-xl border bg-card px-3 py-2 text-center">
      <p className="text-[10px] font-bold tracking-wider text-muted-foreground uppercase">{label}</p>
      <p className="font-mono text-lg font-black tabular-nums">{value}</p>
    </div>
  );
}

function PrimaryButton({ children, onClick, className }: { children: React.ReactNode; onClick: () => void; className?: string }) {
  return (
    <button
      type="button"
      onClick={onClick}
      className={cn(
        "mt-6 inline-flex h-11 items-center gap-2 rounded-full bg-gradient-to-r from-sky-500 to-violet-600 px-6 text-sm font-bold text-white shadow-lg shadow-violet-500/30 transition hover:brightness-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none active:scale-95",
        className,
      )}
    >
      {children}
    </button>
  );
}

function IconButton({ label, onClick, children, disabled, pressed }: { label: string; onClick: () => void; children: React.ReactNode; disabled?: boolean; pressed?: boolean }) {
  return (
    <button
      type="button"
      aria-label={label}
      title={label}
      aria-pressed={pressed}
      disabled={disabled}
      onClick={onClick}
      className="grid size-9 place-items-center rounded-lg text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:opacity-40"
    >
      {children}
    </button>
  );
}

function Stat({ label, value, className }: { label: string; value: number; className?: string }) {
  return (
    <div className={cn("flex flex-col items-end leading-none", className)}>
      <span className="text-[9px] font-bold tracking-[0.18em] text-muted-foreground uppercase">{label}</span>
      <span className="mt-1 font-mono text-lg font-black tabular-nums">{value}</span>
    </div>
  );
}

function DrifterMark({ className }: { className?: string }) {
  const id = React.useId();
  return (
    <svg viewBox="0 0 40 40" className={className} aria-hidden>
      <defs>
        <linearGradient id={`${id}b`} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#0c1330" />
          <stop offset="1" stopColor="#312e81" />
        </linearGradient>
        <linearGradient id={`${id}s`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#e0f2fe" />
          <stop offset="1" stopColor="#38bdf8" />
        </linearGradient>
      </defs>
      <rect x="1" y="1" width="38" height="38" rx="11" fill={`url(#${id}b)`} />
      <circle cx="9" cy="10" r="1" fill="#fff" opacity="0.8" />
      <circle cx="31" cy="15" r="0.8" fill="#fff" opacity="0.6" />
      <circle cx="12" cy="30" r="0.7" fill="#fff" opacity="0.5" />
      <path d="M20 29 L22.5 34 L20 32.5 L17.5 34 Z" fill="#fb923c" />
      <path d="M20 7 C23 12 23.5 20 23 29 H17 C16.5 20 17 12 20 7 Z" fill={`url(#${id}s)`} />
      <path d="M20 17 L29 26 L28 28 L20 25 L12 28 L11 26 Z" fill="#38bdf8" />
      <ellipse cx="20" cy="16" rx="1.6" ry="3" fill="#22d3ee" />
    </svg>
  );
}

More in Games

View all →