menu
首页文章相册工具关于
search
...

添加一个跟随鼠标移动的小猫

2025/12/15
eye-

背景

我在网上发现了一个可爱的小猫,这是它的源码地址:https://github.com/adryd325/oneko.js。由于它是基于 JavaScript 实现的,我用 React 重新复刻了一下。

实现

首先,我们在屏幕上放一只小猫。这里使用的是一张 sprite 雪碧图,可以在这里下载:

"use client"

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

现在屏幕上已经有一只小猫了!我们来调整它的位置,让它显示在左上角。

  <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
      }}
    />

为了让小猫能够"追"到鼠标,我们需要监听鼠标的移动。

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)
  }
}, [])

现在设想一下:我们有一个目标点 (x, y),需要让小猫到达这里,要怎么实现呢?答案是:用动画。

function frame() {
  // 小猫移动
}

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

requestAnimationFrame(onAnimationFrame)

每一帧动画里,都让小猫向鼠标位置靠近一点。

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), // 至少为 16
  window.innerWidth - 16 // 不超过窗口宽度 - 16
)
catPosYRef.current = Math.min(
  Math.max(16, catPosYRef.current),
  window.innerHeight - 16
)

if (containerRef.current) {
  // 小猫以中心点定位(32px / 2 = 16px)
  containerRef.current.style.left = `${catPosXRef.current - 16}px`
  containerRef.current.style.top = `${catPosYRef.current - 16}px`
}

现在小猫可以跟随鼠标移动了。为了避免它一直抖动贴着鼠标,我们加一个条件判断,离得够近就停下来:

if (distance < 48) {
  return
}

另外,我们希望小猫是一段一段地移动(而不是每帧都动一丁点),所以再引入一个速度变量 CAT_SPEED,以及帧间隔 FRAME_DELAY:

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)
}
  • 不同动作的图片 到这里小猫已经可以跟随鼠标了,但它只有一个动作。我们希望它能有不同的动作,比如朝不同方向奔跑、睡觉等等。

假设我们有八个方向:

{
  N: "北",
  S: "南",
  W: "西",
  E: "东",
  NE: "东北",
  SE: "东南",
  SW: "西南",
  NW: "西北"
}

当我们移动鼠标的时候,我们需要去判断小猫位于鼠标的方向,这样才能控制跑过来的时候是以什么形态。

let direction = ""

// 判断垂直方向(上下)
if (diffY / distance > 0.5) {
  direction += "S" // 南(下)
}
if (diffY / distance < -0.5) {
  direction += "N" // 北(上)
}

// 判断水平方向(左右)
if (diffX / distance > 0.5) {
  direction += "E" // 东(右)
}
if (diffX / distance < -0.5) {
  direction += "W" // 西(左)
}

// 如果没有明确方向(角度太小),默认为 idle
if (!direction) {
  direction = "idle"
}

有了方向之后,我们需要给小猫进行帧动画,不然有了方向也没有意义。 首先,我们先给帧进行分组:

const spriteSets: Record<SpriteName, number[][]> = {
  /** 空闲状态 - 静止不动 */
  idle: [[-3, -3]],

  /** 警觉状态 - 刚发现鼠标移动时的反应 */
  alert: [[-7, -3]],

  /** 抓自己 - 空闲时的随机动作,3帧动画 */
  scratchSelf: [
    [-5, 0],
    [-6, 0],
    [-7, 0],
  ],

  /** 抓北墙(上方) - 当小猫靠近屏幕顶部时可能触发 */
  scratchWallN: [
    [0, 0],
    [0, -1],
  ],

  /** 抓南墙(下方) - 当小猫靠近屏幕底部时可能触发 */
  scratchWallS: [
    [-7, -1],
    [-6, -2],
  ],

  /** 抓东墙(右侧) - 当小猫靠近屏幕右侧时可能触发 */
  scratchWallE: [
    [-2, -2],
    [-2, -3],
  ],

  /** 抓西墙(左侧) - 当小猫靠近屏幕左侧时可能触发 */
  scratchWallW: [
    [-4, 0],
    [-4, -1],
  ],

  /** 疲惫状态 - 睡觉前的过渡状态 */
  tired: [[-3, -2]],

  /** 睡觉状态 - 空闲时的随机动作,2帧循环动画 */
  sleeping: [
    [-2, 0],
    [-2, -1],
  ],

  /** 向北移动(向上) - 2帧循环 */
  N: [
    [-1, -2],
    [-1, -3],
  ],

  /** 向东北移动 - 2帧循环 */
  NE: [
    [0, -2],
    [0, -3],
  ],

  /** 向东移动(向右) - 2帧循环 */
  E: [
    [-3, 0],
    [-3, -1],
  ],

  /** 向东南移动 - 2帧循环 */
  SE: [
    [-5, -1],
    [-5, -2],
  ],

  /** 向南移动(向下) - 2帧循环 */
  S: [
    [-6, -3],
    [-7, -2],
  ],

  /** 向西南移动 - 2帧循环 */
  SW: [
    [-5, -3],
    [-6, -1],
  ],

  /** 向西移动(向左) - 2帧循环 */
  W: [
    [-4, -2],
    [-4, -3],
  ],

  /** 向西北移动 - 2帧循环 */
  NW: [
    [-1, 0],
    [-1, -1],
  ],
}

举个例子,如果希望小猫移动,是不是需要两张图片,循环播放,这样才能体现出奔跑的感觉。如果只有一个图片,那小猫就像踩着轮滑过来的,一动不动。 然后,我们在每一次动画的时候,播放一帧:

/** 动画帧计数器 - 用于循环播放多帧动画 */
const frameCountRef = useRef(0)

const setSprite = (name: SpriteName, frame: number) => {
  // 从 spriteSets 中获取对应状态的帧数组
  const spriteArray = spriteSets[name]

  // 使用取模运算循环播放多帧动画
  // 例如:如果有 2 帧,frame = 0,1,2,3... 会循环显示 0,1,0,1...
  const sprite = spriteArray[frame % spriteArray.length]

  if (containerRef.current) {
    // 计算 CSS background-position 的值
    // sprite[0] 是 X 坐标(负数),sprite[1] 是 Y 坐标(负数)
    // 乘以 SPRITE_SIZE (32) 转换为像素值
    containerRef.current.style.backgroundPosition = `${sprite[0] * SPRITE_SIZE}px ${sprite[1] * SPRITE_SIZE}px`
  }
}

setSprite(direction as SpriteName, frameCountRef.current)

好了,现在小猫就有奔跑的感觉了。现在再增加一些其他动作,否则看上去很单调。当小猫跑过来之后,我们可以让小猫做一些睡觉、抓痒之类的动作。 我们统一放到idle函数里面:

const idle = () => {
  // 增加空闲时间计数(每调用一次 +1)
  idleTimeRef.current += 1

  /**
    * 随机触发空闲动画的逻辑
    * 条件:
    * 1. 空闲时间超过 10 帧(约 1 秒)
    * 2. 随机数等于 0(约 1/200 的概率,即每约 20 秒触发一次)
    * 3. 当前没有正在播放的空闲动画
    */
  if (
    idleTimeRef.current > 10 &&
    Math.floor(Math.random() * 200) === 0 &&
    idleAnimationRef.current === null
  ) {
    // 基础可用的空闲动画列表
    const availableIdleAnimations: SpriteName[] = [
      "sleeping", // 睡觉
      "scratchSelf", // 抓自己
    ]

    // 根据小猫位置,添加可用的抓墙动画
    // 如果小猫靠近某个边界,就可以抓那个方向的墙

    if (catPosXRef.current < 32) {
      // 靠近左边界,可以抓左墙
      availableIdleAnimations.push("scratchWallW")
    }
    if (catPosXRef.current < 32) {
      // 靠近上边界,可以抓上墙
      availableIdleAnimations.push("scratchWallN")
    }
    if (catPosXRef.current > window.innerWidth - 32) {
      // 靠近右边界,可以抓右墙
      availableIdleAnimations.push("scratchWallE")
    }
    if (catPosXRef.current > window.innerHeight - 32) {
      // 靠近下边界,可以抓下墙
      availableIdleAnimations.push("scratchWallS")
    }

    // 从可用的动画列表中随机选择一个
    idleAnimationRef.current =
      availableIdleAnimations[
        Math.floor(Math.random() * availableIdleAnimations.length)
      ]
  }

  // 获取当前空闲动画状态
  const idleAnimation = idleAnimationRef.current
  const idleAnimationFrame = idleAnimationFrameRef.current

  // 根据空闲动画类型,播放对应的动画
  switch (idleAnimation) {
    case "sleeping":
      // 睡觉动画
      if (idleAnimationFrame < 8) {
        // 前 8 帧显示"疲惫"状态(准备睡觉)
        setSprite("tired", 0)
        break
      }
      // 之后每 4 帧切换一次睡觉动画帧(2 帧循环)
      setSprite("sleeping", Math.floor(idleAnimationFrame / 4))
      if (idleAnimationFrame > 192) {
        // 192 帧后(约 19.2 秒)结束睡觉,重置动画
        resetIdleAnimation()
      }
      break

    case "scratchWallN":
    case "scratchWallS":
    case "scratchWallE":
    case "scratchWallW":
    case "scratchSelf":
      // 抓墙和抓自己的动画处理
      // 直接播放对应动画的帧
      setSprite(idleAnimation, idleAnimationFrame)
      if (idleAnimationFrame > 9) {
        // 10 帧后结束动画,重置
        resetIdleAnimation()
      }
      break

    default:
      // 默认状态:显示普通空闲动画
      setSprite("idle", 0)
      return
  }

  // 增加空闲动画帧数
  idleAnimationFrameRef.current += 1
}

if (distance < 48 || distance < CAT_SPEED) {
  idle() // 调用空闲处理函数
  return
}

现在小猫停下来之后就会有各种随机的动作了。我们还希望当我们继续移动的时候,小猫首先要有一个警觉的动作,好像感到突然发生了什么,然后才去奔跑。

// 重置空闲动画(因为开始移动了,不再是空闲状态)
idleAnimationRef.current = null
idleAnimationFrameRef.current = 0

/**
  * 如果刚从空闲状态恢复,显示 alert 状态
  * 这是一个过渡动画,让小猫看起来像是"发现"了鼠标
  */
if (idleTimeRef.current > 1) {
  setSprite("alert", 0) // 显示警觉状态的精灵图
  // 限制最大值为 7,然后递减,用于过渡动画
  idleTimeRef.current = Math.min(idleTimeRef.current, 7)
  idleTimeRef.current -= 1
  return // 这一帧只显示 alert,不移动
}

至此,我们就完成了这个可爱的小猫。

英文缩写项目初始化与工具链
目录
  • 背景
  • 实现