mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 00:51:39 +00:00
feat(webchat): rare lobster pet events - molting and twin visits (#103154)
~12% of loads plan a molt: during the first idle act the pet shivers,
squashes, and pops one size tier bigger, leaving its old shell behind
as a frozen, washed-out silhouette that fades over a minute. The shell
keeps the true pre-molt size, respects dismissal and the visits
setting, and its timer resets with the seed. ~4% of loads are twin
days: a mini copycat ('<name> Jr.') tags along on the parent's trailing
side and mirrors every act a beat later via an act-delay variable
threaded through the act animations. Rare-event plans are pure
per-seed functions so tests probe them directly.
This commit is contained in:
committed by
GitHub
parent
d8624a4e1b
commit
b0e77bbe72
@@ -5,7 +5,9 @@ import {
|
||||
LOBSTER_PET_ACT_DURATION_MS,
|
||||
LOBSTER_PET_MODE_ACTS,
|
||||
createLobsterPetLook,
|
||||
isLobsterMoltLoad,
|
||||
isLobsterNightTime,
|
||||
isLobsterTwinLoad,
|
||||
lobsterPetName,
|
||||
lobsterPetSeed,
|
||||
resolveLobsterPetMode,
|
||||
@@ -394,6 +396,94 @@ describe("lobster pet element", () => {
|
||||
expect(seen.has("nap") || seen.has("bubble")).toBe(true);
|
||||
});
|
||||
|
||||
it("molt loads shed a fading shell and size up one tier", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
// Seed 2 plans a molt (and no twin); probed via the pure planners.
|
||||
expect(isLobsterMoltLoad(2)).toBe(true);
|
||||
expect(isLobsterTwinLoad(2)).toBe(false);
|
||||
const preScale = createLobsterPetLook(2).scale;
|
||||
const element = createPet(2);
|
||||
await arrive(element);
|
||||
|
||||
const act = await advanceUntilAct(element, 30_000);
|
||||
expect(act).toBe("molt");
|
||||
await vi.advanceTimersByTimeAsync(LOBSTER_PET_ACT_DURATION_MS.molt + 100);
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.querySelector(".lobster-pet--shell")).not.toBeNull();
|
||||
const mainStyle =
|
||||
element.querySelector(".lobster-pet:not(.lobster-pet--shell)")?.getAttribute("style") ?? "";
|
||||
const tiers = [1.7, 2, 2.5];
|
||||
const expected = tiers[Math.min(tiers.indexOf(preScale) + 1, tiers.length - 1)];
|
||||
expect(mainStyle).toContain(`--lob-scale:${expected}`);
|
||||
|
||||
// The shell fades out after a minute; only one molt per load.
|
||||
await vi.advanceTimersByTimeAsync(61_000);
|
||||
await element.updateComplete;
|
||||
expect(element.querySelector(".lobster-pet--shell")).toBeNull();
|
||||
const nextAct = await advanceUntilAct(element, 30_000);
|
||||
expect(nextAct).not.toBe("molt");
|
||||
});
|
||||
|
||||
it("shooing the pet also clears a fading molt shell", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
const element = createPet(2);
|
||||
await arrive(element);
|
||||
await advanceUntilAct(element, 30_000);
|
||||
await vi.advanceTimersByTimeAsync(LOBSTER_PET_ACT_DURATION_MS.molt + 100);
|
||||
await element.updateComplete;
|
||||
expect(element.querySelector(".lobster-pet--shell")).not.toBeNull();
|
||||
|
||||
element
|
||||
.querySelector(".lobster-pet:not(.lobster-pet--shell)")
|
||||
?.dispatchEvent(new Event("contextmenu", { cancelable: true }));
|
||||
await element.updateComplete;
|
||||
expect(element.querySelector(".lobster-pet--shell")).toBeNull();
|
||||
});
|
||||
|
||||
it("twin loads bring a mini copycat that leaves with the visit", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
expect(isLobsterTwinLoad(21)).toBe(true);
|
||||
const element = createPet(21);
|
||||
await arrive(element);
|
||||
|
||||
const sprites = element.querySelectorAll(".lobster-pet:not(.lobster-pet--shell)");
|
||||
expect(sprites.length).toBe(2);
|
||||
const twin = element.querySelector(".lobster-pet--twin");
|
||||
expect(twin).not.toBeNull();
|
||||
expect(twin?.getAttribute("title")).toMatch(/ Jr\.$/);
|
||||
|
||||
const departed = await advanceUntil(
|
||||
element,
|
||||
() => element.querySelectorAll(".lobster-pet").length === 0,
|
||||
400_000,
|
||||
);
|
||||
expect(departed).toBe(true);
|
||||
});
|
||||
|
||||
it("plain loads stay solo and never molt", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
expect(isLobsterMoltLoad(4)).toBe(false);
|
||||
expect(isLobsterTwinLoad(4)).toBe(false);
|
||||
const element = createPet(4);
|
||||
await arrive(element);
|
||||
|
||||
expect(element.querySelectorAll(".lobster-pet").length).toBe(1);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const act = await advanceUntilAct(element, 30_000);
|
||||
expect(act).not.toBe("molt");
|
||||
if (act) {
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
LOBSTER_PET_ACT_DURATION_MS[act as keyof typeof LOBSTER_PET_ACT_DURATION_MS],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("stays static when reduced motion is preferred, including visibility resumes", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -17,7 +17,8 @@ export type LobsterPetAct =
|
||||
| "bubble"
|
||||
| "scuttle"
|
||||
| "startle"
|
||||
| "cheer";
|
||||
| "cheer"
|
||||
| "molt";
|
||||
|
||||
export type LobsterPetMode = "idle" | "busy" | "offline";
|
||||
|
||||
@@ -85,6 +86,7 @@ export const LOBSTER_PET_ACT_DURATION_MS: Record<LobsterPetAct, number> = {
|
||||
scuttle: 1250,
|
||||
startle: 750,
|
||||
cheer: 1300,
|
||||
molt: 2600,
|
||||
};
|
||||
|
||||
const PERSONALITIES: Record<LobsterPetPersonalityId, ActProfile> = {
|
||||
@@ -289,6 +291,17 @@ export function lobsterPetName(look: LobsterPetLook, seed: number): string {
|
||||
return RARE_NAMES[look.palette.id] ?? PET_NAMES[(seed >>> 3) % PET_NAMES.length];
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Late-night visitors are always sleepy, whatever their daytime personality.
|
||||
export function isLobsterNightTime(now: Date = new Date()): boolean {
|
||||
const hour = now.getHours();
|
||||
@@ -500,7 +513,10 @@ 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, options: { grumpy?: boolean } = {}) {
|
||||
function renderLobsterSvg(
|
||||
look: LobsterPetLook,
|
||||
options: { grumpy?: boolean; shell?: boolean } = {},
|
||||
) {
|
||||
return svg`
|
||||
<svg
|
||||
class="lobster-pet__svg"
|
||||
@@ -535,7 +551,7 @@ function renderLobsterSvg(look: LobsterPetLook, options: { grumpy?: boolean } =
|
||||
${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">
|
||||
<g class="lob-eye-open" style=${options.shell ? "display:none" : ""}>
|
||||
<circle cx="45" cy="32" r="5.5" fill="#0a1014" />
|
||||
<circle cx="75" cy="32" r="5.5" fill="#0a1014" />
|
||||
<circle cx="46.5" cy="30.5" r="2.2" fill="var(--lob-glint, #00e5cc)" />
|
||||
@@ -560,7 +576,7 @@ function renderLobsterSvg(look: LobsterPetLook, options: { grumpy?: boolean } =
|
||||
: nothing
|
||||
}
|
||||
${options.grumpy && look.palette.id !== "retro" ? GRUMPY_FACE : nothing}
|
||||
${look.accessory === "none" ? nothing : ACCESSORY_SPRITES[look.accessory]}
|
||||
${look.accessory === "none" || options.shell ? nothing : ACCESSORY_SPRITES[look.accessory]}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
@@ -584,6 +600,13 @@ export class LobsterPet extends LitElement {
|
||||
@state() private scheduledVisiting = false;
|
||||
@state() private dismissed = false;
|
||||
@state() private grumpy = false;
|
||||
@state() private shellVisible = false;
|
||||
private shellSpotPct = 50;
|
||||
private shellScale = 2;
|
||||
private molted = false;
|
||||
private moltPlanned = false;
|
||||
private twinPlanned = false;
|
||||
private shellTimer: number | null = null;
|
||||
|
||||
private look: LobsterPetLook | null = null;
|
||||
private rng: () => number = mulberry32(0);
|
||||
@@ -610,6 +633,10 @@ export class LobsterPet extends LitElement {
|
||||
window.clearTimeout(this.grumpyTimer);
|
||||
this.grumpyTimer = null;
|
||||
}
|
||||
if (this.shellTimer !== null) {
|
||||
window.clearTimeout(this.shellTimer);
|
||||
this.shellTimer = null;
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -634,6 +661,14 @@ export class LobsterPet extends LitElement {
|
||||
this.act = null;
|
||||
this.dismissed = false;
|
||||
this.presence = "out";
|
||||
this.molted = false;
|
||||
this.shellVisible = false;
|
||||
if (this.shellTimer !== null) {
|
||||
window.clearTimeout(this.shellTimer);
|
||||
this.shellTimer = null;
|
||||
}
|
||||
this.moltPlanned = isLobsterMoltLoad(this.seed);
|
||||
this.twinPlanned = isLobsterTwinLoad(this.seed);
|
||||
this.scheduleVisits();
|
||||
} else if (changed.has("mode") && this.presence === "in" && !prefersReducedMotion()) {
|
||||
// Status flips get an immediate reaction; a finished run (busy -> idle)
|
||||
@@ -843,6 +878,10 @@ export class LobsterPet extends LitElement {
|
||||
if (!nextProfile || document.hidden || this.presence !== "in") {
|
||||
return;
|
||||
}
|
||||
if (this.moltPlanned && !this.molted && this.mode === "idle") {
|
||||
this.performAct("molt");
|
||||
return;
|
||||
}
|
||||
this.performAct(pickWeighted(this.rng, nextProfile.acts));
|
||||
}, delay);
|
||||
}
|
||||
@@ -857,10 +896,41 @@ export class LobsterPet extends LitElement {
|
||||
this.actEndTimer = window.setTimeout(() => {
|
||||
this.actEndTimer = null;
|
||||
this.act = null;
|
||||
if (act === "molt") {
|
||||
this.completeMolt();
|
||||
}
|
||||
this.scheduleNextAct();
|
||||
}, LOBSTER_PET_ACT_DURATION_MS[act]);
|
||||
}
|
||||
|
||||
// Shedding: the old shell stays behind and slowly fades while the pet
|
||||
// steps aside one size bigger. Once per load.
|
||||
private completeMolt() {
|
||||
this.molted = true;
|
||||
if (this.look) {
|
||||
const tiers = [1.7, 2, 2.5];
|
||||
const index = tiers.indexOf(this.look.scale);
|
||||
// The shed shell keeps the true pre-molt size; a max-tier pet sheds a
|
||||
// max-tier shell.
|
||||
this.shellScale = this.look.scale;
|
||||
this.look = { ...this.look, scale: tiers[Math.min(index + 1, tiers.length - 1)] };
|
||||
}
|
||||
this.shellSpotPct = this.spotPct;
|
||||
this.shellVisible = true;
|
||||
const zone = this.currentZone();
|
||||
this.spotPct = Math.min(
|
||||
zone[1],
|
||||
Math.max(zone[0], this.spotPct + (this.facing === 1 ? 9 : -9)),
|
||||
);
|
||||
if (this.shellTimer !== null) {
|
||||
window.clearTimeout(this.shellTimer);
|
||||
}
|
||||
this.shellTimer = window.setTimeout(() => {
|
||||
this.shellTimer = null;
|
||||
this.shellVisible = false;
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
private startScuttle() {
|
||||
if (!this.look) {
|
||||
return;
|
||||
@@ -876,15 +946,33 @@ export class LobsterPet extends LitElement {
|
||||
this.spotPct = target;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const look = this.look;
|
||||
if (!look || this.presence === "out") {
|
||||
return nothing;
|
||||
}
|
||||
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(";");
|
||||
}
|
||||
|
||||
// The bar anchor stands inside the ~30px footer bar, so cap the sprite.
|
||||
private anchoredScale(scale: number): number {
|
||||
return this.anchor === "bar" ? Math.min(scale, BAR_MAX_SCALE) : scale;
|
||||
}
|
||||
|
||||
private renderSprite(look: LobsterPetLook, twin: boolean) {
|
||||
const classes = [
|
||||
"lobster-pet",
|
||||
`lobster-pet--${this.mode}`,
|
||||
`lobster-pet--palette-${look.palette.id}`,
|
||||
twin ? "lobster-pet--twin" : "",
|
||||
this.presence === "leaving" ? "lobster-pet--away" : "",
|
||||
this.entering ? "lobster-pet--entering" : "",
|
||||
this.grumpy ? "lobster-pet--grumpy" : "",
|
||||
@@ -892,27 +980,23 @@ export class LobsterPet extends LitElement {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
// The bar anchor stands inside the ~30px footer bar, so cap the sprite.
|
||||
const scale = this.anchor === "bar" ? Math.min(look.scale, BAR_MAX_SCALE) : look.scale;
|
||||
// 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-scale:${scale}`,
|
||||
`--lob-x:${this.spotPct}%`,
|
||||
`--lob-face:${this.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(";");
|
||||
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);
|
||||
const name = lobsterPetName(look, this.seed);
|
||||
return html`
|
||||
<div
|
||||
class=${classes}
|
||||
style=${style}
|
||||
aria-hidden="true"
|
||||
title=${lobsterPetName(look, this.seed)}
|
||||
title=${twin ? `${name} Jr.` : name}
|
||||
@pointerdown=${this.handlePoke}
|
||||
@contextmenu=${this.handleShoo}
|
||||
>
|
||||
@@ -928,6 +1012,40 @@ export class LobsterPet extends LitElement {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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`
|
||||
<div class="lobster-pet lobster-pet--shell" style=${style} aria-hidden="true">
|
||||
<div class="lobster-pet__body">${renderLobsterSvg(look, { shell: true })}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const look = this.look;
|
||||
if (!look) {
|
||||
return nothing;
|
||||
}
|
||||
const showSprites = this.presence !== "out";
|
||||
// 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;
|
||||
if (!showSprites && !showShell) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
${showShell ? this.renderShell(look) : nothing}
|
||||
${showSprites ? this.renderSprite(look, false) : nothing}
|
||||
${showSprites && this.twinPlanned ? this.renderSprite(look, true) : nothing}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-lobster-pet")) {
|
||||
|
||||
@@ -199,43 +199,76 @@ openclaw-lobster-pet[data-spot="bar"] .lobster-pet {
|
||||
|
||||
.lobster-pet--act-wave .lob-claw--r {
|
||||
animation: lobster-pet-wave 1.4s ease-in-out;
|
||||
animation-delay: var(--lob-act-delay, 0s);
|
||||
}
|
||||
|
||||
.lobster-pet--act-snip .lob-claw {
|
||||
animation: lobster-pet-snip 0.5s ease-in-out 2;
|
||||
animation-delay: var(--lob-act-delay, 0s);
|
||||
}
|
||||
|
||||
.lobster-pet--act-hop .lobster-pet__body {
|
||||
animation: lobster-pet-hop 0.75s ease-in-out;
|
||||
animation-delay: var(--lob-act-delay, 0s);
|
||||
}
|
||||
|
||||
.lobster-pet--act-spin .lobster-pet__body {
|
||||
animation: lobster-pet-spin 0.95s ease-in-out;
|
||||
animation-delay: var(--lob-act-delay, 0s);
|
||||
}
|
||||
|
||||
.lobster-pet--act-peek .lobster-pet__body {
|
||||
animation: lobster-pet-peek 1.7s ease-in-out;
|
||||
animation-delay: var(--lob-act-delay, 0s);
|
||||
}
|
||||
|
||||
.lobster-pet--act-scuttle .lobster-pet__body {
|
||||
animation: lobster-pet-waddle 0.3s ease-in-out infinite;
|
||||
animation-delay: var(--lob-act-delay, 0s);
|
||||
}
|
||||
|
||||
.lobster-pet--act-startle .lobster-pet__body {
|
||||
animation: lobster-pet-startle 0.75s ease-out;
|
||||
animation-delay: var(--lob-act-delay, 0s);
|
||||
}
|
||||
|
||||
.lobster-pet--act-startle .lob-claw {
|
||||
animation: lobster-pet-snip 0.38s ease-in-out 2;
|
||||
animation-delay: var(--lob-act-delay, 0s);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
animation-delay: var(--lob-act-delay, 0s);
|
||||
}
|
||||
|
||||
.lobster-pet--act-cheer .lob-claw {
|
||||
animation: lobster-pet-cheer-claws 1.3s ease-in-out;
|
||||
animation-delay: var(--lob-act-delay, 0s);
|
||||
}
|
||||
|
||||
/* Molt: shiver, squash, pop. */
|
||||
.lobster-pet--act-molt .lobster-pet__body {
|
||||
animation: lobster-pet-molt 2.6s ease-in-out;
|
||||
animation-delay: var(--lob-act-delay, 0s);
|
||||
}
|
||||
|
||||
/* The abandoned shell: frozen, washed out, fading over a minute. */
|
||||
.lobster-pet--shell {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
filter: saturate(0.45) brightness(0.75);
|
||||
animation: lobster-pet-shell-fade 60s linear forwards;
|
||||
}
|
||||
|
||||
.lobster-pet--shell .lobster-pet__svg,
|
||||
.lobster-pet--shell .lob-eye-closed {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.lobster-pet--shell .lob-eye-closed {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---- Nap: eyes shut, z's drift up ---- */
|
||||
@@ -429,6 +462,39 @@ openclaw-lobster-pet[data-spot="bar"] .lobster-pet {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lobster-pet-molt {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg) scaleY(1);
|
||||
}
|
||||
10%,
|
||||
30% {
|
||||
transform: rotate(-2.5deg) scaleY(1);
|
||||
}
|
||||
20%,
|
||||
40% {
|
||||
transform: rotate(2.5deg) scaleY(1);
|
||||
}
|
||||
55% {
|
||||
transform: rotate(0deg) scaleY(0.8);
|
||||
}
|
||||
70% {
|
||||
transform: rotate(0deg) scaleY(1.12);
|
||||
}
|
||||
85% {
|
||||
transform: rotate(0deg) scaleY(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lobster-pet-shell-fade {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lobster-pet-cheer {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
Reference in New Issue
Block a user