mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(webchat): rare lobster pet variants incl. retro homage to the classic logo (#102829)
* feat(webchat): rare lobster pet variants modeled on real lobster genetics Extends the sidebar pet's palette ladder with a rare tier: blue (7%), calico with mottled spots (3%), deep-sea abyss with glowing eye glints (2%), translucent ghost/albino (1%), and a bicolor split two-tone with mismatched claws and antennae (1%). Weights loosely mirror real-world odds (blue ~1 in 2M, calico ~1 in 30M, split ~1 in 50M, ghost ~1 in 100M). Offline mode still greys out every variant's glints. Drops the unused --lob-dim inline variable. * feat(webchat): retro grail variant honoring the classic OpenClaw logo Adds a ~0.5% super-rare 'retro' lobster pet: classic logo red with a white sticker-outline halo, one oversized raised claw with a pincer notch (small left pincher stays), determined angled brows, a smirk, and tall V antennae. The mega claw rides the .lob-claw--r group so wave and snip acts swing it.
This commit is contained in:
parent
b5ba0771f9
commit
70bb770e6b
3 changed files with 198 additions and 16 deletions
|
|
@ -9,8 +9,24 @@ import {
|
|||
resolveLobsterPetMode,
|
||||
type LobsterPet,
|
||||
type LobsterPetMode,
|
||||
type LobsterPetPaletteId,
|
||||
} from "./lobster-pet.ts";
|
||||
|
||||
const LOBSTER_PET_PALETTE_IDS: LobsterPetPaletteId[] = [
|
||||
"crimson",
|
||||
"coral",
|
||||
"teal",
|
||||
"violet",
|
||||
"ink",
|
||||
"blue",
|
||||
"gold",
|
||||
"calico",
|
||||
"abyss",
|
||||
"ghost",
|
||||
"split",
|
||||
"retro",
|
||||
];
|
||||
|
||||
const SPOT_ZONES = { left: [12, 38], right: [60, 84] } as const;
|
||||
|
||||
type LobsterPetElement = LobsterPet & HTMLElement;
|
||||
|
|
@ -59,7 +75,7 @@ describe("lobster pet look", () => {
|
|||
const look = createLobsterPetLook(seed);
|
||||
palettes.add(look.palette.id);
|
||||
personalities.add(look.personality);
|
||||
expect(["crimson", "coral", "teal", "violet", "ink", "gold"]).toContain(look.palette.id);
|
||||
expect(LOBSTER_PET_PALETTE_IDS).toContain(look.palette.id);
|
||||
expect([1.7, 2, 2.5]).toContain(look.scale);
|
||||
expect(["none", "crown", "sprout", "patch"]).toContain(look.accessory);
|
||||
expect(["perky", "droopy"]).toContain(look.antennae);
|
||||
|
|
@ -72,6 +88,24 @@ describe("lobster pet look", () => {
|
|||
expect(personalities.size).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it("hatches every rarity tier, with rares staying rare", () => {
|
||||
const counts = new Map<string, number>();
|
||||
const total = 20_000;
|
||||
for (let seed = 0; seed < total; seed++) {
|
||||
const id = createLobsterPetLook(seed).palette.id;
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
}
|
||||
// Every palette, including the 1% grails, must be reachable.
|
||||
for (const id of LOBSTER_PET_PALETTE_IDS) {
|
||||
expect(counts.get(id) ?? 0).toBeGreaterThan(0);
|
||||
}
|
||||
// Grails stay grails: ghost/split roll ~1%, retro ~0.5%; commons dominate.
|
||||
for (const grail of ["ghost", "split", "retro"]) {
|
||||
expect(counts.get(grail) ?? 0).toBeLessThan(total * 0.03);
|
||||
}
|
||||
expect((counts.get("crimson") ?? 0) + (counts.get("coral") ?? 0)).toBeGreaterThan(total * 0.4);
|
||||
});
|
||||
|
||||
it("derives distinct salted seeds per session key, stable within a load", () => {
|
||||
expect(lobsterPetSeed("agent:a:main")).toBe(lobsterPetSeed("agent:a:main"));
|
||||
expect(lobsterPetSeed("agent:a:main")).not.toBe(lobsterPetSeed("agent:b:other"));
|
||||
|
|
|
|||
|
|
@ -22,8 +22,22 @@ export type LobsterPetMode = "idle" | "busy" | "offline";
|
|||
|
||||
export type LobsterPetPersonalityId = "sleepy" | "zoomy" | "friendly" | "showoff";
|
||||
|
||||
export type LobsterPetPaletteId =
|
||||
| "crimson"
|
||||
| "coral"
|
||||
| "teal"
|
||||
| "violet"
|
||||
| "ink"
|
||||
| "blue"
|
||||
| "gold"
|
||||
| "calico"
|
||||
| "abyss"
|
||||
| "ghost"
|
||||
| "split"
|
||||
| "retro";
|
||||
|
||||
export type LobsterPetPalette = {
|
||||
id: "crimson" | "coral" | "teal" | "violet" | "ink" | "gold";
|
||||
id: LobsterPetPaletteId;
|
||||
shell: string;
|
||||
claw: string;
|
||||
};
|
||||
|
|
@ -131,13 +145,26 @@ export const LOBSTER_PET_MODE_ACTS: Record<Exclude<LobsterPetMode, "idle">, ActP
|
|||
},
|
||||
};
|
||||
|
||||
// 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" }, 30],
|
||||
[{ id: "coral", shell: "#d0836a", claw: "#de9b80" }, 30],
|
||||
[{ id: "teal", shell: "#2fbfa7", claw: "#5cd9c4" }, 12],
|
||||
[{ id: "violet", shell: "#9f7dfa", claw: "#bba4fd" }, 12],
|
||||
[{ id: "ink", shell: "#5e6b7a", claw: "#7b8996" }, 11],
|
||||
[{ 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],
|
||||
];
|
||||
|
||||
const ACCESSORIES: Array<[LobsterPetAccessory, number]> = [
|
||||
|
|
@ -262,6 +289,61 @@ const ACCESSORY_SPRITES: Record<Exclude<LobsterPetAccessory, "none">, TemplateRe
|
|||
`,
|
||||
};
|
||||
|
||||
// Calico mottling: dark blotches scattered clear of the eye line.
|
||||
const CALICO_SPOTS = svg`
|
||||
<g class="lob-spots" fill="#2a1f16" opacity="0.8">
|
||||
<ellipse cx="40" cy="50" rx="6" ry="4" transform="rotate(-15 40 50)" />
|
||||
<ellipse cx="72" cy="62" rx="7" ry="4.5" transform="rotate(18 72 62)" />
|
||||
<ellipse cx="55" cy="76" rx="5" ry="3.5" transform="rotate(-8 55 76)" />
|
||||
<ellipse cx="84" cy="42" rx="4" ry="3" transform="rotate(25 84 42)" />
|
||||
<ellipse cx="47" cy="18" rx="4.5" ry="3" transform="rotate(-20 47 18)" />
|
||||
<ellipse cx="30" cy="64" rx="4" ry="3" transform="rotate(12 30 64)" />
|
||||
</g>
|
||||
`;
|
||||
|
||||
// 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`
|
||||
<path
|
||||
class="lob-split-half"
|
||||
d="M60 8 C88 8 104 32 104 52 C104 72 90 90 76 95 L76 104 L66 104 L66 96 C64 96.8 62 97.1 60 97.1 L60 8 Z"
|
||||
fill="var(--lob-shell2, #46536b)"
|
||||
/>
|
||||
`;
|
||||
|
||||
// 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`
|
||||
<path
|
||||
d="M95 55 C112 53 119 39 116 25 C113 11 99 5 91 12 C88 15 87 19 88 23 C83 27 83 36 88 43 C91 49 93 52 95 55 Z"
|
||||
fill="var(--lob-claw)"
|
||||
/>
|
||||
<path
|
||||
d="M92 14 C97 22 99 31 95 41"
|
||||
stroke="#b8151b"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
`;
|
||||
|
||||
const RETRO_ANTENNAE = svg`
|
||||
<g class="lob-antennae" stroke="var(--lob-shell)" stroke-width="4" stroke-linecap="round" fill="none">
|
||||
<path d="M50 16 Q45 4 37 1" />
|
||||
<path d="M70 16 Q75 4 83 1" />
|
||||
</g>
|
||||
`;
|
||||
|
||||
const RETRO_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="M49 45 Q59 51 69 45 L72 42" 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">
|
||||
|
|
@ -287,23 +369,31 @@ function renderLobsterSvg(look: LobsterPetLook) {
|
|||
preserveAspectRatio="xMidYMax meet"
|
||||
aria-hidden="true"
|
||||
>
|
||||
${ANTENNAE_SPRITES[look.antennae]}
|
||||
${look.palette.id === "retro" ? RETRO_ANTENNAE : ANTENNAE_SPRITES[look.antennae]}
|
||||
<g class="lob-claw lob-claw--l">
|
||||
<path
|
||||
d="M20 42 C5 37 0 47 5 57 C10 67 20 62 25 52 C28 45 25 42 20 42 Z"
|
||||
fill="var(--lob-claw)"
|
||||
/>
|
||||
</g>
|
||||
<g class="lob-claw lob-claw--r">
|
||||
<path
|
||||
d="M100 42 C115 37 120 47 115 57 C110 67 100 62 95 52 C92 45 95 42 100 42 Z"
|
||||
fill="var(--lob-claw)"
|
||||
/>
|
||||
</g>
|
||||
${
|
||||
look.palette.id === "retro"
|
||||
? nothing
|
||||
: svg`
|
||||
<g class="lob-claw lob-claw--r">
|
||||
<path
|
||||
d="M100 42 C115 37 120 47 115 57 C110 67 100 62 95 52 C92 45 95 42 100 42 Z"
|
||||
fill="var(--lob-claw)"
|
||||
/>
|
||||
</g>
|
||||
`
|
||||
}
|
||||
<path
|
||||
d="M60 8 C32 8 16 32 16 52 C16 72 30 90 44 95 L44 104 L54 104 L54 96 C58 97.5 62 97.5 66 96 L66 104 L76 104 L76 95 C90 90 104 72 104 52 C104 32 88 8 60 8 Z"
|
||||
fill="var(--lob-shell)"
|
||||
/>
|
||||
${look.palette.id === "split" ? SPLIT_HALF : nothing}
|
||||
${look.palette.id === "calico" ? CALICO_SPOTS : nothing}
|
||||
<ellipse cx="48" cy="28" rx="20" ry="11" fill="#ffffff" opacity="0.1" />
|
||||
<g class="lob-eye-open">
|
||||
<circle cx="45" cy="32" r="5.5" fill="#0a1014" />
|
||||
|
|
@ -321,6 +411,14 @@ function renderLobsterSvg(look: LobsterPetLook) {
|
|||
<path d="M39 33 Q45 28 51 33" />
|
||||
<path d="M69 33 Q75 28 81 33" />
|
||||
</g>
|
||||
${
|
||||
look.palette.id === "retro"
|
||||
? svg`
|
||||
${RETRO_FACE}
|
||||
<g class="lob-claw lob-claw--r">${RETRO_MEGA_CLAW}</g>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${look.accessory === "none" ? nothing : ACCESSORY_SPRITES[look.accessory]}
|
||||
</svg>
|
||||
`;
|
||||
|
|
@ -487,15 +585,17 @@ export class LobsterPet extends LitElement {
|
|||
const classes = [
|
||||
"lobster-pet",
|
||||
`lobster-pet--${this.mode}`,
|
||||
`lobster-pet--palette-${look.palette.id}`,
|
||||
this.entering ? "lobster-pet--entering" : "",
|
||||
this.act ? `lobster-pet--act-${this.act}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
// Glint color stays class-driven (see lobster-pet.css): an inline
|
||||
// --lob-glint would out-cascade the offline grey override.
|
||||
const style = [
|
||||
`--lob-shell:${look.palette.shell}`,
|
||||
`--lob-claw:${look.palette.claw}`,
|
||||
`--lob-dim:color-mix(in srgb, ${look.palette.shell} 72%, #10181f)`,
|
||||
`--lob-scale:${look.scale}`,
|
||||
`--lob-x:${this.spotPct}%`,
|
||||
`--lob-face:${this.facing}`,
|
||||
|
|
|
|||
|
|
@ -42,11 +42,59 @@ openclaw-lobster-pet {
|
|||
transition: left 1.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* Disconnected pets lose their teal eye glow. */
|
||||
/* ---- Rare palette variants (weights + lore in lobster-pet.ts) ---- */
|
||||
|
||||
/* Ghost/albino: pale translucent shell, icy glints. */
|
||||
.lobster-pet--palette-ghost {
|
||||
--lob-glint: #a8dcff;
|
||||
}
|
||||
|
||||
.lobster-pet--palette-ghost .lobster-pet__svg {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
/* Abyss: deep-sea navy with bioluminescent eye glow. */
|
||||
.lobster-pet--palette-abyss {
|
||||
--lob-glint: #52f5e3;
|
||||
}
|
||||
|
||||
.lobster-pet--palette-abyss .lob-eye-open circle:nth-of-type(3),
|
||||
.lobster-pet--palette-abyss .lob-eye-open circle:nth-of-type(4) {
|
||||
filter: drop-shadow(0 0 2.5px rgba(82, 245, 227, 0.9));
|
||||
}
|
||||
|
||||
/* Retro: homage to the classic OpenClaw logo — white sticker outline around
|
||||
the whole sprite (the halo is two stacked 1px drop-shadows). */
|
||||
.lobster-pet--palette-retro .lobster-pet__svg {
|
||||
filter: drop-shadow(0 0 1.2px #f5f7fa) drop-shadow(0 0 1.2px #f5f7fa)
|
||||
drop-shadow(0 0 1.2px #f5f7fa);
|
||||
}
|
||||
|
||||
/* Split two-tone: the right body half (drawn in lobster-pet.ts) plus the
|
||||
right claw and antenna wear the second shell color. */
|
||||
.lobster-pet--palette-split {
|
||||
--lob-shell2: #46536b;
|
||||
}
|
||||
|
||||
.lobster-pet--palette-split .lob-claw--r path {
|
||||
fill: var(--lob-shell2);
|
||||
}
|
||||
|
||||
.lobster-pet--palette-split .lob-antennae path:last-of-type {
|
||||
stroke: var(--lob-shell2);
|
||||
}
|
||||
|
||||
/* Disconnected pets lose their eye glow; declared after the palette glints so
|
||||
offline wins the cascade at equal specificity. */
|
||||
.lobster-pet--offline {
|
||||
--lob-glint: #8a949d;
|
||||
}
|
||||
|
||||
.lobster-pet--offline .lob-eye-open circle:nth-of-type(3),
|
||||
.lobster-pet--offline .lob-eye-open circle:nth-of-type(4) {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.lobster-pet__body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue