用状态机驱动CSS动画:原生JS实现猫咪互动场景
2026/9/23 3:30:14 网站建设 项目流程

想要真正把“温馨的家(Simon's Cat )”这个题目转成一个前端练手项目,关键不是画多像那只猫,而是把“猫在家里的日常互动”拆解成状态变化和动画表现。动画短片里经常出现的场景无非是这几类:猫饿了出去找吃的、困了走向猫窝、主人叫它时它换一个地方趴着。这些行为放到 Web 前端里,本质上是一套“目标点驱动”的状态机。下面用原生 HTML、CSS 和 JavaScript 实现一个可交互的客厅场景:绘制一个简笔小猫,点击地面任意位置时猫会走过去;点击猫窝时猫会走向猫窝并进入睡觉状态。项目不依赖框架,也不用图片素材,一个浏览器就能跑起来。

1. 先把“温馨的家(Simon's Cat )”还原成技术问题

1.1 动画短片的叙事逻辑与前端状态的对应关系

Simon's Cat 系列的最大特点是“猫没有台词,但行为非常容易被理解”。它饿了、困了、好奇了,都是通过动作表达出来的。放到前端项目里,这套表达体系可以抽象成状态机:

  • 待机状态(idle):猫安静地待在一个位置,尾巴偶尔摆动。
  • 走动状态(walk):猫从当前坐标移动到目标点。
  • 睡觉状态(sleep):猫趴在猫窝里,身体轻微起伏。

每个状态对应一套 CSS 动画,而状态之间的切换由 JavaScript 控制。理解这一点,动画短片就变成了一个可编程的交互场景。

1.2 从主题中拆出三个核心模块

要做这个项目,先拆模块,不要一上来就写代码。

第一块是场景。场景包括地板、墙壁、沙发、食物碗、猫窝这些静态元素。它们决定猫可以活动的范围,也决定用户点击后猫要移动到哪里。

第二块是角色。猫是一个独立对象,需要记录坐标、状态、面向方向。它不是一个单纯被 CSS 动画控制的静态 DOM 节点,而是由 JavaScript 驱动位置的状态节点。

第三块是控制循环。猫不能只靠 CSS 动画“自动播放”,它需要接收点击事件,再把目标坐标、移动速度、状态 class 关联起来,最终通过 requestAnimationFrame 更新位置。

三块分别对应 HTML、CSS、JavaScript,模块边界非常清楚。之后如果换成 Vue 或 React,也只是把三块逻辑移到组件里,数据模型可以保持不变。

1.3 技术选型:为什么用原生三件套

选型原则是:越接近底层,越容易看清原理。

使用原生 HTML、CSS、JavaScript 的好处有三个:

  • 没有依赖,不需要安装 npm 包,不存在版本冲突。
  • 动画可以拆成 CSS keyframes 和 JS 位置更新两层,学生更容易理解。
  • 不引入构建工具,降低环境成本,代码可以直接粘贴到本地运行。

不建议一开始就使用 React、Vue 或小游戏引擎,因为这些框架会把状态更新、渲染和事件绑定封装起来,遇到问题时很难定位。先把原生版本跑通,再迁移到框架,这样能在迁移过程中真正理解框架解决什么问题。

2. 搭建项目目录和静态场景

2.1 目录结构

创建一个空目录,命名为warm-house,结构如下:

warm-house/ ├── index.html ├── css/ │ └── style.css └── js/ └── main.js

这个结构很简单,但后续扩展时可以继续加assetsaudiolib等目录,不会影响现有逻辑。对于原生项目来说,这种“按文件类型分目录”的方式已经足够。

2.2 HTML 结构:先把家具和猫的位置占好

先写index.html。场景固定为 800x500 的矩形区域,里面放置沙发、食物碗和猫窝。猫是独立的#cat容器。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>温馨的家(Simon's Cat)</title> <link rel="stylesheet" href="css/style.css"> </head> <body> <div class="scene" id="scene"> <div class="sofa"></div> <div class="food-bowl"></div> <div class="cat-bed" id="catBed"></div> <div class="cat" id="cat"> <div class="cat-head"></div> <div class="cat-body"></div> <div class="cat-tail"></div> </div> </div> <p class="hint">点击房间任意位置,猫会走过去;点击猫窝,猫会睡觉。</p> <script src="js/main.js"></script> </body> </html>

这里的重点不是家具有多精美,而是结构关系要清晰:

  • 家具使用独立的空 div,方便 CSS 绘制,也方便后续替换成图片。
  • #cat是移动对象,JavaScript 只修改它的位置和状态 class,不直接操作内部元素。
  • 提示文案放在场景外,避免它挡住场景点击事件。
  • script放在 body 末尾,确保 DOM 已经加载完再绑定事件。

2.3 CSS 布局:用暖色背景和分层摆放地基

css/style.css。场景使用暖色渐变背景,制造“家里有灯光”的感觉。先重置默认样式,再设置场景尺寸。

* { margin: 0; padding: 0; box-sizing: border-box; } body { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #fdf3e7; font-family: Arial, "Microsoft YaHei", sans-serif; } .scene { position: relative; width: 800px; height: 500px; background: linear-gradient(to bottom, #f9e7d3 0%, #f2d5b5 60%, #e9c39a 100%); border: 1px solid #d9b898; border-radius: 12px; overflow: hidden; cursor: pointer; } .scene::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 120px; background: rgba(139, 90, 43, 0.12); pointer-events: none; }

说明一下,::after只用来制作地面阴影,让人感觉家具和猫站在同一层。真正的“地面”由背景渐变颜色分割出来,不需要单独画一个 rect。

家具样式:

.sofa { position: absolute; width: 180px; height: 90px; left: 40px; bottom: 30px; background: #c8a27c; border-radius: 20px 20px 8px 8px; box-shadow: inset 0 12px 0 rgba(255, 255, 255, 0.12); } .food-bowl { position: absolute; width: 56px; height: 24px; left: 420px; bottom: 26px; background: #f0d8a0; border-radius: 10px 10px 6px 6px; border: 2px solid #c39a6b; } .cat-bed { position: absolute; width: 140px; height: 70px; left: 620px; bottom: 30px; background: #b7a3d4; border-radius: 50px 50px 16px 16px; }

每件家具都使用绝对定位,并统一通过bottom贴地。这样做的好处是,后续 JavaScript 计算猫的移动目标时,可以让猫和家具使用同一套坐标基准,也就是“左上角 x 坐标 + 距离底部的高度”。

随后是猫的样式。为了不依赖外部图片,先用 CSS 画一只简笔小猫。这只猫由头、身体、尾巴三个子元素组成。

.cat { position: absolute; left: 200px; bottom: 80px; width: 100px; height: 80px; z-index: 10; } .cat-head { position: absolute; left: 0; bottom: 10px; width: 40px; height: 34px; background: #e8a86e; border-radius: 50%; } .cat-head::before, .cat-head::after { content: ""; position: absolute; top: -12px; width: 0; height: 0; border-left: 9px solid transparent; border-right: 9px solid transparent; border-bottom: 16px solid #e8a86e; } .cat-head::before { left: -2px; } .cat-head::after { right: -2px; } .cat-body { position: absolute; left: 30px; bottom: 8px; width: 66px; height: 40px; background: #e8a86e; border-radius: 50px 50px 20px 20px; } .cat-tail { position: absolute; right: -14px; bottom: 28px; width: 26px; height: 8px; background: #e8a86e; border-radius: 20px; transform-origin: left center; }

这是一个可运行的占位猫形,不需要像素级还原动画短片的角色。正式项目中换成图片或 SVG 序列帧即可,状态机的逻辑不受影响。

3. 用 CSS 实现猫的基础动画

3.1 待机时的尾巴摆动

猫在待机状态时,尾巴不应该完全静止,否则画面显得僵硬。给尾巴添加一个绕左侧原点的摆动动画。

.cat.state-idle .cat-tail { animation: tailSway 1.2s ease-in-out infinite; } @keyframes tailSway { 0%, 100% { transform: rotate(-12deg); } 50% { transform: rotate(18deg); } }

动画的关键点是只改变transform,不改变leftright这些会触发布局的属性。rotate发生的是合成层变换,性能更好,动画也更顺滑。

3.2 睡觉时的呼吸动画

进入睡觉状态后,尾巴可以隐藏,身体上下轻微缩放,模拟呼吸起伏。

.cat.state-sleep .cat-body { animation: breathe 2.4s ease-in-out infinite; transform-origin: center bottom; } .cat.state-sleep .cat-tail { display: none; } @keyframes breathe { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(1.08); } }

这里设置transform-origin: center bottom,让缩放从底部向上展开,更像胸腔呼吸,而不是整个身体从中心放大。

3.3 走路时的身体起伏

当猫处于行走状态时,身体轻微上下移动,形成步伐感。

.cat.state-walk .cat-body { animation: walkStep 0.4s ease-in-out infinite; } @keyframes walkStep { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }

真实项目中会把这段“身体起伏”替换成多帧精灵动画,比如左脚、右脚、身体拉扯三帧循环。这里用上下浮动代替,是为了先验证状态切换逻辑是否正确。

3.4 动画参数速查表

动画名称触发状态持续时长变化属性作用
tailSwayidle1.2stransform: rotate尾巴摆动,强调待机
breathesleep2.4stransform: scaleY身体呼吸起伏
walkStepwalk0.4stransform: translateY走动时身体上下移动

这套参数可以继续扩充。新动画只要不改变猫的整体位置坐标,就不会与 JavaScript 的位置更新冲突。

4. 用 JavaScript 控制猫的状态与移动

4.1 初始化对象与状态函数

js/main.js。先定义一个cat对象,集中保存猫的 DOM 节点、坐标、目标点、状态和移动速度。

const cat = { el: document.getElementById('cat'), state: 'idle', x: 200, y: 80, targetX: 200, targetY: 80, speed: 140, walking: false, }; const scene = document.getElementById('scene'); function setState(state) { cat.el.classList.remove('state-idle', 'state-walk', 'state-sleep'); cat.el.classList.add('state-' + state); cat.state = state; } setState('idle');

这里的x表示猫容器左侧距离场景左边的像素值,y表示猫容器底部距离场景底部的像素值。选择底部坐标而不是顶部坐标,是为了让猫和家具的基准一致,更容易判断是否站到猫窝附近。

4.2 点击场景:计算目标点并限制边界

点击场景时,需要把鼠标位置转换成“相对场景的 x 和 bottom”。同时要做边界限制,不能让猫走出场景。

function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function moveCatTo(targetX, targetY) { cat.targetX = clamp(targetX, 0, scene.clientWidth - cat.el.offsetWidth); cat.targetY = clamp(targetY, 0, scene.clientHeight - cat.el.offsetHeight); cat.walking = true; setState('walk'); } scene.addEventListener('click', (event) => { const rect = scene.getBoundingClientRect(); const clickX = event.clientX - rect.left; const clickY = event.clientY - rect.top; const bottom = scene.clientHeight - clickY - cat.el.offsetHeight / 2; moveCatTo(clickX - cat.el.offsetWidth / 2, bottom); });

这里做一个说明:如果不减去猫元素宽度和高度的一半,点击位置会被当成猫的左上角,猫实际落点会偏右、偏上。减去一半后,点击点大概在猫中心。

边界限制也很重要。如果把clickX直接传给moveCatTo,用户点击场景最右侧时,猫可能会有一半身体在场景外。通过 clamp 强制 x 和 y 保持在场景范围内。

4.3 猫窝特殊处理:走到后进入睡眠

点击场景时,如果点击位置落在猫窝上,希望猫先走向猫窝,到达后再睡觉。分两步处理:

第一步,给猫窝绑定点击事件,阻止事件继续冒泡到场景。

const bed = document.getElementById('catBed'); bed.addEventListener('click', (event) => { event.stopPropagation(); const bedRect = bed.getBoundingClientRect(); const sceneRect = scene.getBoundingClientRect(); const targetX = bedRect.left - sceneRect.left + bedRect.width / 2 - cat.el.offsetWidth / 2; const targetY = sceneRect.bottom - bedRect.top + cat.el.offsetHeight / 2; moveCatTo(targetX, targetY); });

第二步,在主循环中判断:如果猫已经走到目标点,且当前目标点是猫窝位置,则切换到睡眠状态。

为了简单判断,可以加一个全局标记:

let sleepAfterArrive = false;

猫窝点击事件里设置它为true。主循环检测到猫到达目标点时,再判断这个标记。

4.4 用 requestAnimationFrame 驱动移动

猫的移动不应该依靠 CSS 的transition,因为交互频繁后,transition 会产生拖影,而且无法精确控制速度。这里直接用requestAnimationFrame写主循环。

let lastTime = 0; let sleepAfterArrive = false; function tick(time) { const dt = (time - lastTime) / 1000; lastTime = time; if (cat.walking) { const dx = cat.targetX - cat.x; const dy = cat.targetY - cat.y; const distance = Math.hypot(dx, dy); if (distance < 2) { cat.x = cat.targetX; cat.y = cat.targetY; cat.walking = false; setState(sleepAfterArrive ? 'sleep' : 'idle'); sleepAfterArrive = false; } else { const step = cat.speed * dt; cat.x += (dx / distance) * step; cat.y += (dy / distance) * step; if (dx !== 0) { cat.el.style.transform = dx > 0 ? 'scaleX(1)' : 'scaleX(-1)'; } } cat.el.style.left = cat.x + 'px'; cat.el.style.bottom = cat.y + 'px'; } requestAnimationFrame(tick); } requestAnimationFrame(tick);

这段代码的关键逻辑有三点:

  • dt是上一帧到当前帧的时间差,单位是秒。用它计算移动距离,可以避免不同刷新率下的速度差异。
  • dx / distancedy / distance是单位方向向量,保证猫在任何方向上的移动速度一致。
  • scaleX(-1)用来翻转猫,让猫朝左走时不需要重新绘制图片。

这里没有使用 CSS 的transition,因为transition会干扰每一帧的位移,产生延迟感。移动逻辑完全交给 JavaScript 控制,CSS 动画只负责尾巴、身体起伏等局部表现。

4.5 完整 main.js 代码

把上面内容合并成完整的main.js

const cat = { el: document.getElementById('cat'), state: 'idle', x: 200, y: 80, targetX: 200, targetY: 80, speed: 140, walking: false, }; const scene = document.getElementById('scene'); const bed = document.getElementById('catBed'); let lastTime = 0; let sleepAfterArrive = false; function setState(state) { cat.el.classList.remove('state-idle', 'state-walk', 'state-sleep'); cat.el.classList.add('state-' + state); cat.state = state; } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function moveCatTo(targetX, targetY) { cat.targetX = clamp(targetX, 0, scene.clientWidth - cat.el.offsetWidth); cat.targetY = clamp(targetY, 0, scene.clientHeight - cat.el.offsetHeight); cat.walking = true; setState('walk'); } scene.addEventListener('click', (event) => { const rect = scene.getBoundingClientRect(); const clickX = event.clientX - rect.left; const clickY = event.clientY - rect.top; const bottom = scene.clientHeight - clickY - cat.el.offsetHeight / 2; moveCatTo(clickX - cat.el.offsetWidth / 2, bottom); }); bed.addEventListener('click', (event) => { event.stopPropagation(); const bedRect = bed.getBoundingClientRect(); const sceneRect = scene.getBoundingClientRect(); const targetX = bedRect.left - sceneRect.left + bedRect.width / 2 - cat.el.offsetWidth / 2; const targetY = sceneRect.bottom - bedRect.top + cat.el.offsetHeight / 2; sleepAfterArrive = true; moveCatTo(targetX, targetY); }); function tick(time) { const dt = (time - lastTime) / 1000; lastTime = time; if (cat.walking) { const dx = cat.targetX - cat.x; const dy = cat.targetY - cat.y; const distance = Math.hypot(dx, dy); if (distance < 2) { cat.x = cat.targetX; cat.y = cat.targetY; cat.walking = false; setState(sleepAfterArrive ? 'sleep' : 'idle'); sleepAfterArrive = false; } else { const step = cat.speed * dt; cat.x += (dx / distance) * step; cat.y += (dy / distance) * step; if (dx !== 0) { cat.el.style.transform = dx > 0 ? 'scaleX(1)' : 'scaleX(-1)'; } } cat.el.style.left = cat.x + 'px'; cat.el.style.bottom = cat.y + 'px'; } requestAnimationFrame(tick); } setState('idle'); requestAnimationFrame(tick);

需要注意,requestAnimationFrame(tick)放在了setState('idle')之后,第一次执行ticklastTime还是 0,因此第一帧的dt会很大。正常情况下这个值只会产生一次,之后会进入稳定状态;如果想更严谨,可以在第一次进入时直接赋值。实际体验中这个单次误差可以忽略,但如果追求准确,可以在tick第一行判断lastTime === 0时只赋值不更新位置。

5. 运行验证与性能检查

5.1 验证步骤

在浏览器中打开index.html,按顺序检查以下行为:

  1. 页面初始显示:猫出现在场景左侧,尾巴摆动。
  2. 点击场景右侧空白区域:猫开始朝右侧移动,移动时身体上下起伏。
  3. 猫到达右侧后停止,尾巴继续摆动,状态切回 idle。
  4. 点击猫窝:猫转向猫窝方向移动,到达后尾巴消失,身体呼吸起伏。
  5. 再次点击其他位置:猫恢复走动,睡眠动画停止。

如果这五步全部符合预期,说明核心状态机工作正常。

5.2 用 DevTools 检查性能

打开浏览器开发者工具的 Performance 面板,点击 Record,记录 2 到 3 秒操作,然后停止。重点看两个指标:

  • FPS 是否稳定在 50 到 60 帧左右。
  • Rendering 时间是否存在明显长条,说明是否有频繁布局计算。

当前这个 demo 规模很小,几乎不会出现性能问题。但通过这个操作,可以养成验证动画性能的习惯。如果后续猫和家具数量增加,就必须关注是否存在大量属性变化导致的性能瓶颈。

5.3 性能优化方向

当前代码每帧都在写入leftbottomleftbottom属于布局属性,写入后浏览器需要重新计算布局。对于这个几百行的 demo 影响不大,但放到大型项目中,或者目标设备是低端手机时,需要改成只使用transform

推荐做法是让猫容器定位在left: 0; bottom: 0,然后通过transform: translate3d(x, y, 0)来移动猫,这样只触发合成层更新,不触发布局重排。但要注意,使用translate3d后,坐标计算方式会从“left / bottom”变成“从容器左下角开始的偏移”,需要额外换算。这也是写 demo 时先用left / bottom、上线前再优化成本更低的原因。

6. 常见问题排查:猫不动、动画不对、状态错乱

6.1 点击没有反应

现象:点击场景后,猫完全不动。

可能原因

  • main.js在 DOM 加载之前执行,导致getElementById找不到节点。
  • 家具元素挡住了场景点击事件,比如猫窝虽然没有自己的事件处理,但它会接收点击并冒泡。
  • 场景尺寸为0,导致坐标计算错误。

检查方式

打开控制台,输入document.getElementById('cat'),查看是否返回元素。再点击场景,观察有没有 JavaScript 报错。

处理方式

确保script放在 body 末尾。检查场景的 CSS 是否有width: 0height: 0等异常。给家具绑定event.stopPropagation(),避免它们拦截场景点击。

6.2 猫出界或猫站到场景外

现象:点击场景边缘时,猫有一半身体跑到场景外。

可能原因:目标点坐标没有做边界限制,或者未减去猫本身的宽高。

检查方式:在场景点击事件中打印clickXclickYbottom,对照场景尺寸看是否越界。

处理方式:使用clamp函数限制 x 和 y,并在计算时减去cat.el.offsetWidth / 2cat.el.offsetHeight / 2

6.3 状态切换错乱

现象:猫走到猫窝后没有睡觉,或者点击其他位置后仍然保持睡眠状态。

可能原因setState函数没有把旧的sleepwalkclass 全部移除,或者sleepAfterArrive标记没有被重置。

检查方式:在控制台输入cat.el.classList,查看是否同时存在多个状态 class。

处理方式:统一在setState中移除全部状态 class,再添加新的。sleepAfterArrive在切换状态后必须重置为false

6.4 猫走路时上下跳动过于生硬

现象:猫移动时身体起伏明显,甚至出现上下瞬移。

可能原因walkStep动画与 JavaScript 中的位置更新发生冲突,尤其是当猫从站立瞬间切换到行走时,body 的 transform 被 CSS 关键帧接管,容易产生抖动。

检查方式:单独禁用 .cat 的 scaleX 翻转,看是否仍然抖动。

处理方式:把 CSS 动画放在不同子元素上,或者把走路动画简化成只移动头部和身体之间的相对位置。最彻底的方式是用 Canvas 精灵动画替换 CSS 起伏。

6.5 首帧 dt 过大导致猫瞬间移动

现象:第一次点击时,猫会瞬移一段距离。

可能原因lastTime初始值为 0,第一次执行requestAnimationFrame(tick)时,time可能是十几毫秒前的时间戳,但减 0 后得到一个很大的数值,导致dt变得巨大。

检查方式:在tick开头加日志输出dt,观察第一帧数值。

处理方式:在tick中检测lastTime === 0时直接赋值lastTime = time,不做移动结算。

function tick(time) { if (lastTime === 0) { lastTime = time; requestAnimationFrame(tick); return; } const dt = (time - lastTime) / 1000; lastTime = time; // ... }

6.6 常见问题速查表

问题现象可能原因检查方式处理建议
点击无反应JS 执行早于 DOM 加载控制台输入 document.getElementById('cat')script 移到 body 末尾
猫走出边界未做 clamp打印点击坐标使用 clamp 限制目标点
不睡觉sleepAfterArrive 未重置查看 classList 和标记setState 后重置标记
动画抖动CSS transform 与 JS 位置更新冲突禁用 scaleX 测试拆分动画到子元素
首帧瞬移lastTime 初始为 0打印 dt首帧只更新时间不移动

7. 从 demo 到可维护项目:发布前清单与扩展方向

7.1 发布前检查清单

这个项目如果只是本地练习,上面代码已经足够。但如果要部署到服务器,或者继续开发成完整页面,需要检查这组项目点:

  • 页面在窗口缩放时是否保持比例。当前场景是固定 800x500,实际使用时要考虑响应式,或使用 CSS transform 缩放场景。
  • 事件是否重复绑定。多次执行main.js会导致重复添加 listener,刷新页面时可能出现多次移动。
  • 鼠标点击时是否误触提示文字或页面其他区域。
  • 猫的坐标是否在窗口缩放后重新计算。可以用 ResizeObserver 或监听 resize 事件。
  • 动画资源是否有版权风险。如果使用 Simon's Cat 的截图或官方素材,只能用于学习研究,不能

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询