puck / frontend /src /ui /Sprite.tsx
vu1n's picture
Puck β€” desktop fairy familiar (HF Build Small)
3c124f3
Raw
History Blame Contribute Delete
8.91 kB
// PuckSprite β€” the flying creature + its speech bubble.
// Forms are swappable; the sprite layer mounts standalone in overlay mode.
import * as React from "react";
import type { ReactionKind, StyleTag, Tier } from "../engine";
export type SpriteForm = "mossling" | "wisp" | "gremlin" | "moth";
export type BubbleStyle = "voice" | "plain" | "hand";
// the creature body in a given form (also reused in panels/toasts at small size)
export function SpriteBody({ form = "mossling" }: { form?: SpriteForm }) {
if (form === "wisp") {
return (
<div className="puck-face">
<div
className="puck-body"
style={{
width: 26,
height: 26,
background: "radial-gradient(circle at 40% 35%, #fff7e0, var(--puck-body) 72%)",
}}
>
<div className="puck-eye l" style={{ width: 4, height: 5, top: "42%", left: "33%" }} />
<div className="puck-eye r" style={{ width: 4, height: 5, top: "42%", right: "33%" }} />
</div>
<div
style={{
position: "absolute",
inset: "18%",
border: "1px solid var(--puck-wing)",
borderRadius: "50%",
opacity: 0.6,
}}
/>
<div
style={{
position: "absolute",
inset: "4%",
border: "1px solid var(--puck-wing)",
borderRadius: "50%",
opacity: 0.35,
}}
/>
</div>
);
}
if (form === "gremlin") {
return (
<div className="puck-face">
<div
className="puck-wing l"
style={{ width: 14, height: 22, top: "30%", borderRadius: "40% 10% 10% 40%" }}
/>
<div
className="puck-wing r"
style={{ width: 14, height: 22, top: "30%", borderRadius: "10% 40% 40% 10%" }}
/>
<div className="puck-body" style={{ width: 30, height: 30, borderRadius: "10px" }}>
<div className="puck-eye l" style={{ borderRadius: 2, width: 5, height: 6 }} />
<div className="puck-eye r" style={{ borderRadius: 2, width: 5, height: 6 }} />
<div
style={{
position: "absolute",
bottom: "22%",
left: "50%",
transform: "translateX(-50%)",
width: 8,
height: 2,
background: "#15201a",
borderRadius: 2,
}}
/>
</div>
</div>
);
}
if (form === "moth") {
return (
<div className="puck-face">
<div
className="puck-wing l"
style={{ width: 30, height: 34, top: "34%", borderRadius: "70% 20% 60% 40%" }}
/>
<div
className="puck-wing r"
style={{ width: 30, height: 34, top: "34%", borderRadius: "20% 70% 40% 60%" }}
/>
<div className="puck-body" style={{ width: 18, height: 30, borderRadius: "50%" }}>
<div className="puck-eye l" style={{ left: "24%" }} />
<div className="puck-eye r" style={{ right: "24%" }} />
</div>
<div
style={{
position: "absolute",
top: "22%",
left: "44%",
width: 1.5,
height: 9,
background: "var(--puck-body)",
transform: "rotate(18deg)",
}}
/>
<div
style={{
position: "absolute",
top: "22%",
right: "44%",
width: 1.5,
height: 9,
background: "var(--puck-body)",
transform: "rotate(-18deg)",
}}
/>
</div>
);
}
// mossling (default)
return (
<div className="puck-face">
<div className="puck-wing l" />
<div className="puck-wing r" />
<div className="puck-body">
<div className="puck-eye l" />
<div className="puck-eye r" />
<div className="puck-cheek l" />
<div className="puck-cheek r" />
</div>
</div>
);
}
export interface SpriteState {
x: number;
y: number;
flying: boolean;
resting: boolean;
facing: "left" | "right";
}
export function PuckSprite({
sprite,
form,
speaking,
alert = false,
reaction = null,
shout,
reactKey = 0,
muted = false,
camo = false,
onPoke,
onMenu,
}: {
sprite: SpriteState;
form: SpriteForm;
speaking: boolean;
/** Something is waiting (bubble/toast/interrupt) β€” pulse to draw the eye,
* the overlay's answer to a sprite lost on a busy desktop. */
alert?: boolean;
/** Muted: watching but silent β€” calm the glow and show a πŸ”‡ badge. */
muted?: boolean;
/** Camo: blended into the desktop β€” glassy/transparent skin, faint eyes. */
camo?: boolean;
/** One-shot personality gesture (null between reactions). */
reaction?: ReactionKind | null;
/** Optional exclamation that floats over the sprite during the reaction. */
shout?: string;
/** Monotonic counter β€” bumping it remounts the animated layers so the SAME
* reaction restarts its CSS animation (back-to-back pushes still pop). */
reactKey?: number;
onPoke: () => void;
/** Right-click β€” the overlay has no menu bar, so the sprite IS the menu. */
onMenu?: () => void;
}) {
const cls = ["puck"];
if (sprite.flying) cls.push("flying", "snappy");
if (sprite.resting) cls.push("resting");
if (sprite.facing === "left") cls.push("face-left");
if (speaking) cls.push("speaking");
if (alert) cls.push("alert");
if (reaction) cls.push(`react-${reaction}`);
if (muted) cls.push("muted");
if (camo) cls.push("camo");
return (
<div className={cls.join(" ")} style={{ transform: `translate(${sprite.x - 32}px, ${sprite.y - 32}px)` }}>
{shout && (
// sibling of .puck-bob so the body's scale/spin doesn't warp the text
<div className="puck-shout" key={reactKey}>
{shout}
</div>
)}
<div className="puck-bob" key={reactKey}>
<div className="puck-glow" />
<div className="puck-alert-ring" />
<div className="puck-trail" />
<div className="puck-voicewave">
<i />
<i />
<i />
</div>
<SpriteBody form={form} />
{muted && <div className="puck-mute">πŸ”‡</div>}
<div
className="puck-hit"
onClick={onPoke}
onContextMenu={(e) => {
if (!onMenu) return;
e.preventDefault();
onMenu();
}}
title="poke Puck Β· right-click for menu"
/>
</div>
</div>
);
}
// ---- speech bubble ----------------------------------------------------------
export interface BubbleData {
uid: string;
source: string;
text: string;
tier: Tier;
tags: StyleTag[];
x: number;
y: number;
ratable: boolean;
}
export function Bubble({
data,
bubbleStyle,
onRate,
onDismiss,
}: {
data: BubbleData;
bubbleStyle: BubbleStyle;
onRate: (rating: "helpful" | "annoying" | "cute") => void;
onDismiss: () => void;
}) {
const [out, setOut] = React.useState(false);
const W = 290;
const vw = window.innerWidth;
const vh = window.innerHeight;
const below = data.y < 250;
const left = Math.max(12, Math.min(vw - W - 12, data.x - 40));
const style: React.CSSProperties = below
? { left, top: data.y + 46 }
: { left, top: "auto", bottom: vh - data.y + 46 };
const tailX = Math.max(16, Math.min(W - 28, data.x - left - 7));
const dismiss = (rating?: "helpful" | "annoying" | "cute") => {
setOut(true);
setTimeout(() => {
if (rating) onRate(rating);
else onDismiss();
}, 260);
};
const styleCls = { voice: "", plain: "style-plain", hand: "style-hand" }[bubbleStyle];
return (
<div className={`bubble ${styleCls} ${out ? "out" : ""}`} style={{ ...style, width: W }}>
<div
className="bub-tail"
style={
below
? { top: -7, left: tailX, transform: "rotate(135deg)" }
: { bottom: -7, left: tailX, transform: "rotate(45deg)" }
}
/>
<div className="bub-src">
<span>{data.source ? `Puck Β· ${data.source}` : "Puck"}</span>
<span className="bub-tier">
{data.tier === "mythic" ? "✦ theatrical" : data.tier === "plain" ? "plain" : "playful"}
</span>
</div>
<div className="bub-text">{data.text}</div>
{data.ratable && (
<div className="bub-acts">
<button type="button" className="bub-rate" onClick={() => dismiss("helpful")}>
πŸ‘ helpful
</button>
<button type="button" className="bub-rate" onClick={() => dismiss("annoying")}>
πŸ™„ annoying
</button>
<button type="button" className="bub-rate" onClick={() => dismiss("cute")}>
🌸 cute, useless
</button>
</div>
)}
</div>
);
}