From 64019bc0c2c3ce196428bf2db93a33baf44caa8f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 20:02:24 +0100 Subject: [PATCH] refactor(ui): split lobster-pet into look/plans/element modules (#110816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(ui): split lobster-pet into look/plans/element modules Move existing behavior along appearance and seeded visit-planning ownership seams without changing runtime behavior. Extract only the bounded logo stand-in, WebAudio chirp, and pure render-template helpers while keeping the state machine in the element. Remove the grandfathered max-lines suppression and baseline entry. * style(ui): format lobster-pet split modules * fix(ui): keep lobster split internals unexported, refresh i18n raw-copy baseline knip flags the split's newly exported internals (strangerLookFor, renderCrabSvg, lobsterPetSpriteStyle, LobsterPasserKind) — they have no external consumers; the html-text raw-copy baseline is file-keyed, so the sprite z/Z markup moving into lobster-pet-look.ts needs a regen. * fix(ui): satisfy lint for the lobster-pet split (param reassign, max-lines) * fix(ui): keep moved act-profile internals unexported --- config/max-lines-baseline.txt | 1 - ui/src/components/lobster-pet-audio.ts | 46 + ui/src/components/lobster-pet-look.ts | 760 +++++++++++++ ui/src/components/lobster-pet-plans.ts | 239 +++++ ui/src/components/lobster-pet-standin.ts | 48 + ui/src/components/lobster-pet.ts | 1230 +++------------------- ui/src/i18n/.i18n/raw-copy-baseline.json | 4 +- 7 files changed, 1222 insertions(+), 1106 deletions(-) create mode 100644 ui/src/components/lobster-pet-audio.ts create mode 100644 ui/src/components/lobster-pet-look.ts create mode 100644 ui/src/components/lobster-pet-plans.ts create mode 100644 ui/src/components/lobster-pet-standin.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index c48504bbe4f7..ac4a17e2c0dc 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -1101,7 +1101,6 @@ ui/src/api/types.ts ui/src/app/app-host.ts ui/src/components/browser/browser-panel.ts ui/src/components/config-form.node.ts -ui/src/components/lobster-pet.ts ui/src/components/markdown.ts ui/src/components/terminal/terminal-panel.ts ui/src/e2e/chat-flow.e2e.test.ts diff --git a/ui/src/components/lobster-pet-audio.ts b/ui/src/components/lobster-pet-audio.ts new file mode 100644 index 000000000000..8eb57d7d0cf6 --- /dev/null +++ b/ui/src/components/lobster-pet-audio.ts @@ -0,0 +1,46 @@ +export type LobsterPetChirpKind = "poke" | "pet"; + +// Opt-in (default off) tiny synth chirps: a descending blub for pokes, a +// rising coo for pets. Only ever called from pointer gestures, so the +// AudioContext is created inside a user activation and never blocked by +// autoplay policy. Sound is decoration - any audio failure is swallowed. +export function playLobsterPetChirp( + audioCtx: AudioContext | null, + soundsEnabled: boolean, + kind: LobsterPetChirpKind, +): AudioContext | null { + let ctx = audioCtx; + if (!soundsEnabled) { + return ctx; + } + try { + const Ctor = window.AudioContext; + if (!Ctor) { + return ctx; + } + ctx ??= new Ctor(); + if (ctx.state === "suspended") { + void ctx.resume(); + } + const at = ctx.currentTime; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = "sine"; + if (kind === "poke") { + osc.frequency.setValueAtTime(330, at); + osc.frequency.exponentialRampToValueAtTime(165, at + 0.09); + } else { + osc.frequency.setValueAtTime(392, at); + osc.frequency.exponentialRampToValueAtTime(523, at + 0.18); + } + gain.gain.setValueAtTime(0.0001, at); + gain.gain.exponentialRampToValueAtTime(0.05, at + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, at + (kind === "poke" ? 0.12 : 0.24)); + osc.connect(gain).connect(ctx.destination); + osc.start(at); + osc.stop(at + 0.26); + } catch { + // never let audio break the pet + } + return ctx; +} diff --git a/ui/src/components/lobster-pet-look.ts b/ui/src/components/lobster-pet-look.ts new file mode 100644 index 000000000000..0f8bac3f0e0e --- /dev/null +++ b/ui/src/components/lobster-pet-look.ts @@ -0,0 +1,760 @@ +import { expectDefined } from "@openclaw/normalization-core"; +import { html, nothing, svg, type TemplateResult } from "lit"; +import { lobsterHonorific } from "./lobster-dex.ts"; +import type { + LobsterPetAccessory, + LobsterPetAntennae, + LobsterPetBuild, + LobsterPetClawSize, + LobsterPetLook, + LobsterPetMode, + LobsterPetPalette, + LobsterPetPaletteId, + LobsterPetPersonalityId, +} from "./lobster-pet-contract.ts"; + +// Rarity ladder loosely mirrors real lobster genetics: blue ~1 in 2 million, +// yellow ~1 in 30 million, calico ~1 in 30 million, split two-tone ~1 in +// 50 million, albino/ghost ~1 in 100 million. Abyss is our deep-sea fantasy. +// Split/calico extra geometry and ghost/abyss styling key off the palette id +// (see lobster-pet.css and renderLobsterSvg). +const PALETTES: Array<[LobsterPetPalette, number]> = [ + [{ id: "crimson", shell: "#ff4f40", claw: "#ff775f" }, 26], + [{ id: "coral", shell: "#d0836a", claw: "#de9b80" }, 26], + [{ id: "teal", shell: "#2fbfa7", claw: "#5cd9c4" }, 10], + [{ id: "violet", shell: "#9f7dfa", claw: "#bba4fd" }, 10], + [{ id: "ink", shell: "#5e6b7a", claw: "#7b8996" }, 9], + [{ id: "blue", shell: "#4a7dfc", claw: "#7fa4ff" }, 7], + [{ id: "gold", shell: "#f4b840", claw: "#f9d47a" }, 5], + [{ id: "calico", shell: "#d97a3d", claw: "#e89a63" }, 3], + [{ id: "abyss", shell: "#2c3b68", claw: "#465b96" }, 2], + [{ id: "ghost", shell: "#dce8f2", claw: "#ecf3fa" }, 1], + [{ id: "split", shell: "#ff4f40", claw: "#ff775f" }, 1], + // The grail: homage to the classic OpenClaw logo (big raised claw, smirk, + // angry brows, white sticker outline). ~0.5% of sessions. + [{ id: "retro", shell: "#e8262c", claw: "#f04a3e" }, 0.5], +]; + +// Catalog order for collection UIs (Lobsterdex): common to grail. +export const LOBSTER_PET_PALETTES: readonly LobsterPetPalette[] = PALETTES.map( + ([palette]) => palette, +); + +// A neutral look used to render catalog minis outside the pet lifecycle. +export function canonicalLobsterLook(palette: LobsterPetPalette): LobsterPetLook { + return { + palette, + scale: 2, + accessory: "none", + antennae: "perky", + side: "left", + spotPct: 0, + facing: 1, + personality: "friendly", + blinkDelayS: 0, + build: "round", + clawSize: "regular", + tailFan: false, + }; +} + +const ACCESSORIES: Array<[LobsterPetAccessory, number]> = [ + ["none", 62], + ["sprout", 14], + ["patch", 14], + ["crown", 10], +]; + +// OpenClaw's repository was born 2025-11-24 (GitHub created_at); on the +// anniversary every visitor dresses as the classic logo and parties. +const ANNIVERSARY = { month: 10, day: 24 } as const; + +function isLobsterAnniversary(now: Date): boolean { + return now.getMonth() === ANNIVERSARY.month && now.getDate() === ANNIVERSARY.day; +} + +// Seasonal wardrobe: extra accessory entries join the pool on the right +// dates. One weighted roll either way, so the rest of the look sequence is +// unchanged on any given seed. +function seasonalAccessories(now: Date): Array<[LobsterPetAccessory, number]> { + const month = now.getMonth(); + const day = now.getDate(); + if (month === 11) { + return [["santa", 18]]; + } + if (month === 9 && day >= 20) { + return [["pumpkin", 18]]; + } + return []; +} + +const PERSONALITY_IDS: Array<[LobsterPetPersonalityId, number]> = [ + ["sleepy", 25], + ["zoomy", 25], + ["friendly", 25], + ["showoff", 25], +]; + +const SCALES: Array<[number, number]> = [ + [1.7, 25], + [2, 55], + [2.5, 20], +]; + +const BUILDS: Array<[LobsterPetBuild, number]> = [ + ["round", 40], + ["squat", 30], + ["slender", 30], +]; + +const CLAW_SIZES: Array<[LobsterPetClawSize, number]> = [ + ["regular", 55], + ["dainty", 25], + ["mighty", 20], +]; + +// Builds reshape the whole sprite by stretching its aspect ratio (the svg +// renders with preserveAspectRatio="none"), so eyes, claws, accessories, and +// rare-variant geometry stay aligned for every silhouette. +export const LOBSTER_PET_BUILD_MULS: Record = { + round: { w: 1, h: 1 }, + squat: { w: 1.14, h: 0.9 }, + slender: { w: 0.88, h: 1.1 }, +}; + +export const LOBSTER_PET_CLAW_MULS: Record = { + dainty: 0.85, + regular: 1, + mighty: 1.18, +}; + +// Seeded pet names; rare palettes carry signature names. Shown via the +// sprite's native title tooltip, so no i18n surface. +const PET_NAMES = [ + "Pinchy", + "Barnaby", + "Thermidor", + "Clawdette", + "Sheldon", + "Scuttles", + "Bisque", + "Crusty", + "Snips", + "Bubbles", + "Clawdia", + "Ferdinand", + "Maple", + "Pearl", + "Biscuit", + "Captain", + "Ziggy", + "Noodle", + "Waffles", + "Pippin", + "Squirt", + "Chip", + "Clementine", + "Moss", +] as const; + +const RARE_NAMES: Partial> = { + blue: "Blueberry", + gold: "Goldie", + calico: "Patches", + abyss: "Lantern", + ghost: "Boo", + split: "Picasso", + retro: "OG", +}; + +export function lobsterPetName(look: LobsterPetLook, seed: number): string { + return ( + RARE_NAMES[look.palette.id] ?? + expectDefined(PET_NAMES[(seed >>> 3) % PET_NAMES.length], "lobster pet name catalog entry") + ); +} + +// A stranger wears a different palette than the resident pet. +function strangerLookFor(seed: number, own: LobsterPetPaletteId): LobsterPetLook { + for (let offset = 1; offset <= 24; offset++) { + const look = createLobsterPetLook((seed + offset * 7919) >>> 0); + if (look.palette.id !== own) { + return look; + } + } + return createLobsterPetLook((seed + 1) >>> 0); +} + +export function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function pickWeighted(rng: () => number, entries: Array<[T, number]>): T { + const total = entries.reduce((sum, [, weight]) => sum + weight, 0); + let roll = rng() * total; + for (const [value, weight] of entries) { + roll -= weight; + if (roll <= 0) { + return value; + } + } + return expectDefined(entries.at(-1), "weighted lobster choice fallback")[0]; +} + +export function randomBetween(rng: () => number, min: number, max: number): number { + return min + rng() * (max - min); +} + +export function createLobsterPetLook(seed: number, now: Date = new Date()): LobsterPetLook { + const rng = mulberry32(seed); + const palette = pickWeighted(rng, PALETTES); + const scale = pickWeighted(rng, SCALES); + const accessory = pickWeighted(rng, [...ACCESSORIES, ...seasonalAccessories(now)]); + const antennae: LobsterPetAntennae = rng() < 0.6 ? "perky" : "droopy"; + const side = rng() < 0.5 ? "left" : "right"; + const zone = SPOT_ZONES[side]; + const spotPct = Math.round(randomBetween(rng, zone[0], zone[1])); + const facing = rng() < 0.5 ? 1 : -1; + const personality = pickWeighted(rng, PERSONALITY_IDS); + const blinkDelayS = Math.round(randomBetween(rng, 0, 4) * 10) / 10; + // Shape traits roll after the original ones so pre-existing seeds keep + // their palette/personality and only gain a silhouette. + const build = pickWeighted(rng, BUILDS); + const clawSize = pickWeighted(rng, CLAW_SIZES); + const tailFan = rng() < 0.3; + if (isLobsterAnniversary(now)) { + // Birthday dress code: everyone is the classic logo, party hats on. + const retro = PALETTES.find(([entry]) => entry.id === "retro")?.[0]; + return { + palette: retro ?? palette, + scale, + accessory: "party", + antennae, + side, + spotPct, + facing, + personality, + blinkDelayS, + build, + clawSize, + tailFan, + }; + } + return { + palette, + scale, + accessory, + antennae, + side, + spotPct, + facing, + personality, + blinkDelayS, + build, + clawSize, + tailFan, + }; +} + +const ACCESSORY_SPRITES: Record, TemplateResult> = { + crown: svg` + + `, + sprout: svg` + + + + + `, + patch: svg` + + + + + `, + santa: svg` + + + + + + `, + pumpkin: svg` + + + + + + `, + party: svg` + + + + + + `, +}; + +// Calico mottling: dark blotches scattered clear of the eye line. +const CALICO_SPOTS = svg` + + + + + + + + +`; + +// Split two-tone: the right half of the body (down to the belly midline) +// repainted in the second shell color; the right claw and antenna follow via +// CSS. Mirrors the famous bilateral half-and-half lobsters. +const SPLIT_HALF = svg` + +`; + +// Retro homage parts (classic OpenClaw logo): one oversized raised claw with +// a pincer notch, tall V antennae, angry brows, and a smirk. The mega claw +// lives inside the .lob-claw--r group so wave/snip acts swing it. +const RETRO_MEGA_CLAW = svg` + + +`; + +const RETRO_ANTENNAE = svg` + + + + +`; + +const RETRO_FACE = svg` + + + + + +`; + +// Tail-fan lobes peek out diagonally behind the lower body (drawn before the +// body path so they read as "behind"). Fill color lives in lobster-pet.css. +const TAIL_FAN = svg` + + + + +`; + +// Moving-day bindle: a stick over the shoulder with a polka-dot bundle, +// carried for the whole first load after a gateway upgrade. +const BINDLE = svg` + + + + + + + +`; + +// On lobster days (see src/shared/lobster-day.ts, shared with the CLI +// banner cousin) the pet wears a little sailor cap - unless the seed already +// rolled headwear, which keeps its place. +const HEADWEAR: ReadonlySet = new Set([ + "crown", + "sprout", + "santa", + "pumpkin", + "party", +]); + +const SAILOR_CAP = svg` + + + + + +`; + +// Shown while grumpy (poked too much): angry brows and a frown. +const GRUMPY_FACE = svg` + + + + + +`; + +const ANTENNAE_SPRITES: Record = { + perky: svg` + + + + + `, + droopy: svg` + + + + + `, +}; + +// Not a lobster. Wide shell, eye stalks, walks sideways across the ledge, +// and the Lobsterdex refuses to acknowledge it. +function renderCrabSvg() { + return svg` + + `; +} + +// Same species as icons.lobster / the dreams-scene sleeper: smooth dome body +// with stubby legs, side claws, antennae, and teal-glint eyes. +export function renderLobsterSvg( + look: LobsterPetLook, + options: { + grumpy?: boolean; + shell?: boolean; + sleeping?: boolean; + standalone?: boolean; + bindle?: boolean; + sailorCap?: boolean; + } = {}, +) { + return svg` + + `; +} + +export const SPOT_ZONES = { left: [12, 38], right: [60, 84] } as const; + +function lobsterPetSpriteStyle( + look: LobsterPetLook, + scale: number, + spotPct: number, + facing: 1 | -1, +) { + // Glint color stays class-driven (see lobster-pet.css): an inline + // --lob-glint would out-cascade the offline grey override. + return [ + `--lob-shell:${look.palette.shell}`, + `--lob-claw:${look.palette.claw}`, + `--lob-scale:${scale}`, + `--lob-x:${spotPct}%`, + `--lob-face:${facing}`, + `--lob-blink-delay:${look.blinkDelayS}s`, + `--lob-w:${LOBSTER_PET_BUILD_MULS[look.build].w}`, + `--lob-h:${LOBSTER_PET_BUILD_MULS[look.build].h}`, + `--lob-claw-scale:${LOBSTER_PET_CLAW_MULS[look.clawSize]}`, + ].join(";"); +} + +export function renderLobsterPetScene(args: { + look: LobsterPetLook; + mode: LobsterPetMode; + presence: "out" | "in" | "leaving"; + logoPerched: boolean; + shellVisible: boolean; + visitsEnabled: boolean; + dismissed: boolean; + passer: { kind: "stranger" | "crab"; direction: 1 | -1 } | null; + twinPlanned: boolean; + anniversary: boolean; + entering: boolean; + grumpy: boolean; + vigil: boolean; + act: string | null; + zone: readonly [number, number]; + spotPct: number; + facing: 1 | -1; + anchor: "ledge" | "bar"; + barMaxScale: number; + shellScale: number; + shellSpotPct: number; + familiarityVisits: number; + seed: number; + movingDay: boolean; + sailorDay: boolean; + onPointerDown: () => void; + onPointerUp: () => void; + onPointerCancel: () => void; + onContextMenu: (event: Event) => void; +}) { + const anchoredScale = (scale: number) => + args.anchor === "bar" ? Math.min(scale, args.barMaxScale) : scale; + const renderSprite = (twin: boolean) => { + // On the month/day anniversary of this palette's first Lobsterdex visit, + // the party hat overrides whatever accessory the seed rolled. + const dressed = + args.anniversary && args.look.accessory !== "party" + ? { ...args.look, accessory: "party" as const } + : args.look; + const classes = [ + "lobster-pet", + `lobster-pet--${args.mode}`, + `lobster-pet--palette-${args.look.palette.id}`, + twin ? "lobster-pet--twin" : "", + dressed.accessory === "party" ? "lobster-pet--party" : "", + args.presence === "leaving" ? "lobster-pet--away" : "", + args.entering ? "lobster-pet--entering" : "", + args.grumpy ? "lobster-pet--grumpy" : "", + args.vigil ? "lobster-pet--vigil" : "", + args.act ? `lobster-pet--act-${args.act}` : "", + ] + .filter(Boolean) + .join(" "); + // The twin tags along on the parent's trailing side and copies every act + // a beat later (--lob-act-delay feeds each act's animation-delay). + const spotPct = twin + ? Math.min( + args.zone[1], + Math.max(args.zone[0], args.spotPct + (args.facing === 1 ? -12 : 12)), + ) + : args.spotPct; + const scale = anchoredScale(twin ? args.look.scale * 0.55 : args.look.scale); + const style = twin + ? `${lobsterPetSpriteStyle(args.look, scale, spotPct, args.facing === 1 ? -1 : 1)};--lob-act-delay:0.18s` + : lobsterPetSpriteStyle(args.look, scale, spotPct, args.facing); + // Milestone honorifics come from the load-start familiarity snapshot, so + // a title never pops mid-visit; it is simply there next time. + const honorific = lobsterHonorific(args.familiarityVisits); + const baseName = lobsterPetName(args.look, args.seed); + const name = honorific ? `${honorific} ${baseName}` : baseName; + // The twin travels light; only the resident pet hauls the moving bindle. + const bindle = args.movingDay && !twin; + const title = twin ? `${name} Jr.` : bindle ? `${name} · just moved in` : name; + return html` + + `; + }; + // While the pet is upstairs playing logo, the ledge stays empty - one + // crab, two homes, never both at once. + const showSprites = args.presence !== "out" && !args.logoPerched; + // The shell may outlive the visit while it fades, but dismissal and the + // visits setting silence it like everything else. + const showShell = args.shellVisible && args.visitsEnabled && !args.dismissed; + const showPasser = args.passer !== null && args.visitsEnabled; + if (!showSprites && !showShell && !showPasser) { + return nothing; + } + // The abandoned shell: the pre-molt silhouette, frozen and slowly fading. + const shellStyle = lobsterPetSpriteStyle( + args.look, + anchoredScale(args.shellScale), + args.shellSpotPct, + args.facing, + ); + // A pass-through visitor: crosses the ledge once and is gone. Strangers + // are other lobsters (never your palette); the crab is, allegedly, also a + // lobster. Neither perches, neither counts for the Lobsterdex. + const passerLook = + args.passer?.kind === "stranger" ? strangerLookFor(args.seed, args.look.palette.id) : args.look; + const passerClasses = args.passer + ? [ + "lobster-pet", + "lobster-pet--passer", + args.passer.kind === "crab" + ? "lobster-pet--crab" + : `lobster-pet--palette-${passerLook.palette.id}`, + args.passer.direction === 1 ? "lobster-pet--passer-ltr" : "lobster-pet--passer-rtl", + ].join(" ") + : ""; + const passerStyle = + args.passer?.kind === "crab" + ? `--lob-scale:2;--lob-w:1;--lob-h:0.82;--lob-face:1` + : args.passer + ? lobsterPetSpriteStyle(passerLook, Math.min(passerLook.scale, 2), 0, args.passer.direction) + : ""; + return html` + ${showShell + ? html` + + ` + : nothing} + ${showSprites ? renderSprite(false) : nothing} + ${showSprites && args.twinPlanned ? renderSprite(true) : nothing} + ${showPasser && args.passer + ? html` + + ` + : nothing} + `; +} diff --git a/ui/src/components/lobster-pet-plans.ts b/ui/src/components/lobster-pet-plans.ts new file mode 100644 index 000000000000..2627d09e33db --- /dev/null +++ b/ui/src/components/lobster-pet-plans.ts @@ -0,0 +1,239 @@ +import { getSafeLocalStorage } from "../local-storage.ts"; +import type { + LobsterPetMode, + LobsterPetPersonalityId, + LobsterRunOutcome, +} from "./lobster-pet-contract.ts"; +import { mulberry32, SPOT_ZONES } from "./lobster-pet-look.ts"; + +export { SPOT_ZONES }; + +export type LobsterPetAct = + | "wave" + | "snip" + | "hop" + | "spin" + | "peek" + | "nap" + | "bubble" + | "scuttle" + | "startle" + | "cheer" + | "molt" + | "pet" + | "droop" + | "sweep"; + +type ActProfile = { + // [min, max] delay before the next act. + delayMs: [number, number]; + acts: Array<[LobsterPetAct, number]>; +}; + +// Act windows mirror the CSS animation durations in lobster-pet.css so jsdom +// tests and browsers clear acts on the same clock without animationend. +export const LOBSTER_PET_ACT_DURATION_MS: Record = { + wave: 1400, + snip: 1000, + hop: 750, + spin: 950, + peek: 1700, + nap: 4400, + bubble: 2600, + scuttle: 1250, + startle: 750, + cheer: 1300, + molt: 2600, + pet: 1500, + droop: 1600, + sweep: 1800, +}; + +const PERSONALITIES: Record = { + sleepy: { + delayMs: [6000, 12000], + acts: [ + ["nap", 40], + ["bubble", 20], + ["wave", 12], + ["scuttle", 12], + ["peek", 10], + ["hop", 6], + ], + }, + zoomy: { + delayMs: [2800, 6000], + acts: [ + ["scuttle", 42], + ["hop", 22], + ["spin", 12], + ["peek", 12], + ["wave", 12], + ], + }, + friendly: { + delayMs: [3600, 7500], + acts: [ + ["wave", 32], + ["snip", 22], + ["scuttle", 18], + ["hop", 14], + ["bubble", 14], + ], + }, + showoff: { + delayMs: [3600, 7500], + acts: [ + ["spin", 24], + ["snip", 22], + ["peek", 20], + ["hop", 18], + ["wave", 16], + ], + }, +}; + +// Busy and offline override the personality: the pet is a status indicator +// first. Busy scurries (no naps mid-run); offline paces and peeks. +const LOBSTER_PET_MODE_ACTS: Record, ActProfile> = { + busy: { + delayMs: [2200, 4500], + acts: [ + ["scuttle", 40], + ["hop", 20], + ["snip", 20], + ["wave", 12], + ["spin", 8], + ], + }, + offline: { + delayMs: [2800, 5600], + acts: [ + ["scuttle", 55], + ["peek", 30], + ["hop", 15], + ], + }, +}; + +export function resolveLobsterActProfile( + mode: LobsterPetMode, + personality: LobsterPetPersonalityId | null, + now: Date = new Date(), +): ActProfile | null { + if (mode === "busy" || mode === "offline") { + return LOBSTER_PET_MODE_ACTS[mode]; + } + if (isLobsterNightTime(now)) { + return PERSONALITIES.sleepy; + } + return personality ? PERSONALITIES[personality] : null; +} + +export function resolveLobsterFinishAct(outcome: LobsterRunOutcome): LobsterPetAct { + return outcome === "error" ? "droop" : outcome === "aborted" ? "startle" : "cheer"; +} + +export const ENTER_MS = 450; +export const LEAVE_MS = 350; +// One full ledge crossing for pass-through visitors. +export const PASSER_CROSS_MS = 11_000; + +export type LobsterPetAnchor = "ledge" | "bar"; + +// The historical bar visit keeps its compact left-to-center roaming and scale +// cap, while CSS places it on the same ledge as regular visits. +export const BAR_ZONE = [18, 50] as const; +export const BAR_MAX_SCALE = 1.7; + +// Visit cadence: seeded per load, the pet is a guest, not a fixture. A share +// of loads gets no visit at all; the rest get a first arrival within minutes, +// stays of a few minutes, and long gaps between returns. Disconnects summon +// the pet regardless of schedule (unless dismissed or disabled). +export const VISIT_SHY_CHANCE = 0.25; +export const VISIT_FIRST_DELAY_MS = [15_000, 180_000] as const; +export const VISIT_STAY_MS = [90_000, 300_000] as const; +export const VISIT_GAP_MS = [360_000, 1_080_000] as const; + +// Some ledge visits spook the logo: a beat after the crab settles in, the +// brand mark ducks away, and it pops back once the visit ends. Rolled from a +// dedicated seeded stream so tests can probe arrivals purely. +export const LOGO_SCARE_CHANCE = 0.3; +export const LOGO_SCARE_DELAY_MS = 900; + +// Rare-event loads, planned per seed so tests can probe them purely: a molt +// load sheds its shell during the first idle act and sizes up one tier; a +// twin load brings a mini copycat along on every visit. +export function isLobsterMoltLoad(seed: number): boolean { + return mulberry32((seed ^ 0x301d) >>> 0)() < 0.12; +} + +export function isLobsterTwinLoad(seed: number): boolean { + return mulberry32((seed ^ 0x7715) >>> 0)() < 0.04; +} + +// On a logo load the pet's first scheduled visit skips the ledge entirely: +// it climbs up top and fills in for the brand logo until the stay ends. +// Offline summons still report to the ledge - status duty outranks cosplay. +export function isLobsterLogoLoad(seed: number): boolean { + return mulberry32((seed ^ 0x1063) >>> 0)() < 0.12; +} + +type LobsterPasserKind = "stranger" | "crab"; + +export type LobsterPasserPlan = { + kind: LobsterPasserKind; + atMs: number; + direction: 1 | -1; +}; + +// Once per load, someone else might just... walk through. Strangers are +// other lobsters that never stop; the crab is not a lobster and refuses to +// discuss it. Neither counts for the Lobsterdex. +export function planLobsterPasser(seed: number): LobsterPasserPlan | null { + const rng = mulberry32((seed ^ 0xcab) >>> 0); + const roll = rng(); + if (roll >= 0.095) { + return null; + } + const kind: LobsterPasserKind = roll < 0.015 ? "crab" : "stranger"; + const atMs = Math.round(60_000 + rng() * 840_000); + const direction: 1 | -1 = rng() < 0.5 ? 1 : -1; + return { kind, atMs, direction }; +} + +// The pet notices gateway upgrades: the first page load on a new version, it +// shows up carrying a bindle (moving day). The very first version sighting +// only records a baseline - no bindle without a previous home. +const MOVING_DAY_KEY = "openclaw.control.lobsterpet.gatewayVersion.v1"; + +export function detectLobsterMovingDay(version: string): boolean { + try { + const storage = getSafeLocalStorage(); + if (!storage) { + return false; + } + const previous = storage.getItem(MOVING_DAY_KEY); + if (previous === version) { + return false; + } + storage.setItem(MOVING_DAY_KEY, version); + return previous !== null; + } catch { + return false; + } +} + +// Late-night visitors are always sleepy, whatever their daytime personality. +function isLobsterNightTime(now: Date = new Date()): boolean { + const hour = now.getHours(); + return hour >= 22 || hour < 6; +} + +export function prefersReducedMotion(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} diff --git a/ui/src/components/lobster-pet-standin.ts b/ui/src/components/lobster-pet-standin.ts new file mode 100644 index 000000000000..44c515c07ae9 --- /dev/null +++ b/ui/src/components/lobster-pet-standin.ts @@ -0,0 +1,48 @@ +import { html, LitElement, nothing } from "lit"; +import { property } from "lit/decorators.js"; +import type { LobsterLogoVisitDetail } from "./lobster-pet-contract.ts"; +import { + LOBSTER_PET_BUILD_MULS, + LOBSTER_PET_CLAW_MULS, + renderLobsterSvg, +} from "./lobster-pet-look.ts"; + +class LobsterLogoStandIn extends LitElement { + override createRenderRoot() { + return this; + } + + @property({ attribute: false }) visit: LobsterLogoVisitDetail | null = null; + + override render() { + const visit = this.visit; + if (!visit?.look) { + return nothing; + } + const look = visit.look; + const classes = [ + "sidebar-brand__pet", + `lobster-pet--palette-${look.palette.id}`, + visit.phase === "leaving" ? "sidebar-brand__pet--leaving" : "", + ] + .filter(Boolean) + .join(" "); + const style = [ + `--lob-shell:${look.palette.shell}`, + `--lob-claw:${look.palette.claw}`, + `--lob-blink-delay:${look.blinkDelayS}s`, + `--lob-w:${LOBSTER_PET_BUILD_MULS[look.build].w}`, + `--lob-h:${LOBSTER_PET_BUILD_MULS[look.build].h}`, + `--lob-claw-scale:${LOBSTER_PET_CLAW_MULS[look.clawSize]}`, + ].join(";"); + return html` + ${renderLobsterSvg(look)} + `; + } +} + +if (!customElements.get("openclaw-lobster-logo-standin")) { + customElements.define("openclaw-lobster-logo-standin", LobsterLogoStandIn); +} diff --git a/ui/src/components/lobster-pet.ts b/ui/src/components/lobster-pet.ts index d2ad4f1eec04..ef43fe01e50b 100644 --- a/ui/src/components/lobster-pet.ts +++ b/ui/src/components/lobster-pet.ts @@ -6,36 +6,15 @@ // every new session hatches a slightly different lobster. import "../styles/lobster-pet.css"; import { expectDefined } from "@openclaw/normalization-core"; -import { html, LitElement, nothing, svg, type TemplateResult } from "lit"; +import { LitElement, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { isLobsterDay } from "../../../src/shared/lobster-day.js"; -import { getSafeLocalStorage } from "../local-storage.ts"; -import { - LOBSTER_FAMILIARITY_TUNING, - getLobsterFamiliarity, - getLobsterdexEntries, - isLobsterFirstVisitAnniversary, - lobsterHonorific, - recordLobsterArrivalStats, - recordLobsterShoo, - recordLobsterVisit, - type LobsterFamiliarity, -} from "./lobster-dex.ts"; -import { - LOBSTER_LOGO_VISIT_EVENT, - type LobsterLogoVisitDetail, - type LobsterLogoVisitPhase, - type LobsterPetAccessory, - type LobsterPetAntennae, - type LobsterPetBuild, - type LobsterPetClawSize, - type LobsterPetLook, - type LobsterPetMode, - type LobsterPetPalette, - type LobsterPetPaletteId, - type LobsterPetPersonalityId, - type LobsterRunOutcome, -} from "./lobster-pet-contract.ts"; +import * as dex from "./lobster-dex.ts"; +import { playLobsterPetChirp, type LobsterPetChirpKind } from "./lobster-pet-audio.ts"; +import * as contract from "./lobster-pet-contract.ts"; +import * as lobsterLook from "./lobster-pet-look.ts"; +import "./lobster-pet-standin.ts"; +import * as plans from "./lobster-pet-plans.ts"; export { LOBSTER_LOGO_VISIT_EVENT, @@ -48,801 +27,14 @@ export { type LobsterPetMode, type LobsterRunOutcome, } from "./lobster-pet-contract.ts"; - -type LobsterPetAct = - | "wave" - | "snip" - | "hop" - | "spin" - | "peek" - | "nap" - | "bubble" - | "scuttle" - | "startle" - | "cheer" - | "molt" - | "pet" - | "droop" - | "sweep"; - -type ActProfile = { - // [min, max] delay before the next act. - delayMs: [number, number]; - acts: Array<[LobsterPetAct, number]>; -}; - -// Act windows mirror the CSS animation durations in lobster-pet.css so jsdom -// tests and browsers clear acts on the same clock without animationend. -const LOBSTER_PET_ACT_DURATION_MS: Record = { - wave: 1400, - snip: 1000, - hop: 750, - spin: 950, - peek: 1700, - nap: 4400, - bubble: 2600, - scuttle: 1250, - startle: 750, - cheer: 1300, - molt: 2600, - pet: 1500, - droop: 1600, - sweep: 1800, -}; - -const PERSONALITIES: Record = { - sleepy: { - delayMs: [6000, 12000], - acts: [ - ["nap", 40], - ["bubble", 20], - ["wave", 12], - ["scuttle", 12], - ["peek", 10], - ["hop", 6], - ], - }, - zoomy: { - delayMs: [2800, 6000], - acts: [ - ["scuttle", 42], - ["hop", 22], - ["spin", 12], - ["peek", 12], - ["wave", 12], - ], - }, - friendly: { - delayMs: [3600, 7500], - acts: [ - ["wave", 32], - ["snip", 22], - ["scuttle", 18], - ["hop", 14], - ["bubble", 14], - ], - }, - showoff: { - delayMs: [3600, 7500], - acts: [ - ["spin", 24], - ["snip", 22], - ["peek", 20], - ["hop", 18], - ["wave", 16], - ], - }, -}; - -// Busy and offline override the personality: the pet is a status indicator -// first. Busy scurries (no naps mid-run); offline paces and peeks. -const LOBSTER_PET_MODE_ACTS: Record, ActProfile> = { - busy: { - delayMs: [2200, 4500], - acts: [ - ["scuttle", 40], - ["hop", 20], - ["snip", 20], - ["wave", 12], - ["spin", 8], - ], - }, - offline: { - delayMs: [2800, 5600], - acts: [ - ["scuttle", 55], - ["peek", 30], - ["hop", 15], - ], - }, -}; - -// Rarity ladder loosely mirrors real lobster genetics: blue ~1 in 2 million, -// yellow ~1 in 30 million, calico ~1 in 30 million, split two-tone ~1 in -// 50 million, albino/ghost ~1 in 100 million. Abyss is our deep-sea fantasy. -// Split/calico extra geometry and ghost/abyss styling key off the palette id -// (see lobster-pet.css and renderLobsterSvg). -const PALETTES: Array<[LobsterPetPalette, number]> = [ - [{ id: "crimson", shell: "#ff4f40", claw: "#ff775f" }, 26], - [{ id: "coral", shell: "#d0836a", claw: "#de9b80" }, 26], - [{ id: "teal", shell: "#2fbfa7", claw: "#5cd9c4" }, 10], - [{ id: "violet", shell: "#9f7dfa", claw: "#bba4fd" }, 10], - [{ id: "ink", shell: "#5e6b7a", claw: "#7b8996" }, 9], - [{ id: "blue", shell: "#4a7dfc", claw: "#7fa4ff" }, 7], - [{ id: "gold", shell: "#f4b840", claw: "#f9d47a" }, 5], - [{ id: "calico", shell: "#d97a3d", claw: "#e89a63" }, 3], - [{ id: "abyss", shell: "#2c3b68", claw: "#465b96" }, 2], - [{ id: "ghost", shell: "#dce8f2", claw: "#ecf3fa" }, 1], - [{ id: "split", shell: "#ff4f40", claw: "#ff775f" }, 1], - // The grail: homage to the classic OpenClaw logo (big raised claw, smirk, - // angry brows, white sticker outline). ~0.5% of sessions. - [{ id: "retro", shell: "#e8262c", claw: "#f04a3e" }, 0.5], -]; - -// Catalog order for collection UIs (Lobsterdex): common to grail. -export const LOBSTER_PET_PALETTES: readonly LobsterPetPalette[] = PALETTES.map( - ([palette]) => palette, -); - -// A neutral look used to render catalog minis outside the pet lifecycle. -export function canonicalLobsterLook(palette: LobsterPetPalette): LobsterPetLook { - return { - palette, - scale: 2, - accessory: "none", - antennae: "perky", - side: "left", - spotPct: 0, - facing: 1, - personality: "friendly", - blinkDelayS: 0, - build: "round", - clawSize: "regular", - tailFan: false, - }; -} - -const ACCESSORIES: Array<[LobsterPetAccessory, number]> = [ - ["none", 62], - ["sprout", 14], - ["patch", 14], - ["crown", 10], -]; - -// OpenClaw's repository was born 2025-11-24 (GitHub created_at); on the -// anniversary every visitor dresses as the classic logo and parties. -const ANNIVERSARY = { month: 10, day: 24 } as const; - -function isLobsterAnniversary(now: Date): boolean { - return now.getMonth() === ANNIVERSARY.month && now.getDate() === ANNIVERSARY.day; -} - -// Seasonal wardrobe: extra accessory entries join the pool on the right -// dates. One weighted roll either way, so the rest of the look sequence is -// unchanged on any given seed. -function seasonalAccessories(now: Date): Array<[LobsterPetAccessory, number]> { - const month = now.getMonth(); - const day = now.getDate(); - if (month === 11) { - return [["santa", 18]]; - } - if (month === 9 && day >= 20) { - return [["pumpkin", 18]]; - } - return []; -} - -const PERSONALITY_IDS: Array<[LobsterPetPersonalityId, number]> = [ - ["sleepy", 25], - ["zoomy", 25], - ["friendly", 25], - ["showoff", 25], -]; - -const SCALES: Array<[number, number]> = [ - [1.7, 25], - [2, 55], - [2.5, 20], -]; - -const BUILDS: Array<[LobsterPetBuild, number]> = [ - ["round", 40], - ["squat", 30], - ["slender", 30], -]; - -const CLAW_SIZES: Array<[LobsterPetClawSize, number]> = [ - ["regular", 55], - ["dainty", 25], - ["mighty", 20], -]; - -// Builds reshape the whole sprite by stretching its aspect ratio (the svg -// renders with preserveAspectRatio="none"), so eyes, claws, accessories, and -// rare-variant geometry stay aligned for every silhouette. -export const LOBSTER_PET_BUILD_MULS: Record = { - round: { w: 1, h: 1 }, - squat: { w: 1.14, h: 0.9 }, - slender: { w: 0.88, h: 1.1 }, -}; - -export const LOBSTER_PET_CLAW_MULS: Record = { - dainty: 0.85, - regular: 1, - mighty: 1.18, -}; - -// Keep the perch off the footer center so tooltips and the theme toggle -// never sit under the sprite. -const SPOT_ZONES = { left: [12, 38], right: [60, 84] } as const; -const ENTER_MS = 450; -const LEAVE_MS = 350; -// One full ledge crossing for pass-through visitors. -const PASSER_CROSS_MS = 11_000; - -type LobsterPetAnchor = "ledge" | "bar"; - -// The historical bar visit keeps its compact left-to-center roaming and scale -// cap, while CSS places it on the same ledge as regular visits. -const BAR_ZONE = [18, 50] as const; -const BAR_MAX_SCALE = 1.7; - -// Visit cadence: seeded per load, the pet is a guest, not a fixture. A share -// of loads gets no visit at all; the rest get a first arrival within minutes, -// stays of a few minutes, and long gaps between returns. Disconnects summon -// the pet regardless of schedule (unless dismissed or disabled). -const VISIT_SHY_CHANCE = 0.25; -const VISIT_FIRST_DELAY_MS = [15_000, 180_000] as const; -const VISIT_STAY_MS = [90_000, 300_000] as const; -const VISIT_GAP_MS = [360_000, 1_080_000] as const; - -// Some ledge visits spook the logo: a beat after the crab settles in, the -// brand mark ducks away, and it pops back once the visit ends. Rolled from a -// dedicated seeded stream so tests can probe arrivals purely. -const LOGO_SCARE_CHANCE = 0.3; -const LOGO_SCARE_DELAY_MS = 900; - -// Seeded pet names; rare palettes carry signature names. Shown via the -// sprite's native title tooltip, so no i18n surface. -const PET_NAMES = [ - "Pinchy", - "Barnaby", - "Thermidor", - "Clawdette", - "Sheldon", - "Scuttles", - "Bisque", - "Crusty", - "Snips", - "Bubbles", - "Clawdia", - "Ferdinand", - "Maple", - "Pearl", - "Biscuit", - "Captain", - "Ziggy", - "Noodle", - "Waffles", - "Pippin", - "Squirt", - "Chip", - "Clementine", - "Moss", -] as const; - -const RARE_NAMES: Partial> = { - blue: "Blueberry", - gold: "Goldie", - calico: "Patches", - abyss: "Lantern", - ghost: "Boo", - split: "Picasso", - retro: "OG", -}; - -function lobsterPetName(look: LobsterPetLook, seed: number): string { - return ( - RARE_NAMES[look.palette.id] ?? - expectDefined(PET_NAMES[(seed >>> 3) % PET_NAMES.length], "lobster pet name catalog entry") - ); -} - -// Rare-event loads, planned per seed so tests can probe them purely: a molt -// load sheds its shell during the first idle act and sizes up one tier; a -// twin load brings a mini copycat along on every visit. -function isLobsterMoltLoad(seed: number): boolean { - return mulberry32((seed ^ 0x301d) >>> 0)() < 0.12; -} - -function isLobsterTwinLoad(seed: number): boolean { - return mulberry32((seed ^ 0x7715) >>> 0)() < 0.04; -} - -// On a logo load the pet's first scheduled visit skips the ledge entirely: -// it climbs up top and fills in for the brand logo until the stay ends. -// Offline summons still report to the ledge - status duty outranks cosplay. -function isLobsterLogoLoad(seed: number): boolean { - return mulberry32((seed ^ 0x1063) >>> 0)() < 0.12; -} - -type LobsterPasserKind = "stranger" | "crab"; - -type LobsterPasserPlan = { - kind: LobsterPasserKind; - atMs: number; - direction: 1 | -1; -}; - -// Once per load, someone else might just... walk through. Strangers are -// other lobsters that never stop; the crab is not a lobster and refuses to -// discuss it. Neither counts for the Lobsterdex. -function planLobsterPasser(seed: number): LobsterPasserPlan | null { - const rng = mulberry32((seed ^ 0xcab) >>> 0); - const roll = rng(); - if (roll >= 0.095) { - return null; - } - const kind: LobsterPasserKind = roll < 0.015 ? "crab" : "stranger"; - const atMs = Math.round(60_000 + rng() * 840_000); - const direction: 1 | -1 = rng() < 0.5 ? 1 : -1; - return { kind, atMs, direction }; -} - -// A stranger wears a different palette than the resident pet. -function strangerLookFor(seed: number, own: LobsterPetPaletteId): LobsterPetLook { - for (let offset = 1; offset <= 24; offset++) { - const look = createLobsterPetLook((seed + offset * 7919) >>> 0); - if (look.palette.id !== own) { - return look; - } - } - return createLobsterPetLook((seed + 1) >>> 0); -} - -// The pet notices gateway upgrades: the first page load on a new version, it -// shows up carrying a bindle (moving day). The very first version sighting -// only records a baseline - no bindle without a previous home. -const MOVING_DAY_KEY = "openclaw.control.lobsterpet.gatewayVersion.v1"; - -function detectLobsterMovingDay(version: string): boolean { - try { - const storage = getSafeLocalStorage(); - if (!storage) { - return false; - } - const previous = storage.getItem(MOVING_DAY_KEY); - if (previous === version) { - return false; - } - storage.setItem(MOVING_DAY_KEY, version); - return previous !== null; - } catch { - return false; - } -} - -// Late-night visitors are always sleepy, whatever their daytime personality. -function isLobsterNightTime(now: Date = new Date()): boolean { - const hour = now.getHours(); - return hour >= 22 || hour < 6; -} - -function mulberry32(seed: number): () => number { - let a = seed >>> 0; - return () => { - a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -function pickWeighted(rng: () => number, entries: Array<[T, number]>): T { - const total = entries.reduce((sum, [, weight]) => sum + weight, 0); - let roll = rng() * total; - for (const [value, weight] of entries) { - roll -= weight; - if (roll <= 0) { - return value; - } - } - return expectDefined(entries.at(-1), "weighted lobster choice fallback")[0]; -} - -function randomBetween(rng: () => number, min: number, max: number): number { - return min + rng() * (max - min); -} - -export function createLobsterPetLook(seed: number, now: Date = new Date()): LobsterPetLook { - const rng = mulberry32(seed); - const palette = pickWeighted(rng, PALETTES); - const scale = pickWeighted(rng, SCALES); - const accessory = pickWeighted(rng, [...ACCESSORIES, ...seasonalAccessories(now)]); - const antennae: LobsterPetAntennae = rng() < 0.6 ? "perky" : "droopy"; - const side = rng() < 0.5 ? "left" : "right"; - const zone = SPOT_ZONES[side]; - const spotPct = Math.round(randomBetween(rng, zone[0], zone[1])); - const facing = rng() < 0.5 ? 1 : -1; - const personality = pickWeighted(rng, PERSONALITY_IDS); - const blinkDelayS = Math.round(randomBetween(rng, 0, 4) * 10) / 10; - // Shape traits roll after the original ones so pre-existing seeds keep - // their palette/personality and only gain a silhouette. - const build = pickWeighted(rng, BUILDS); - const clawSize = pickWeighted(rng, CLAW_SIZES); - const tailFan = rng() < 0.3; - if (isLobsterAnniversary(now)) { - // Birthday dress code: everyone is the classic logo, party hats on. - const retro = PALETTES.find(([entry]) => entry.id === "retro")?.[0]; - return { - palette: retro ?? palette, - scale, - accessory: "party", - antennae, - side, - spotPct, - facing, - personality, - blinkDelayS, - build, - clawSize, - tailFan, - }; - } - return { - palette, - scale, - accessory, - antennae, - side, - spotPct, - facing, - personality, - blinkDelayS, - build, - clawSize, - tailFan, - }; -} - -function prefersReducedMotion(): boolean { - return ( - typeof window !== "undefined" && - typeof window.matchMedia === "function" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches - ); -} - -const ACCESSORY_SPRITES: Record, TemplateResult> = { - crown: svg` - - `, - sprout: svg` - - - - - `, - patch: svg` - - - - - `, - santa: svg` - - - - - - `, - pumpkin: svg` - - - - - - `, - party: svg` - - - - - - `, -}; - -// Calico mottling: dark blotches scattered clear of the eye line. -const CALICO_SPOTS = svg` - - - - - - - - -`; - -// Split two-tone: the right half of the body (down to the belly midline) -// repainted in the second shell color; the right claw and antenna follow via -// CSS. Mirrors the famous bilateral half-and-half lobsters. -const SPLIT_HALF = svg` - -`; - -// Retro homage parts (classic OpenClaw logo): one oversized raised claw with -// a pincer notch, tall V antennae, angry brows, and a smirk. The mega claw -// lives inside the .lob-claw--r group so wave/snip acts swing it. -const RETRO_MEGA_CLAW = svg` - - -`; - -const RETRO_ANTENNAE = svg` - - - - -`; - -const RETRO_FACE = svg` - - - - - -`; - -// Tail-fan lobes peek out diagonally behind the lower body (drawn before the -// body path so they read as "behind"). Fill color lives in lobster-pet.css. -const TAIL_FAN = svg` - - - - -`; - -// Moving-day bindle: a stick over the shoulder with a polka-dot bundle, -// carried for the whole first load after a gateway upgrade. -const BINDLE = svg` - - - - - - - -`; - -// On lobster days (see src/shared/lobster-day.ts, shared with the CLI -// banner cousin) the pet wears a little sailor cap - unless the seed already -// rolled headwear, which keeps its place. -const HEADWEAR: ReadonlySet = new Set([ - "crown", - "sprout", - "santa", - "pumpkin", - "party", -]); - -const SAILOR_CAP = svg` - - - - - -`; - -// Shown while grumpy (poked too much): angry brows and a frown. -const GRUMPY_FACE = svg` - - - - - -`; - -const ANTENNAE_SPRITES: Record = { - perky: svg` - - - - - `, - droopy: svg` - - - - - `, -}; - -// Not a lobster. Wide shell, eye stalks, walks sideways across the ledge, -// and the Lobsterdex refuses to acknowledge it. -function renderCrabSvg() { - return svg` - - `; -} - -// Same species as icons.lobster / the dreams-scene sleeper: smooth dome body -// with stubby legs, side claws, antennae, and teal-glint eyes. -export function renderLobsterSvg( - look: LobsterPetLook, - options: { - grumpy?: boolean; - shell?: boolean; - sleeping?: boolean; - standalone?: boolean; - bindle?: boolean; - sailorCap?: boolean; - } = {}, -) { - return svg` - - `; -} - -class LobsterLogoStandIn extends LitElement { - override createRenderRoot() { - return this; - } - - @property({ attribute: false }) visit: LobsterLogoVisitDetail | null = null; - - override render() { - const visit = this.visit; - if (!visit?.look) { - return nothing; - } - const look = visit.look; - const classes = [ - "sidebar-brand__pet", - `lobster-pet--palette-${look.palette.id}`, - visit.phase === "leaving" ? "sidebar-brand__pet--leaving" : "", - ] - .filter(Boolean) - .join(" "); - const style = [ - `--lob-shell:${look.palette.shell}`, - `--lob-claw:${look.palette.claw}`, - `--lob-blink-delay:${look.blinkDelayS}s`, - `--lob-w:${LOBSTER_PET_BUILD_MULS[look.build].w}`, - `--lob-h:${LOBSTER_PET_BUILD_MULS[look.build].h}`, - `--lob-claw-scale:${LOBSTER_PET_CLAW_MULS[look.clawSize]}`, - ].join(";"); - return html` - ${renderLobsterSvg(look)} - `; - } -} +export { + LOBSTER_PET_BUILD_MULS, + LOBSTER_PET_CLAW_MULS, + LOBSTER_PET_PALETTES, + canonicalLobsterLook, + createLobsterPetLook, + renderLobsterSvg, +} from "./lobster-pet-look.ts"; class LobsterPet extends LitElement { override createRenderRoot() { @@ -850,33 +42,33 @@ class LobsterPet extends LitElement { } @property({ attribute: false }) seed = 0; - @property({ attribute: false }) mode: LobsterPetMode = "idle"; + @property({ attribute: false }) mode: contract.LobsterPetMode = "idle"; @property({ attribute: false }) visitsEnabled = true; - @property({ attribute: false }) runOutcome: LobsterRunOutcome = "ok"; + @property({ attribute: false }) runOutcome: contract.LobsterRunOutcome = "ok"; @property({ attribute: false }) soundsEnabled = false; @property({ attribute: false }) gatewayVersion: string | null = null; - @state() private act: LobsterPetAct | null = null; + @state() private act: plans.LobsterPetAct | null = null; @state() private spotPct = 80; @state() private facing: 1 | -1 = 1; @state() private entering = false; @state() private presence: "out" | "in" | "leaving" = "out"; - @state() private anchor: LobsterPetAnchor = "ledge"; + @state() private anchor: plans.LobsterPetAnchor = "ledge"; @state() private scheduledVisiting = false; @state() private logoPerched = false; @state() private logoScared = false; private logoScarePending = false; private logoScareTimer: number | null = null; - private scareRng: () => number = mulberry32(0); + private scareRng: () => number = lobsterLook.mulberry32(0); private logoPlanned = false; private logoDone = false; - private lastLogoPhase: LobsterLogoVisitPhase = "out"; + private lastLogoPhase: contract.LobsterLogoVisitPhase = "out"; @state() private dismissed = false; @state() private grumpy = false; @state() private vigil = false; @state() private outcomePresenceOwner: "vigil" | null = null; - @state() private passer: LobsterPasserPlan | null = null; + @state() private passer: plans.LobsterPasserPlan | null = null; @state() private movingDay = false; private movingDayChecked = false; @state() private anniversary = false; @@ -891,12 +83,17 @@ class LobsterPet extends LitElement { private passerTimer: number | null = null; private passerEndTimer: number | null = null; private passerWatchTimer: number | null = null; - private familiarity: LobsterFamiliarity = { tier: "regular", wary: false, visits: 0, shoos: 0 }; + private familiarity: dex.LobsterFamiliarity = { + tier: "regular", + wary: false, + visits: 0, + shoos: 0, + }; private greetedThisLoad = false; - private look: LobsterPetLook | null = null; - private rng: () => number = mulberry32(0); - private visitRng: () => number = mulberry32(0); + private look: contract.LobsterPetLook | null = null; + private rng: () => number = lobsterLook.mulberry32(0); + private visitRng: () => number = lobsterLook.mulberry32(0); private idleTimer: number | null = null; private actEndTimer: number | null = null; private enterTimer: number | null = null; @@ -969,10 +166,10 @@ class LobsterPet extends LitElement { override willUpdate(changed: Map) { const seedChanged = this.look === null || changed.has("seed"); if (seedChanged) { - this.look = createLobsterPetLook(this.seed); - this.rng = mulberry32(this.seed ^ 0x9e3779b9); - this.visitRng = mulberry32(this.seed ^ 0x5eaf00d); - this.scareRng = mulberry32((this.seed ^ 0x5ca2e) >>> 0); + this.look = lobsterLook.createLobsterPetLook(this.seed); + this.rng = lobsterLook.mulberry32(this.seed ^ 0x9e3779b9); + this.visitRng = lobsterLook.mulberry32(this.seed ^ 0x5eaf00d); + this.scareRng = lobsterLook.mulberry32((this.seed ^ 0x5ca2e) >>> 0); this.spotPct = this.look.spotPct; this.facing = this.look.facing; // Reset the act loop inside the update pass; deferring state flips to @@ -988,9 +185,9 @@ class LobsterPet extends LitElement { window.clearTimeout(this.shellTimer); this.shellTimer = null; } - this.moltPlanned = isLobsterMoltLoad(this.seed); - this.twinPlanned = isLobsterTwinLoad(this.seed); - this.logoPlanned = isLobsterLogoLoad(this.seed); + this.moltPlanned = plans.isLobsterMoltLoad(this.seed); + this.twinPlanned = plans.isLobsterTwinLoad(this.seed); + this.logoPlanned = plans.isLobsterLogoLoad(this.seed); this.logoDone = false; this.logoPerched = false; this.logoScared = false; @@ -999,7 +196,7 @@ class LobsterPet extends LitElement { window.clearTimeout(this.logoScareTimer); this.logoScareTimer = null; } - this.familiarity = getLobsterFamiliarity(); + this.familiarity = dex.getLobsterFamiliarity(); this.sailorDay = isLobsterDay(new Date()); this.greetedThisLoad = false; this.scheduleVisits(); @@ -1016,23 +213,18 @@ class LobsterPet extends LitElement { if (this.logoPerched && this.mode !== "idle") { this.logoPerched = false; } - const previousMode = changed.get("mode") as LobsterPetMode | undefined; + const previousMode = changed.get("mode") as contract.LobsterPetMode | undefined; const finished = previousMode === "busy" && this.mode === "idle"; const presenceOwner = finished && this.vigil ? "vigil" : null; this.trackVigil(); - if (this.presence === "in" && !prefersReducedMotion()) { + if (this.presence === "in" && !plans.prefersReducedMotion()) { // Status flips get an immediate reaction. A finished run (busy -> // idle) earns a cheer when it succeeded and a sympathetic droop when // it failed; everything else startles. The act-end timer then // reschedules from the new mode's pool. // Success cheers, failure droops, a user abort is nothing to // celebrate or mourn - just acknowledge the change. - const finishAct = - this.runOutcome === "error" - ? "droop" - : this.runOutcome === "aborted" - ? "startle" - : "cheer"; + const finishAct = plans.resolveLobsterFinishAct(this.runOutcome); this.performAct(finished ? finishAct : "startle", presenceOwner); } } @@ -1040,7 +232,7 @@ class LobsterPet extends LitElement { // known (the hello can land after the first render). if (!this.movingDayChecked && this.gatewayVersion) { this.movingDayChecked = true; - this.movingDay = detectLobsterMovingDay(this.gatewayVersion); + this.movingDay = plans.detectLobsterMovingDay(this.gatewayVersion); } this.reconcilePresence(); } @@ -1067,30 +259,30 @@ class LobsterPet extends LitElement { // One scare roll per arrival keeps the stream aligned across visit // kinds; only an idle ledge visit may spook the logo (a perch owns // the slot outright, offline summons are on status duty). - const scareRolled = this.scareRng() < LOGO_SCARE_CHANCE; + const scareRolled = this.scareRng() < plans.LOGO_SCARE_CHANCE; this.logoScarePending = scareRolled && !this.logoPerched && this.scheduledVisiting && this.mode === "idle" && - !prefersReducedMotion(); + !plans.prefersReducedMotion(); if (this.look) { // Anniversary check reads the dex before this arrival records into // it: a first-ever visit today must not celebrate itself. - this.anniversary = isLobsterFirstVisitAnniversary( - getLobsterdexEntries().get(this.look.palette.id)?.firstSeenAt ?? null, + this.anniversary = dex.isLobsterFirstVisitAnniversary( + dex.getLobsterdexEntries().get(this.look.palette.id)?.firstSeenAt ?? null, new Date(), ); // Every genuine arrival (visit or offline summon) logs the palette // with the first visitor's name, and bumps the familiarity count. - recordLobsterVisit(this.look.palette.id, { - name: lobsterPetName(this.look, this.seed), + dex.recordLobsterVisit(this.look.palette.id, { + name: lobsterLook.lobsterPetName(this.look, this.seed), }); - recordLobsterArrivalStats(); + dex.recordLobsterArrivalStats(); } } this.presence = "in"; - this.entering = !prefersReducedMotion(); + this.entering = !plans.prefersReducedMotion(); this.restartPending = true; return; } @@ -1112,7 +304,7 @@ class LobsterPet extends LitElement { // The "out" edge is the single restore point: the logo fades back // in the same update that ends the visit, scare or perch alike. this.logoScared = false; - }, LEAVE_MS); + }, plans.LEAVE_MS); } } @@ -1132,12 +324,12 @@ class LobsterPet extends LitElement { this.familiarity.tier === "friend" && this.presence === "in" && !this.logoPerched && - !prefersReducedMotion() + !plans.prefersReducedMotion() ) { this.greetedThisLoad = true; this.performAct("wave"); } - }, ENTER_MS); + }, plans.ENTER_MS); if (this.logoScarePending) { this.logoScarePending = false; // The beat between arrival and the duck is what sells "the crab scared @@ -1147,12 +339,12 @@ class LobsterPet extends LitElement { if (this.presence === "in" && !this.logoPerched) { this.logoScared = true; } - }, LOGO_SCARE_DELAY_MS); + }, plans.LOGO_SCARE_DELAY_MS); } this.scheduleNextAct(); } - private logoVisitPhase(): LobsterLogoVisitPhase { + private logoVisitPhase(): contract.LobsterLogoVisitPhase { const occupied = this.logoPerched || this.logoScared; if (!occupied || !this.visitsEnabled || this.dismissed) { return "out"; @@ -1177,11 +369,11 @@ class LobsterPet extends LitElement { ? { ...look, accessory: "party" as const } : look; this.dispatchEvent( - new CustomEvent(LOBSTER_LOGO_VISIT_EVENT, { + new CustomEvent(contract.LOBSTER_LOGO_VISIT_EVENT, { detail: { phase, look: dressed, - name: dressed ? lobsterPetName(dressed, this.seed) : null, + name: dressed ? lobsterLook.lobsterPetName(dressed, this.seed) : null, }, bubbles: true, composed: true, @@ -1204,7 +396,7 @@ class LobsterPet extends LitElement { // it grumpy for a minute, 10 send it off in a huff until a later visit. // Offline pets are on duty and never huff. private readonly handleHoldStart = () => { - if (prefersReducedMotion()) { + if (plans.prefersReducedMotion()) { return; } this.holdPetted = false; @@ -1239,44 +431,8 @@ class LobsterPet extends LitElement { this.holdPetted = false; }; - // Opt-in (default off) tiny synth chirps: a descending blub for pokes, a - // rising coo for pets. Only ever called from pointer gestures, so the - // AudioContext is created inside a user activation and never blocked by - // autoplay policy. Sound is decoration - any audio failure is swallowed. - private playChirp(kind: "poke" | "pet") { - if (!this.soundsEnabled) { - return; - } - try { - const Ctor = window.AudioContext; - if (!Ctor) { - return; - } - this.audioCtx ??= new Ctor(); - const ctx = this.audioCtx; - if (ctx.state === "suspended") { - void ctx.resume(); - } - const at = ctx.currentTime; - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = "sine"; - if (kind === "poke") { - osc.frequency.setValueAtTime(330, at); - osc.frequency.exponentialRampToValueAtTime(165, at + 0.09); - } else { - osc.frequency.setValueAtTime(392, at); - osc.frequency.exponentialRampToValueAtTime(523, at + 0.18); - } - gain.gain.setValueAtTime(0.0001, at); - gain.gain.exponentialRampToValueAtTime(0.05, at + 0.02); - gain.gain.exponentialRampToValueAtTime(0.0001, at + (kind === "poke" ? 0.12 : 0.24)); - osc.connect(gain).connect(ctx.destination); - osc.start(at); - osc.stop(at + 0.26); - } catch { - // never let audio break the pet - } + private playChirp(kind: LobsterPetChirpKind) { + this.audioCtx = playLobsterPetChirp(this.audioCtx, this.soundsEnabled, kind); } private pokeNow() { @@ -1311,7 +467,9 @@ class LobsterPet extends LitElement { // still returns on a later scheduled visit. this.clearVisitTimers(); this.scheduledVisiting = false; - this.armArrival(randomBetween(this.visitRng, VISIT_GAP_MS[0], VISIT_GAP_MS[1])); + this.armArrival( + lobsterLook.randomBetween(this.visitRng, plans.VISIT_GAP_MS[0], plans.VISIT_GAP_MS[1]), + ); } // Long runs earn solidarity: after 10 minutes of busy the pet settles @@ -1336,7 +494,7 @@ class LobsterPet extends LitElement { // The pet watches your pointer: facing follows it between acts. Throttled, // idle-only, and inert under reduced motion or while acting. private readonly handleGaze = (event: PointerEvent) => { - if (this.presence !== "in" || this.act !== null || this.vigil || prefersReducedMotion()) { + if (this.presence !== "in" || this.act !== null || this.vigil || plans.prefersReducedMotion()) { return; } const now = Date.now(); @@ -1360,7 +518,7 @@ class LobsterPet extends LitElement { private readonly handleShoo = (event: Event) => { event.preventDefault(); this.dismissed = true; - recordLobsterShoo(); + dex.recordLobsterShoo(); }; private clearActTimers() { @@ -1390,13 +548,16 @@ class LobsterPet extends LitElement { this.clearVisitTimers(); this.scheduledVisiting = false; // A shy share of loads never visits on their own; offline still summons. - if (this.visitRng() < VISIT_SHY_CHANCE) { + if (this.visitRng() < plans.VISIT_SHY_CHANCE) { return; } - const tuning = LOBSTER_FAMILIARITY_TUNING[this.familiarity.tier]; + const tuning = dex.LOBSTER_FAMILIARITY_TUNING[this.familiarity.tier]; this.armArrival( - randomBetween(this.visitRng, VISIT_FIRST_DELAY_MS[0], VISIT_FIRST_DELAY_MS[1]) * - tuning.firstDelayMul, + lobsterLook.randomBetween( + this.visitRng, + plans.VISIT_FIRST_DELAY_MS[0], + plans.VISIT_FIRST_DELAY_MS[1], + ) * tuning.firstDelayMul, ); } @@ -1406,8 +567,8 @@ class LobsterPet extends LitElement { this.rollPerch(); this.scheduledVisiting = true; this.armDeparture( - randomBetween(this.visitRng, VISIT_STAY_MS[0], VISIT_STAY_MS[1]) * - LOBSTER_FAMILIARITY_TUNING[this.familiarity.tier].stayMul, + lobsterLook.randomBetween(this.visitRng, plans.VISIT_STAY_MS[0], plans.VISIT_STAY_MS[1]) * + dex.LOBSTER_FAMILIARITY_TUNING[this.familiarity.tier].stayMul, ); }, delayMs); } @@ -1416,10 +577,12 @@ class LobsterPet extends LitElement { this.visitTimer = window.setTimeout(() => { this.visitTimer = null; this.scheduledVisiting = false; - const tuning = LOBSTER_FAMILIARITY_TUNING[this.familiarity.tier]; - const waryMul = this.familiarity.wary ? LOBSTER_FAMILIARITY_TUNING.waryGapMul : 1; + const tuning = dex.LOBSTER_FAMILIARITY_TUNING[this.familiarity.tier]; + const waryMul = this.familiarity.wary ? dex.LOBSTER_FAMILIARITY_TUNING.waryGapMul : 1; this.armArrival( - randomBetween(this.visitRng, VISIT_GAP_MS[0], VISIT_GAP_MS[1]) * tuning.gapMul * waryMul, + lobsterLook.randomBetween(this.visitRng, plans.VISIT_GAP_MS[0], plans.VISIT_GAP_MS[1]) * + tuning.gapMul * + waryMul, ); }, stayMs); } @@ -1436,8 +599,8 @@ class LobsterPet extends LitElement { this.passerEndTimer = null; this.passerWatchTimer = null; this.passer = null; - const plan = planLobsterPasser(this.seed); - if (!plan || prefersReducedMotion()) { + const plan = plans.planLobsterPasser(this.seed); + if (!plan || plans.prefersReducedMotion()) { return; } this.passerTimer = window.setTimeout(() => { @@ -1451,14 +614,14 @@ class LobsterPet extends LitElement { this.passerEndTimer = null; this.passer = null; this.scheduleNextAct(); - }, PASSER_CROSS_MS); + }, plans.PASSER_CROSS_MS); }, plan.atMs); } // The resident notices traffic: it turns toward a passer's entry side, // then follows it out with a mid-crossing flip. Acts, vigil, and absence // all take precedence - the pet is curious, not obsessive. - private watchPasser(plan: LobsterPasserPlan) { + private watchPasser(plan: plans.LobsterPasserPlan) { const watch = (facing: 1 | -1) => { // Scuttle owns facing while it walks; anything else can turn its head. if (this.presence === "in" && this.act !== "scuttle" && !this.vigil) { @@ -1469,7 +632,7 @@ class LobsterPet extends LitElement { this.passerWatchTimer = window.setTimeout(() => { this.passerWatchTimer = null; watch(plan.direction); - }, PASSER_CROSS_MS / 2); + }, plans.PASSER_CROSS_MS / 2); } // Each arrival re-rolls either the standard side zone or compact bar zone. @@ -1478,26 +641,16 @@ class LobsterPet extends LitElement { this.anchor = this.visitRng() < 0.6 ? "ledge" : "bar"; this.setAttribute("data-spot", this.anchor); const zone = this.currentZone(); - this.spotPct = Math.round(randomBetween(this.visitRng, zone[0], zone[1])); + this.spotPct = Math.round(lobsterLook.randomBetween(this.visitRng, zone[0], zone[1])); this.facing = this.visitRng() < 0.5 ? 1 : -1; } private currentZone(): readonly [number, number] { if (this.anchor === "bar") { - return BAR_ZONE; + return plans.BAR_ZONE; } const side = this.look?.side ?? "right"; - return SPOT_ZONES[side]; - } - - private actProfile(): ActProfile | null { - if (this.mode === "busy" || this.mode === "offline") { - return LOBSTER_PET_MODE_ACTS[this.mode]; - } - if (isLobsterNightTime()) { - return PERSONALITIES.sleepy; - } - return this.look ? PERSONALITIES[this.look.personality] : null; + return plans.SPOT_ZONES[side]; } private scheduleNextAct() { @@ -1511,18 +664,18 @@ class LobsterPet extends LitElement { this.passer !== null || this.idleTimer !== null || this.actEndTimer !== null || - prefersReducedMotion() + plans.prefersReducedMotion() ) { return; } - const profile = this.actProfile(); + const profile = plans.resolveLobsterActProfile(this.mode, this.look.personality); if (!profile) { return; } - const delay = randomBetween(this.rng, profile.delayMs[0], profile.delayMs[1]); + const delay = lobsterLook.randomBetween(this.rng, profile.delayMs[0], profile.delayMs[1]); this.idleTimer = window.setTimeout(() => { this.idleTimer = null; - const nextProfile = this.actProfile(); + const nextProfile = plans.resolveLobsterActProfile(this.mode, this.look?.personality ?? null); // A crossing pauses the fidget loop: the pet is busy watching. The // passer-end timer restarts scheduling. if (!nextProfile || document.hidden || this.presence !== "in" || this.passer !== null) { @@ -1532,11 +685,11 @@ class LobsterPet extends LitElement { this.performAct("molt"); return; } - this.performAct(pickWeighted(this.rng, nextProfile.acts)); + this.performAct(lobsterLook.pickWeighted(this.rng, nextProfile.acts)); }, delay); } - private performAct(act: LobsterPetAct, presenceOwner: "vigil" | null = null) { + private performAct(act: plans.LobsterPetAct, presenceOwner: "vigil" | null = null) { this.clearActTimers(); // The active outcome chain carries its sole presence owner across linked // acts; overrides, forced departures, and the terminal act release it. @@ -1561,7 +714,7 @@ class LobsterPet extends LitElement { if (this.wantsVisible()) { this.scheduleNextAct(); } - }, LOBSTER_PET_ACT_DURATION_MS[act]); + }, plans.LOBSTER_PET_ACT_DURATION_MS[act]); } // Shedding: the old shell stays behind and slowly fades while the pet @@ -1603,7 +756,7 @@ class LobsterPet extends LitElement { return; } const zone = this.currentZone(); - let target = Math.round(randomBetween(this.rng, zone[0], zone[1])); + let target = Math.round(lobsterLook.randomBetween(this.rng, zone[0], zone[1])); // A same-spot walk reads as a glitch; nudge to the other zone edge. if (Math.abs(target - this.spotPct) < 4) { target = @@ -1613,173 +766,44 @@ class LobsterPet extends LitElement { this.spotPct = target; } - private spriteStyle(look: LobsterPetLook, scale: number, spotPct: number, facing: 1 | -1) { - // Glint color stays class-driven (see lobster-pet.css): an inline - // --lob-glint would out-cascade the offline grey override. - return [ - `--lob-shell:${look.palette.shell}`, - `--lob-claw:${look.palette.claw}`, - `--lob-scale:${scale}`, - `--lob-x:${spotPct}%`, - `--lob-face:${facing}`, - `--lob-blink-delay:${look.blinkDelayS}s`, - `--lob-w:${LOBSTER_PET_BUILD_MULS[look.build].w}`, - `--lob-h:${LOBSTER_PET_BUILD_MULS[look.build].h}`, - `--lob-claw-scale:${LOBSTER_PET_CLAW_MULS[look.clawSize]}`, - ].join(";"); - } - - private anchoredScale(scale: number): number { - return this.anchor === "bar" ? Math.min(scale, BAR_MAX_SCALE) : scale; - } - - private renderSprite(look: LobsterPetLook, twin: boolean) { - // On the month/day anniversary of this palette's first Lobsterdex visit, - // the party hat overrides whatever accessory the seed rolled. - const dressed = - this.anniversary && look.accessory !== "party" - ? { ...look, accessory: "party" as const } - : look; - const classes = [ - "lobster-pet", - `lobster-pet--${this.mode}`, - `lobster-pet--palette-${look.palette.id}`, - twin ? "lobster-pet--twin" : "", - dressed.accessory === "party" ? "lobster-pet--party" : "", - this.presence === "leaving" ? "lobster-pet--away" : "", - this.entering ? "lobster-pet--entering" : "", - this.grumpy ? "lobster-pet--grumpy" : "", - this.vigil ? "lobster-pet--vigil" : "", - this.act ? `lobster-pet--act-${this.act}` : "", - ] - .filter(Boolean) - .join(" "); - const zone = this.currentZone(); - // The twin tags along on the parent's trailing side and copies every act - // a beat later (--lob-act-delay feeds each act's animation-delay). - const spotPct = twin - ? Math.min(zone[1], Math.max(zone[0], this.spotPct + (this.facing === 1 ? -12 : 12))) - : this.spotPct; - const scale = this.anchoredScale(twin ? look.scale * 0.55 : look.scale); - const style = twin - ? `${this.spriteStyle(look, scale, spotPct, this.facing === 1 ? -1 : 1)};--lob-act-delay:0.18s` - : this.spriteStyle(look, scale, spotPct, this.facing); - // Milestone honorifics come from the load-start familiarity snapshot, so - // a title never pops mid-visit; it is simply there next time. - const honorific = lobsterHonorific(this.familiarity.visits); - const baseName = lobsterPetName(look, this.seed); - const name = honorific ? `${honorific} ${baseName}` : baseName; - // The twin travels light; only the resident pet hauls the moving bindle. - const bindle = this.movingDay && !twin; - const title = twin ? `${name} Jr.` : bindle ? `${name} · just moved in` : name; - return html` - - `; - } - - // The abandoned shell: the pre-molt silhouette, frozen and slowly fading. - private renderShell(look: LobsterPetLook) { - const style = this.spriteStyle( - look, - this.anchoredScale(this.shellScale), - this.shellSpotPct, - this.facing, - ); - return html` - - `; - } - override render() { const look = this.look; if (!look) { return nothing; } - // While the pet is upstairs playing logo, the ledge stays empty - one - // crab, two homes, never both at once. - const showSprites = this.presence !== "out" && !this.logoPerched; - // The shell may outlive the visit while it fades, but dismissal and the - // visits setting silence it like everything else. - const showShell = this.shellVisible && this.visitsEnabled && !this.dismissed; - const showPasser = this.passer !== null && this.visitsEnabled; - if (!showSprites && !showShell && !showPasser) { - return nothing; - } - return html` - ${showShell ? this.renderShell(look) : nothing} - ${showSprites ? this.renderSprite(look, false) : nothing} - ${showSprites && this.twinPlanned ? this.renderSprite(look, true) : nothing} - ${showPasser && this.passer ? this.renderPasser(this.passer, look) : nothing} - `; - } - - // A pass-through visitor: crosses the ledge once and is gone. Strangers - // are other lobsters (never your palette); the crab is, allegedly, also a - // lobster. Neither perches, neither counts for the Lobsterdex. - private renderPasser(passer: LobsterPasserPlan, own: LobsterPetLook) { - const crab = passer.kind === "crab"; - const look = crab ? own : strangerLookFor(this.seed, own.palette.id); - const classes = [ - "lobster-pet", - "lobster-pet--passer", - crab ? "lobster-pet--crab" : `lobster-pet--palette-${look.palette.id}`, - passer.direction === 1 ? "lobster-pet--passer-ltr" : "lobster-pet--passer-rtl", - ].join(" "); - const style = crab - ? `--lob-scale:2;--lob-w:1;--lob-h:0.82;--lob-face:1` - : this.spriteStyle(look, Math.min(look.scale, 2), 0, passer.direction); - return html` - - `; + return lobsterLook.renderLobsterPetScene({ + look, + mode: this.mode, + presence: this.presence, + logoPerched: this.logoPerched, + shellVisible: this.shellVisible, + visitsEnabled: this.visitsEnabled, + dismissed: this.dismissed, + passer: this.passer, + twinPlanned: this.twinPlanned, + anniversary: this.anniversary, + entering: this.entering, + grumpy: this.grumpy, + vigil: this.vigil, + act: this.act, + zone: this.currentZone(), + spotPct: this.spotPct, + facing: this.facing, + anchor: this.anchor, + barMaxScale: plans.BAR_MAX_SCALE, + shellScale: this.shellScale, + shellSpotPct: this.shellSpotPct, + familiarityVisits: this.familiarity.visits, + seed: this.seed, + movingDay: this.movingDay, + sailorDay: this.sailorDay, + onPointerDown: this.handleHoldStart, + onPointerUp: this.handleHoldEnd, + onPointerCancel: this.handleHoldCancel, + onContextMenu: this.handleShoo, + }); } } - -if (!customElements.get("openclaw-lobster-logo-standin")) { - customElements.define("openclaw-lobster-logo-standin", LobsterLogoStandIn); -} - if (!customElements.get("openclaw-lobster-pet")) { customElements.define("openclaw-lobster-pet", LobsterPet); } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index ac21cb691929..f8c67c1b639c 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -68,14 +68,14 @@ "count": 2, "kind": "html-text", "name": "text", - "path": "ui/src/components/lobster-pet.ts", + "path": "ui/src/components/lobster-pet-look.ts", "text": "z" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/components/lobster-pet.ts", + "path": "ui/src/components/lobster-pet-look.ts", "text": "Z" }, {