add-a-cat-following-mouse

eye-

Background

I came across a cute little cat on the internet — here is the source: https://github.com/adryd325/oneko.js. Since it's implemented in JavaScript, I decided to rewrite it in React.

Implement

First, let's put a little cat on the screen. We'll use a sprite sheet, which you can download here:

"use client"

export default function MyCat() {
  return (
    <div
      className="fixed pointer-events-none z-50"
      style={{
        width: `32px`,
        height: `32px`,
        backgroundImage: "url(/cat.gif)",
      }}
    />
  )
}

Now we already have a little cat on the screen! Let's adjust its position so it appears in the top-left corner.

  <div
      className="fixed pointer-events-none z-50"
      style={{
        width: `32px`,
        height: `32px`,
        backgroundImage: "url(/cat.gif)",
      + backgroundPosition: "-96px -96px",
      + left: "16px",
      + top: "16px",
      + zIndex: 2147483647 // 2 ** 31 - 1
      }}
    />

To make the cat "chase" the mouse, we need to track mouse movement.

const mousePosXRef = useRef(0)
const mousePosYRef = useRef(0)


const handleMouseMove = (event: MouseEvent) => {
  mousePosXRef.current = event.clientX
  mousePosYRef.current = event.clientY
}

useEffect(() => {
  window.addEventListener("mousemove", handleMouseMove)

  return () => {
    window.removeEventListener("mousemove", handleMouseMove)
  }
}, [])

Now let's image, we have a point: (x, y), we need to let cat reach there, how to implement it? Yes, we need to animation.

function frame() {
  // cat run
}

function onAnimationFrame(timestamp) {
  frame()
  requestAnimationFrame(onAnimationFrame)
}

requestAnimationFrame(onAnimationFrame)

We need to bring the cat closer to the mouse position during each animation.

const catPostXRef = useRef(32)
const catPostYRef = useRef(32)

const diffX = mousePosXRef.current - catPostXRef.current
const diffY = mousePosYRef.current - catPostYRef.current

const distance = Math.sqrt(diffX ** 2 + diffY ** 2)

catPostXRef.current += (diffX / distance)
catPostYRef.current += (diffY / distance)

catPosXRef.current = Math.min(
  Math.max(16, catPosXRef.current), // at least 16
  window.innerWidth - 16 // make sure not large than window width - 16
)
catPosYRef.current = Math.min(
  Math.max(16, catPosYRef.current),
  window.innerHeight - 16
)

if (containerRef.current) {
  // cat center(32px / 2 = 16px)
  containerRef.current.style.left = `${catPosXRef.current - 16}px`
  containerRef.current.style.top = `${catPosYRef.current - 16}px`
}

Now the cat can follow our mouse. We need to add a conditional statement to stop animation:

if (distance < 48) {
  return
}

Also, we want to let cat move in segments, so we need an extra variable: CAT_SPEED.

const CAT_SPEED = 10
const FRAME_DELAY = 100

...

catPosXRef.current += (diffX / distance) * CAT_SPEED
catPosYRef.current += (diffY / distance) * CAT_SPEED
...

function onAnimationFrame(timestamp) {
  if (!lastFrameTimestampRef.current) {
    lastFrameTimestampRef.current = timestamp
  }

  if (timestamp - lastFrameTimestampRef.current > FRAME_DELAY) {
    lastFrameTimestampRef.current = timestamp
    frame()
  }

  requestAnimationFrame(onAnimationFrame)
}

  • Images of different actions Now we can make cat follow our mouse. But it has one action. We want to let it have different actions, like running in different directions, sleeping, etc.

Let's say we have eight directions.

{
  N: "North",
  S: "South",
  W: "West",
  E: "East",
  NE: "North-East",
  SE: "South-East",
  SW: "South-West",
  NW: "North-West"
}

Whenever the mouse moves, we need to figure out which direction the cat is relative to the mouse, so we know which running pose to use.

let direction = ""

// Determine vertical direction (up/down)
if (diffY / distance > 0.5) {
  direction += "S" // South (down)
}
if (diffY / distance < -0.5) {
  direction += "N" // North (up)
}

// Determine horizontal direction (left/right)
if (diffX / distance > 0.5) {
  direction += "E" // East (right)
}
if (diffX / distance < -0.5) {
  direction += "W" // West (left)
}

// If there's no clear direction (angle too small), default to idle
if (!direction) {
  direction = "idle"
}

Once we have a direction, we need frame animation for the cat — otherwise having a direction is meaningless. First, let's group the sprite frames:

const spriteSets: Record<SpriteName, number[][]> = {
  /** Idle state - stationary */
  idle: [[-3, -3]],

  /** Alert state - the reaction when mouse movement is just detected */
  alert: [[-7, -3]],

  /** Scratching self - random idle action, 3-frame animation */
  scratchSelf: [
    [-5, 0],
    [-6, 0],
    [-7, 0],
  ],

  /** Scratching the north wall (top) - may trigger when the cat is near the top of the screen */
  scratchWallN: [
    [0, 0],
    [0, -1],
  ],

  /** Scratching the south wall (bottom) - may trigger when the cat is near the bottom of the screen */
  scratchWallS: [
    [-7, -1],
    [-6, -2],
  ],

  /** Scratching the east wall (right) - may trigger when the cat is near the right side of the screen */
  scratchWallE: [
    [-2, -2],
    [-2, -3],
  ],

  /** Scratching the west wall (left) - may trigger when the cat is near the left side of the screen */
  scratchWallW: [
    [-4, 0],
    [-4, -1],
  ],

  /** Tired state - transition state before sleeping */
  tired: [[-3, -2]],

  /** Sleeping state - random idle action, 2-frame loop animation */
  sleeping: [
    [-2, 0],
    [-2, -1],
  ],

  /** Moving north (up) - 2-frame loop */
  N: [
    [-1, -2],
    [-1, -3],
  ],

  /** Moving northeast - 2-frame loop */
  NE: [
    [0, -2],
    [0, -3],
  ],

  /** Moving east (right) - 2-frame loop */
  E: [
    [-3, 0],
    [-3, -1],
  ],

  /** Moving southeast - 2-frame loop */
  SE: [
    [-5, -1],
    [-5, -2],
  ],

  /** Moving south (down) - 2-frame loop */
  S: [
    [-6, -3],
    [-7, -2],
  ],

  /** Moving southwest - 2-frame loop */
  SW: [
    [-5, -3],
    [-6, -1],
  ],

  /** Moving west (left) - 2-frame loop */
  W: [
    [-4, -2],
    [-4, -3],
  ],

  /** Moving northwest - 2-frame loop */
  NW: [
    [-1, 0],
    [-1, -1],
  ],
}

For example, when the cat moves, we need two images played in a loop to give the sense of running. With only a single image, the cat would look like it was rollerblading — stuck in one pose. Then, in each animation tick, we play one frame:

/** Animation frame counter - used to cycle through multi-frame animations */
const frameCountRef = useRef(0)

const setSprite = (name: SpriteName, frame: number) => {
  // Get the frame array for the given state from spriteSets
  const spriteArray = spriteSets[name]

  // Use modulo to loop through multi-frame animations
  // e.g. if there are 2 frames, frame = 0,1,2,3... will cycle as 0,1,0,1...
  const sprite = spriteArray[frame % spriteArray.length]

  if (containerRef.current) {
    // Compute the CSS background-position value
    // sprite[0] is the X coordinate (negative), sprite[1] is the Y coordinate (negative)
    // Multiply by SPRITE_SIZE (32) to convert to pixel values
    containerRef.current.style.backgroundPosition = `${sprite[0] * SPRITE_SIZE}px ${sprite[1] * SPRITE_SIZE}px`
  }
}

setSprite(direction as SpriteName, frameCountRef.current)

Now, after the cat stops, it will perform various random actions. We'd also like the cat to have an "alert" pose when we start moving the mouse again — as if it suddenly notices something — before it starts running.

// Reset the idle animation (we're moving now, no longer idle)
idleAnimationRef.current = null
idleAnimationFrameRef.current = 0

/**
  * If we just came out of an idle state, show the alert state
  * This is a transitional animation that makes the cat look like it's "spotted" the mouse
  */
if (idleTimeRef.current > 1) {
  setSprite("alert", 0) // Show the alert sprite
  // Cap it at 7, then count down - used as a transition animation
  idleTimeRef.current = Math.min(idleTimeRef.current, 7)
  idleTimeRef.current -= 1
  return // This frame only shows alert; don't move
}

And that's it — we've built this cute little cat.