用状态机驱动CSS动画:原生JS实现猫咪互动场景
想要真正把“温馨的家Simons Cat ”这个题目转成一个前端练手项目关键不是画多像那只猫而是把“猫在家里的日常互动”拆解成状态变化和动画表现。动画短片里经常出现的场景无非是这几类猫饿了出去找吃的、困了走向猫窝、主人叫它时它换一个地方趴着。这些行为放到 Web 前端里本质上是一套“目标点驱动”的状态机。下面用原生 HTML、CSS 和 JavaScript 实现一个可交互的客厅场景绘制一个简笔小猫点击地面任意位置时猫会走过去点击猫窝时猫会走向猫窝并进入睡觉状态。项目不依赖框架也不用图片素材一个浏览器就能跑起来。1. 先把“温馨的家Simons Cat ”还原成技术问题1.1 动画短片的叙事逻辑与前端状态的对应关系Simons 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这个结构很简单但后续扩展时可以继续加assets、audio、lib等目录不会影响现有逻辑。对于原生项目来说这种“按文件类型分目录”的方式已经足够。2.2 HTML 结构先把家具和猫的位置占好先写index.html。场景固定为 800x500 的矩形区域里面放置沙发、食物碗和猫窝。猫是独立的#cat容器。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title温馨的家Simons Cat/title link relstylesheet hrefcss/style.css /head body div classscene idscene div classsofa/div div classfood-bowl/div div classcat-bed idcatBed/div div classcat idcat div classcat-head/div div classcat-body/div div classcat-tail/div /div /div p classhint点击房间任意位置猫会走过去点击猫窝猫会睡觉。/p script srcjs/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不改变left、right这些会触发布局的属性。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 / distance和dy / distance是单位方向向量保证猫在任何方向上的移动速度一致。scaleX(-1)用来翻转猫让猫朝左走时不需要重新绘制图片。这里没有使用 CSS 的transition因为transition会干扰每一帧的位移产生延迟感。移动逻辑完全交给 JavaScript 控制CSS 动画只负责尾巴、身体起伏等局部表现。4.5 完整 main.js 代码把上面内容合并成完整的main.jsconst 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)之后第一次执行tick时lastTime还是 0因此第一帧的dt会很大。正常情况下这个值只会产生一次之后会进入稳定状态如果想更严谨可以在第一次进入时直接赋值。实际体验中这个单次误差可以忽略但如果追求准确可以在tick第一行判断lastTime 0时只赋值不更新位置。5. 运行验证与性能检查5.1 验证步骤在浏览器中打开index.html按顺序检查以下行为页面初始显示猫出现在场景左侧尾巴摆动。点击场景右侧空白区域猫开始朝右侧移动移动时身体上下起伏。猫到达右侧后停止尾巴继续摆动状态切回 idle。点击猫窝猫转向猫窝方向移动到达后尾巴消失身体呼吸起伏。再次点击其他位置猫恢复走动睡眠动画停止。如果这五步全部符合预期说明核心状态机工作正常。5.2 用 DevTools 检查性能打开浏览器开发者工具的 Performance 面板点击 Record记录 2 到 3 秒操作然后停止。重点看两个指标FPS 是否稳定在 50 到 60 帧左右。Rendering 时间是否存在明显长条说明是否有频繁布局计算。当前这个 demo 规模很小几乎不会出现性能问题。但通过这个操作可以养成验证动画性能的习惯。如果后续猫和家具数量增加就必须关注是否存在大量属性变化导致的性能瓶颈。5.3 性能优化方向当前代码每帧都在写入left和bottom。left和bottom属于布局属性写入后浏览器需要重新计算布局。对于这个几百行的 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: 0、height: 0等异常。给家具绑定event.stopPropagation()避免它们拦截场景点击。6.2 猫出界或猫站到场景外现象点击场景边缘时猫有一半身体跑到场景外。可能原因目标点坐标没有做边界限制或者未减去猫本身的宽高。检查方式在场景点击事件中打印clickX、clickY、bottom对照场景尺寸看是否越界。处理方式使用clamp函数限制 x 和 y并在计算时减去cat.el.offsetWidth / 2和cat.el.offsetHeight / 2。6.3 状态切换错乱现象猫走到猫窝后没有睡觉或者点击其他位置后仍然保持睡眠状态。可能原因setState函数没有把旧的sleep、walkclass 全部移除或者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 事件。动画资源是否有版权风险。如果使用 Simons Cat 的截图或官方素材只能用于学习研究不能