Spaces:
Running
Running
| // 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> | |
| ); | |
| } | |