mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 09:21:36 +00:00
feat(webchat): lobster pet charm bundle - names, celebrations, poke moods, night owl (#103149)
Every pet gets a seeded name shown via native hover tooltip (rare palettes carry signature names: Goldie, Boo, Picasso, Patches, Lantern, Blueberry, OG). A busy->idle mode flip now earns a cheer act (double hop with a mid-air twirl, claws up) instead of a startle, so the lobster celebrates finished runs. Three fast pokes make it grumpy for a minute (angry brows + frown overlay); ten pokes send it off in a huff until a later scheduled visit (offline pets are on duty and never huff). Visits between 22:00 and 06:00 local always act sleepy regardless of personality.
This commit is contained in:
committed by
GitHub
parent
8b300e57f0
commit
e26a850fbd
@@ -5,6 +5,8 @@ import {
|
||||
LOBSTER_PET_ACT_DURATION_MS,
|
||||
LOBSTER_PET_MODE_ACTS,
|
||||
createLobsterPetLook,
|
||||
isLobsterNightTime,
|
||||
lobsterPetName,
|
||||
lobsterPetSeed,
|
||||
resolveLobsterPetMode,
|
||||
type LobsterPet,
|
||||
@@ -150,6 +152,27 @@ describe("lobster pet look", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("lobsterPetName", () => {
|
||||
it("is deterministic and rare palettes carry signature names", () => {
|
||||
for (let seed = 0; seed < 50; seed++) {
|
||||
const look = createLobsterPetLook(seed);
|
||||
const name = lobsterPetName(look, seed);
|
||||
expect(name).toBe(lobsterPetName(look, seed));
|
||||
expect(name.length).toBeGreaterThan(1);
|
||||
}
|
||||
const retroLook = {
|
||||
...createLobsterPetLook(1),
|
||||
palette: { id: "retro" as const, shell: "#e8262c", claw: "#f04a3e" },
|
||||
};
|
||||
expect(lobsterPetName(retroLook, 1)).toBe("OG");
|
||||
const goldLook = {
|
||||
...createLobsterPetLook(1),
|
||||
palette: { id: "gold" as const, shell: "#f4b840", claw: "#f9d47a" },
|
||||
};
|
||||
expect(lobsterPetName(goldLook, 1)).toBe("Goldie");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLobsterPetMode", () => {
|
||||
it("maps connection and run state to modes", () => {
|
||||
expect(resolveLobsterPetMode(false, [{ hasActiveRun: true }])).toBe("offline");
|
||||
@@ -197,6 +220,7 @@ describe("lobster pet element", () => {
|
||||
|
||||
it("schedules acts while perched", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
const element = createPet(42);
|
||||
await arrive(element);
|
||||
|
||||
@@ -214,6 +238,7 @@ describe("lobster pet element", () => {
|
||||
|
||||
it("startles on mode changes and then draws from the new mode's pool", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
const element = createPet(42);
|
||||
await arrive(element);
|
||||
|
||||
@@ -297,6 +322,78 @@ describe("lobster pet element", () => {
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("celebrates when a run finishes and startles on other status flips", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
const element = createPet(42, "busy");
|
||||
await arrive(element);
|
||||
|
||||
element.mode = "idle";
|
||||
await element.updateComplete;
|
||||
expect(spriteClasses(element)).toContain("lobster-pet--act-cheer");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(LOBSTER_PET_ACT_DURATION_MS.cheer);
|
||||
element.mode = "offline";
|
||||
await element.updateComplete;
|
||||
expect(spriteClasses(element)).toContain("lobster-pet--act-startle");
|
||||
});
|
||||
|
||||
it("gets grumpy after three fast pokes and recovers after a minute", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
const element = createPet(42);
|
||||
await arrive(element);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
element.querySelector(".lobster-pet")?.dispatchEvent(new Event("pointerdown"));
|
||||
await element.updateComplete;
|
||||
}
|
||||
expect(spriteClasses(element)).toContain("lobster-pet--grumpy");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(61_000);
|
||||
await element.updateComplete;
|
||||
expect(spriteClasses(element)).not.toContain("lobster-pet--grumpy");
|
||||
});
|
||||
|
||||
it("leaves in a huff after ten pokes but returns for a later visit", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
const element = createPet(42);
|
||||
await arrive(element);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
element.querySelector(".lobster-pet")?.dispatchEvent(new Event("pointerdown"));
|
||||
await element.updateComplete;
|
||||
}
|
||||
const gone = await advanceUntil(element, () => !spritePresent(element), 5_000);
|
||||
expect(gone).toBe(true);
|
||||
|
||||
const returned = await advanceUntil(element, () => spritePresent(element), 1_300_000);
|
||||
expect(returned).toBe(true);
|
||||
});
|
||||
|
||||
it("night visits act sleepy regardless of personality", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T23:30:00"));
|
||||
expect(isLobsterNightTime()).toBe(true);
|
||||
const element = createPet(42);
|
||||
await arrive(element);
|
||||
|
||||
// Sleepy-pool exclusives (nap/bubble) never appear in zoomy/showoff pools;
|
||||
// observing one proves the override. Seeded, so the sequence is stable.
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 6 && !(seen.has("nap") || seen.has("bubble")); i++) {
|
||||
const act = await advanceUntilAct(element, 30_000);
|
||||
if (act) {
|
||||
seen.add(act);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
LOBSTER_PET_ACT_DURATION_MS[act as keyof typeof LOBSTER_PET_ACT_DURATION_MS],
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(seen.has("nap") || seen.has("bubble")).toBe(true);
|
||||
});
|
||||
|
||||
it("stays static when reduced motion is preferred, including visibility resumes", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -16,7 +16,8 @@ export type LobsterPetAct =
|
||||
| "nap"
|
||||
| "bubble"
|
||||
| "scuttle"
|
||||
| "startle";
|
||||
| "startle"
|
||||
| "cheer";
|
||||
|
||||
export type LobsterPetMode = "idle" | "busy" | "offline";
|
||||
|
||||
@@ -83,6 +84,7 @@ export const LOBSTER_PET_ACT_DURATION_MS: Record<LobsterPetAct, number> = {
|
||||
bubble: 2600,
|
||||
scuttle: 1250,
|
||||
startle: 750,
|
||||
cheer: 1300,
|
||||
};
|
||||
|
||||
const PERSONALITIES: Record<LobsterPetPersonalityId, ActProfile> = {
|
||||
@@ -244,6 +246,55 @@ 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;
|
||||
|
||||
// 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<Record<LobsterPetPaletteId, string>> = {
|
||||
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] ?? PET_NAMES[(seed >>> 3) % PET_NAMES.length];
|
||||
}
|
||||
|
||||
// Late-night visitors are always sleepy, whatever their daytime personality.
|
||||
export function isLobsterNightTime(now: Date = new Date()): boolean {
|
||||
const hour = now.getHours();
|
||||
return hour >= 22 || hour < 6;
|
||||
}
|
||||
|
||||
function fnv1a(value: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
@@ -423,6 +474,15 @@ const TAIL_FAN = svg`
|
||||
</g>
|
||||
`;
|
||||
|
||||
// Shown while grumpy (poked too much): angry brows and a frown.
|
||||
const GRUMPY_FACE = svg`
|
||||
<g stroke="#0a1014" stroke-linecap="round" fill="none">
|
||||
<path d="M37 24 L51 28" stroke-width="3.5" />
|
||||
<path d="M69 28 L83 24" stroke-width="3.5" />
|
||||
<path d="M50 48 Q60 42 70 48" stroke-width="3" />
|
||||
</g>
|
||||
`;
|
||||
|
||||
const ANTENNAE_SPRITES: Record<LobsterPetAntennae, TemplateResult> = {
|
||||
perky: svg`
|
||||
<g class="lob-antennae" stroke="var(--lob-shell)" stroke-width="4" stroke-linecap="round" fill="none">
|
||||
@@ -440,7 +500,7 @@ const ANTENNAE_SPRITES: Record<LobsterPetAntennae, TemplateResult> = {
|
||||
|
||||
// Same species as icons.lobster / the dreams-scene sleeper: smooth dome body
|
||||
// with stubby legs, side claws, antennae, and teal-glint eyes.
|
||||
function renderLobsterSvg(look: LobsterPetLook) {
|
||||
function renderLobsterSvg(look: LobsterPetLook, options: { grumpy?: boolean } = {}) {
|
||||
return svg`
|
||||
<svg
|
||||
class="lobster-pet__svg"
|
||||
@@ -499,6 +559,7 @@ function renderLobsterSvg(look: LobsterPetLook) {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${options.grumpy && look.palette.id !== "retro" ? GRUMPY_FACE : nothing}
|
||||
${look.accessory === "none" ? nothing : ACCESSORY_SPRITES[look.accessory]}
|
||||
</svg>
|
||||
`;
|
||||
@@ -522,6 +583,7 @@ export class LobsterPet extends LitElement {
|
||||
@state() private anchor: LobsterPetAnchor = "ledge";
|
||||
@state() private scheduledVisiting = false;
|
||||
@state() private dismissed = false;
|
||||
@state() private grumpy = false;
|
||||
|
||||
private look: LobsterPetLook | null = null;
|
||||
private rng: () => number = mulberry32(0);
|
||||
@@ -531,6 +593,8 @@ export class LobsterPet extends LitElement {
|
||||
private enterTimer: number | null = null;
|
||||
private visitTimer: number | null = null;
|
||||
private leaveTimer: number | null = null;
|
||||
private grumpyTimer: number | null = null;
|
||||
private pokeTimes: number[] = [];
|
||||
private restartPending = false;
|
||||
|
||||
override connectedCallback() {
|
||||
@@ -542,6 +606,10 @@ export class LobsterPet extends LitElement {
|
||||
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
this.clearActTimers();
|
||||
this.clearVisitTimers();
|
||||
if (this.grumpyTimer !== null) {
|
||||
window.clearTimeout(this.grumpyTimer);
|
||||
this.grumpyTimer = null;
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -568,9 +636,11 @@ export class LobsterPet extends LitElement {
|
||||
this.presence = "out";
|
||||
this.scheduleVisits();
|
||||
} else if (changed.has("mode") && this.presence === "in" && !prefersReducedMotion()) {
|
||||
// Status flips get an immediate reaction; the act-end timer then
|
||||
// reschedules from the new mode's pool.
|
||||
this.performAct("startle");
|
||||
// Status flips get an immediate reaction; a finished run (busy -> idle)
|
||||
// earns a celebration, everything else a startle. The act-end timer
|
||||
// then reschedules from the new mode's pool.
|
||||
const previousMode = changed.get("mode") as LobsterPetMode | undefined;
|
||||
this.performAct(previousMode === "busy" && this.mode === "idle" ? "cheer" : "startle");
|
||||
}
|
||||
this.reconcilePresence();
|
||||
}
|
||||
@@ -626,13 +696,46 @@ export class LobsterPet extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
// Pokes are fun until they are not: 3 fast pokes turn 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 handlePoke = () => {
|
||||
if (prefersReducedMotion()) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
this.pokeTimes = [...this.pokeTimes.filter((at) => now - at < 6000), now];
|
||||
if (this.pokeTimes.length >= 10 && this.mode !== "offline") {
|
||||
this.huffOff();
|
||||
return;
|
||||
}
|
||||
if (this.pokeTimes.length >= 3) {
|
||||
this.enterGrumpy();
|
||||
}
|
||||
this.performAct("startle");
|
||||
};
|
||||
|
||||
private enterGrumpy() {
|
||||
this.grumpy = true;
|
||||
if (this.grumpyTimer !== null) {
|
||||
window.clearTimeout(this.grumpyTimer);
|
||||
}
|
||||
this.grumpyTimer = window.setTimeout(() => {
|
||||
this.grumpyTimer = null;
|
||||
this.grumpy = false;
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
private huffOff() {
|
||||
this.pokeTimes = [];
|
||||
this.grumpy = false;
|
||||
// Ends the current visit only; unlike a right-click dismissal the pet
|
||||
// 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]));
|
||||
}
|
||||
|
||||
// Right-click shoos the pet away for the rest of this page load.
|
||||
private readonly handleShoo = (event: Event) => {
|
||||
event.preventDefault();
|
||||
@@ -711,6 +814,9 @@ export class LobsterPet extends LitElement {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -781,6 +887,7 @@ export class LobsterPet extends LitElement {
|
||||
`lobster-pet--palette-${look.palette.id}`,
|
||||
this.presence === "leaving" ? "lobster-pet--away" : "",
|
||||
this.entering ? "lobster-pet--entering" : "",
|
||||
this.grumpy ? "lobster-pet--grumpy" : "",
|
||||
this.act ? `lobster-pet--act-${this.act}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -805,11 +912,12 @@ export class LobsterPet extends LitElement {
|
||||
class=${classes}
|
||||
style=${style}
|
||||
aria-hidden="true"
|
||||
title=${lobsterPetName(look, this.seed)}
|
||||
@pointerdown=${this.handlePoke}
|
||||
@contextmenu=${this.handleShoo}
|
||||
>
|
||||
<div class="lobster-pet__body">
|
||||
${renderLobsterSvg(look)}
|
||||
${renderLobsterSvg(look, { grumpy: this.grumpy })}
|
||||
<span class="lobster-pet__z" style="--i:0">z</span>
|
||||
<span class="lobster-pet__z" style="--i:1">z</span>
|
||||
<span class="lobster-pet__z" style="--i:2">Z</span>
|
||||
|
||||
@@ -229,6 +229,15 @@ openclaw-lobster-pet[data-spot="bar"] .lobster-pet {
|
||||
animation: lobster-pet-snip 0.38s ease-in-out 2;
|
||||
}
|
||||
|
||||
/* Cheer: a finished run earns a double hop with a mid-air twirl, claws up. */
|
||||
.lobster-pet--act-cheer .lobster-pet__body {
|
||||
animation: lobster-pet-cheer 1.3s ease-in-out;
|
||||
}
|
||||
|
||||
.lobster-pet--act-cheer .lob-claw {
|
||||
animation: lobster-pet-cheer-claws 1.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* ---- Nap: eyes shut, z's drift up ---- */
|
||||
|
||||
.lobster-pet--act-nap .lob-eye-open {
|
||||
@@ -420,6 +429,36 @@ openclaw-lobster-pet[data-spot="bar"] .lobster-pet {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lobster-pet-cheer {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotateY(0deg);
|
||||
}
|
||||
20% {
|
||||
transform: translateY(-42%) rotateY(0deg);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(0) rotateY(0deg);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-34%) rotateY(180deg);
|
||||
}
|
||||
80% {
|
||||
transform: translateY(0) rotateY(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lobster-pet-cheer-claws {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
25%,
|
||||
65% {
|
||||
transform: translateY(-5px) rotate(-18deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lobster-pet-z {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
Reference in New Issue
Block a user