// 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 (
); } if (form === "gremlin") { return (
); } if (form === "moth") { return (
); } // mossling (default) return (
); } 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 (
{shout && ( // sibling of .puck-bob so the body's scale/spin doesn't warp the text
{shout}
)}
{muted &&
๐Ÿ”‡
}
{ if (!onMenu) return; e.preventDefault(); onMenu(); }} title="poke Puck ยท right-click for menu" />
); } // ---- 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 (
{data.source ? `Puck ยท ${data.source}` : "Puck"} {data.tier === "mythic" ? "โœฆ theatrical" : data.tier === "plain" ? "plain" : "playful"}
{data.text}
{data.ratable && (
)}
); }