"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>
);
}