一台抓娃娃机摆在面前,很多人的第一反应是:这种东西不得用游戏引擎才能写吗?但如果你只用 Vue 3 的响应式系统加一点坐标计算,同样能做出一台能下爪、能夹娃娃、能概率出货的机器。我经常跟练前端的同学说,与其继续写增删改查列表,不如花一个周末把抓娃娃机做了。它不依赖后端、不依赖第三方物理库,纯组件、纯状态、纯动画,做完了你对 Vue 的理解会上一个台阶。
这篇文章会从零开始拆解一台 Vue 抓娃娃机的完整实现思路。不是贴一套能跑就行的代码,而是把每一步设计背后的原因、动画时序的控制方式、抓取判定的计算逻辑、以及我实际调试时踩过的坑都讲清楚。适合已经学完 Vue 基础、想找一个“有挑战但不至于劝退”的练手项目的同学,也适合想在简历里放一个交互类项目的前端开发者。
1. 娃娃机到底在练什么:一个反直觉的前端练手项目
1.1 一个小游戏页面藏了多少前端难题
很多人觉得抓娃娃机是休闲小游戏,代码量应该不大。真正动手之后才发现,它几乎把前端交互里最容易写崩的几个点全占了。
首先是时序控制。爪子从待机位移动到目标上方,然后下爪、停顿、合爪、上提、回程、松手,这一整套动作必须严格按顺序执行,任何一步被重复触发都会导致动画错乱。程序里有五种以上状态,状态之间还需要互斥,用户狂点按钮也不能打断流程。第二是坐标计算。爪子要移动到指定格子,娃娃被夹住后要跟着爪子走,松手后要落到出口框,每一步都离不开坐标换算和偏移量修正。第三是动效的平滑度。爪子不是整块平移,下爪时吊绳要拉长、爪子要张开,抓住娃娃之后爪子要合拢、娃娃要被“拽”起来,这些用 CSS transition 硬凑很容易卡顿。
所以抓娃娃机不是一个“画个界面就行”的项目,它是一个把组件化、状态管理、动画协调、碰撞判断全部串起来的综合练习。做完这一台,再回头看 Vue 官方文档里的响应式原理和生命周期,体会完全不一样。
1.2 技术选型:为什么是 Vue 3 加纯坐标驱动
我推荐用 Vue 3 的 Composition API 来做这个项目,而不是 Vue 2 或写原生 JS。原因很直接:抓娃娃机里所有物体的位置、状态、动画进度都是动态数据,Vue 3 的响应式系统处理这类“数据驱动视图”的场景非常顺手。你用ref存一个爪子坐标,模板里直接绑定到style上的left和top,坐标一变,爪子就动了,不需要手动去操作 DOM。
至于为什么不用游戏引擎,比如 Phaser 或者 Cocos,我是故意不用的。抓娃娃机的物理规则极其简单,爪子垂直下移、垂直上提,娃娃要么跟着走要么原地不动,根本不需要重力、摩擦力、碰撞体这类复杂模拟。用游戏引擎反而会把重心带偏,让你把时间花在学习引擎 API 上。我建议整个项目只用三样东西:Vue 3 的响应式系统、CSS 的transition或 Web Animations API、加一个简单的 requestAnimationFrame 工具函数。足够了。
1.3 整机运行状态总览
开工之前,先把机器全流程的状态列清楚,后面所有代码都是围绕这套状态来写的:
idle:待机。爪子停在初始位置,玩家可以通过方向键控制左右、前后移动。dropping:下爪。爪子从当前位置垂直下降到娃娃堆上方。grabbing:合爪。爪子闭合,同时决定“有没有抓住娃娃”。lifting:上提。若抓住,娃娃跟着爪子一起上升;若没抓住,空爪上升。returning:回程。爪子移动到出口框上方。releasing:松手。若出货,娃娃从出口框掉入取物口;若未出货,娃娃在出口框位置播放滑落动画,回到娃娃堆。reset:复位。爪子回到初始姿态,状态切回idle。
这套状态机是整个项目的脊柱。先把它写在纸上,再开始写组件,你会少走很多弯路。
2. 先摆清骨架:机器、爪子、娃娃的根本关系
2.1 组件树划分与各自职责
一个娃娃机界面在视觉上很饱满,但拆成组件之后其实很清爽。我一般这样划分:
App └── GameMachine ├── MachineFrame // 外壳、玻璃窗、顶灯、底板 ├── ClawUnit // 爪子整体:吊绳、横臂、左右爪钳 │ ├── ClawCable // 吊绳,长度随下爪动态变化 │ └── ClawPincers // 左右爪钳,控制张开与闭合 ├── PrizeArea // 娃娃堆区域,循环渲染若干 DollItem │ └── DollItem // 单个娃娃,负责自身位置和形态 ├── ControlPanel // 方向键、下爪按钮、投币按钮 └── ResultPopup // 中奖提示浮层这个划分的关键思路是:ClawUnit 和 DollItem 是平级关系,不是父子关系。很多初学者会把娃娃放在爪子组件里面渲染,这样爪子一动,娃娃跟着歪,坐标完全乱掉。正确做法是它们都挂在 GameMachine 这个父组件下,共享同一套坐标系统,彼此通过事件和状态通信。
2.2 统一坐标系的三个约定
抓娃娃机之所以不容易写,是因为涉及多层定位:爪子相对舞台定位,娃娃相对奖池定位,出口框又单独一个位置。如果各算各的,后面碰撞检测和抓取跟随一定会乱套。我在项目里定下三条硬性约定。
第一,所有物体的坐标都以stage(娃娃机内部空间)为基准。stage是一个position: relative的容器,爪子、娃娃、出口框全部用position: absolute定位,坐标含义统一是“相对舞台左上角的偏移”。第二,所有娃娃的坐标用中心点表示,而不是用左上角。因为碰撞检测、抓取跟随都需要中心点对齐,左上角定位会导致偏移计算非常别扭。第三,百分比与像素不混用。我规定坐标一律用像素值,方便计算和调试;尺寸方面则允许用百分比,但只用在舞台宽度高度这类固定属性上。
这三点建议提前写进项目注释里。我实际开发中遇到的很多“娃娃漂移”“爪子抓空”问题,最后追查下来都是坐标基准不统一造成的。
2.3 用 CSS 变量管理动态样式
爪子张开的宽度、吊绳的长度、出口框的闪烁效果,这些动态样式如果全部用 Vue 绑定内联样式会显得很啰嗦。我是用 CSS 变量配合 Vue 的响应式数据来做的。
<template> <div class="claw-pincers" :style="{ '--open-angle': clawOpenAngle + 'deg', '--clamp-speed': clampSpeed + 's', }" /> </template>在 CSS 里直接定义:
.claw-pincers { transform: rotate(var(--open-angle)); transition: transform var(--clamp-speed) ease-in-out; }这样有一个很实际的好处:动画参数集中在 CSS 里调整,微调手感和动效节奏的时候不需要动 JavaScript 逻辑。我后面调“爪子夹紧”的视觉效果时,只改了--open-angle的值,代码层面完全没动。
3. 爪子动画的完整状态机:从下爪到松手的每一帧
3.1 用状态枚举把流程定死
抓娃娃机的动效从外表看很流畅,但内部其实就是一台“按步骤执行”的状态机。我会在代码里定义一个枚举,把流程彻底定死,避免任何一个分支漏掉:
const ClawState = { IDLE: 'idle', DROPPING: 'dropping', GRABBING: 'grabbing', LIFTING: 'lifting', RETURNING: 'returning', RELEASING: 'releasing', RESET: 'reset', }整个机器的工作方式很简单:当前状态决定当前能做什么。比如只有IDLE状态下,玩家按方向键才能移动爪子,点“下爪”按钮才有效;其他状态一律忽略操作。这一步能直接堵住“动画没结束就重复触发”的经典 bug。
3.2 状态流转代码的写法:异步串联与互斥
状态之间是有顺序的,用异步函数来描述最自然。我用一个Promise加requestAnimationFrame封装了动画工具函数,让每个状态阶段可以被await等待。
function animateTo(targetX, targetY, duration, setPosition) { return new Promise((resolve) => { const startX = currentPosition.x const startY = currentPosition.y const startTime = performance.now() function frame(now) { const progress = Math.min((now - startTime) / duration, 1) // 使用 easeInOut 曲线,爪子动起来更像真实机械 const eased = progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2 setPosition({ x: startX + (targetX - startX) * eased, y: startY + (targetY - startY) * eased, }) if (progress < 1) { requestAnimationFrame(frame) } else { resolve() } } requestAnimationFrame(frame) }) }那整个“玩一局”的流程就是一段线性代码:
async function playRound() { if (clawState.value !== ClawState.IDLE) return clawState.value = ClawState.DROPPING await animateTo(claw.x, targetDropY, 600, updateClaw) clawState.value = ClawState.GRABBING const isWin = await tryGrab() // 合爪 + 判定 await delay(300) clawState.value = ClawState.LIFTING await animateTo(claw.x, initialY, 500, updateClaw) clawState.value = ClawState.RETURNING await animateTo(exitX, exitY, 800, updateClaw) clawState.value = ClawState.RELEASING await releaseDoll(isWin) // 松手动画 await delay(300) clawState.value = ClawState.RESET clawState.value = ClawState.IDLE }这段代码最大的好处是可读性极强:整个一局游戏的步骤一眼扫完,中间任何一步卡住,看状态值就能定位。你可能会问,动画过程中用户又点了一下按钮怎么办?由于playRound开头有一行if (clawState.value !== ClawState.IDLE) return,之后的任何点击都不会重复触发流程,这就是互斥锁的简单实现。
3.3 爪子张合与姿态调整
爪子的张合我是单独处理的,没有放到主流程里面去阻塞。因为张合动作持续时间很短,而且在“合爪”这个瞬间,玩家注意力全在爪尖上,这里非常值得精心调一下。
我用两个 CSS 变量控制左右爪钳的旋转角度:
.claw-pincer-left { transform-origin: top right; } .claw-pincer-right { transform-origin: top left; }关键点是transform-origin必须要对准爪钳的铰链位置。左边爪钳绕着右上角旋转,右边爪钳绕着左上角旋转,这样旋转时候看起来才像合拢,而不是整个爪钳平移。
实际运行时,我让clawOpenAngle从 40 度快速缩到 5 度,合爪瞬间加一个 60ms 的停顿,视觉上会出现“死死夹住”的力度感。这个停顿很重要,没有它,合爪和上提连在一起,玩家根本看不清夹到了什么,游戏体验会打折扣。
4. “抓起来”的真相:碰撞检测与坐标换算
4.1 为什么不需要物理引擎
新手写到这里很容易想复杂:爪子下去要跟娃娃算碰撞体积,夹住之后要算摩擦力,上提的时候要考虑娃娃会不会掉……一旦开始想这些,项目就会失控。
抓娃娃机的真相是:它并不是真实物理模拟,而是先决定结果,再演出过程。机台在爪子闭合的那一刻就已经决定了玩家这次能不能抓到,后面的动画只是把结果表演出来。这样设计既能让游戏体验可控,也能大幅简化代码。
所以所谓的碰撞检测,在实现上只是一个“目标点判断”:当爪子合拢时,检查爪子中心点落在哪个娃娃附近,如果落中且命中判定通过,就说“抓到”了;否则就是空抓。
4.2 抓取判定:闭合瞬间的点到爪心
我的实现是,在爪子下降到指定深度之后,做一次遍历检测:
function tryGrab() { const clawCenter = { x: claw.x, y: claw.y } let targetDoll = null let minDistance = Infinity for (const doll of dolls.value) { if (doll.won) continue // 只看中心点距离,不看整个包围盒 const dist = Math.hypot(doll.centerX - clawCenter.x, doll.centerY - clawCenter.y) if (dist < minDistance) { minDistance = dist targetDoll = doll } } // 先看物理上是否足够近 const closeEnough = minDistance < GRAB_RADIUS // 再看概率是否命中 const lucky = Math.random() < currentWinRate() if (closeEnough && lucky) { attachDoll(targetDoll) return true } return false }我用中心点距离而非包围盒相交作为判断依据,是因为娃娃堆叠时包围盒会重合,用相交判断容易一次命中多个,而玩家视觉上只能看到爪子夹住一个娃娃。中心点距离则更符合直觉:爪子尖到哪个娃娃的中心最近,夹到的就是谁。
有一点值得单独说明:概率判定和距离判定是分开的。即使爪子完美对准了某个娃娃,也不保证一定出货,这对应了真实娃娃机“夹住了但抓不紧”的体验。但反过来,距离没对准,概率再高也不能出货,这保证了玩家怒骂“我都对准了凭什么抓不到”的边界不会过度越界。
4.3 抓取成功后的跟随与视觉偏移
抓到娃娃之后,娃娃必须跟着爪子移动。这里如果直接把娃娃坐标设置成爪子坐标,会有一个明显问题:娃娃看起来像是焊死在爪子上,没有真实感。真实机器里娃娃是被爪钳夹住的,位置应当在爪钳正下方偏内一点。
所以我引入了一个固定的抓取偏移量:
const GRAB_OFFSET = { x: 0, y: 32 }在attachDoll之后,每帧更新爪子坐标的时候,同步更新这个娃娃的坐标:
function updateClaw(nextPos) { claw.x = nextPos.x claw.y = nextPos.y if (currentDoll.value) { currentDoll.value.centerX = nextPos.x + GRAB_OFFSET.x currentDoll.value.centerY = nextPos.y + GRAB_OFFSET.y } }这个偏移量的存在还让上提动画更自然:爪子已经升到半空,娃娃还悬在爪尖下方,视觉上悬空感刚好合适。
4.4 松手位置和目标出口的对应
松手不是随便一个位置都可以,必须等爪子移动到出口框正上方。我单独设了一个出口点位exit,RETURNING状态就是把爪子从当前位置移动到这个点。等移动结束后,才进入RELEASING。
出货时,娃娃从出口框直接下坠到取物口;未出货时,娃娃则要从出口框位置“弹回”娃娃堆。这里的弹回我用了一个简单的抛物线动画:
function releaseDoll(isWin) { if (isWin) { // 直接掉落到取物口,简单下移 return animateTo(exit.x, dropY, 400, updateDoll) } // 未出货:从出口框弹回娃娃堆顶部 const target = { x: exit.x, y: startupDollY } return animateTo(target.x, target.y, 600, updateDoll) }很多初学者忽略这一点,把未出货处理成娃娃原地凭空消失。玩家视角里,一个明明已经夹到出口口沿的娃娃突然消失,会非常出戏。弹回动画虽然只有 600ms,但对游戏真实感的提升非常关键。
5. 概率与体验:让玩家觉得“差一点”的调参艺术
5.1 概率不是“随机”两个字那么简单
抓娃娃机的随机概率,最大的痛点是玩家体验不稳定。如果你直接写Math.random() < 0.3,会出现连续五局不中或者连续三局都中的极端情况。真实机台是绝不允许连续空抓次数太多的,否则玩家会暴躁离场。
我加了两层保障。第一层是保底计数:用一个missCount记录连续空抓次数,每次空抓后基础命中率按比例提升,直到保底必中一次。
function currentWinRate() { return Math.min(0.8, BASE_WIN_RATE + missCount.value * 0.08) }第二层是出货冷却:中了一次奖之后,短时间内命中的概率大幅下调,避免一台机器被同一个玩家连续“薅羊毛”。这套机制在真实街机厅里也很常见,通常是软概率加硬保底。
5.2 “夹得稳但滑落”的骗术
做过娃娃机的人都知道,最精妙的部分不是“抓到”,而是“抓不到但看起来马上要抓到了”。我有意把概率判定提前到合爪之前:即使判定结果为没抓到,爪子闭合瞬间仍然会播放“爪子夹紧”“娃娃被提起一小段”的动画,然后在上提过程中让娃娃脱落。
具体实现是:判定未击中时,attachDoll不会真正绑定娃娃,但会绑一个临时的“拖影”占位符,让它跟着爪子上升 100ms 后滑落回原位。玩家看到的是“明明差一点就夹上来了”,实际上从头到尾就没机会。这种视觉欺骗在所有竞技类游戏里都是常规操作,抓娃娃机只是把它用得很直白。
这一层骗术建议配合音效使用。没有音效的娃娃机是没有灵魂的:合爪“咔嗒”声、上提时马达声、松手时“咚”的掉落声,这三段声音加上之后,玩家对“差一点”的遗憾感会被放大很多,游戏沉浸感会上一个台阶。
5.3 难度曲线的调节
难度曲线我用一个简单的字段控制:前几局命中率偏高,后面逐步回落。因为新玩家第一次玩,如果不给他一点甜头,很容易直接放弃;但让他第一局就大赚,后面又会失去挑战感。合理的曲线是前 3 局基础命中率 0.45,之后降为 0.25,再用保底机制兜底。
你可以把这个参数暴露在控制面板上,方便自己测试。我在调试时就经常把命中率临时调到 0.95,专门验证“命中后娃娃跟随”这条路径;再把命中率调到 0,专门验证空抓和滑落。建议所有做概率系统的同学都这么做,不要靠运气去测试。
6. 实战踩坑记录:位置漂移、动画时序错乱与复现
6.1 transform-origin 导致的抓取漂移
第一次跑通全流程时,我遇到的第一个诡异问题是:爪子张开和闭合的时候,整个爪子会发生肉眼可见的横向跳动。爪子明明应该绕着铰链旋转,结果看起来像在左右平移。
排查过程非常痛苦,最后定位到是transform-origin没有设置。transform: rotate()默认绕元素中心旋转,而我画的爪钳是左右对称的两片,默认旋转中心在各自元素的正中心,所以一旋转,爪尖就会往中间猛甩或往外甩。
解决办法就是前面提到的:
.claw-pincer-left { transform-origin: top right; } .claw-pincer-right { transform-origin: top left; }这个坑看起来小,但如果不理解transform-origin的几何意义,可能调一晚上都看不出来问题在哪。我的建议是:任何涉及多部件组合旋转的图形,先把每个部件的旋转原点画出来,再写样式。
6.2 快速连点带来的状态错乱
另一个高频 bug 是连点按钮导致状态错乱:玩家狂按下爪键,爪子还没下到底,第二段动画又被触发,屏幕上的爪子像抽风一样抖动。
我在状态机那节已经做了一层拦截,但实际测试中发现还不够。因为playRound的异步流程中,所有await都是独立的,一旦两个流程交错执行(比如玩家在LIFTING阶段按下复位键),互斥状态可能被绕过。所以在所有可能被外部操作触发的函数里,我都重复检查了状态:
function onDrop() { if (clawState.value !== ClawState.IDLE) return // 防御性检查,避免未来改动漏掉 isRoundActive.value = true playRound() }更稳妥的做法是给整局游戏加一个isRoundActive布尔锁,所有入口函数先判断这个锁,局内流程结束时再释放。这个锁能防御住未来新增入口时忘记判断状态的问题。
6.3 把内部状态可视化,缩短调试链路
抓娃娃机这类项目,大部分 bug 不是“代码报错”,而是“状态不对”。比如娃娃走到了不该在的位置,爪子动画卡在中途,这些情况 console 日志很难追踪。我的经验是:开发阶段直接把这些内部状态画到页面上。
我在页面角落放了一个调试面板,实时显示当前状态值、爪子坐标、目标坐标、最近一次命中判定记录。有用到什么程度呢?有一次我怀疑回程动画路径不对,打开调试面板后发现爪子坐标一路飙到了 3000 多像素,问题立刻定位到animateTo的参数传反了。这种问题看动画根本看不出来,看数字一下就知道。
调试面板的代码也很简单,就是几行模板:
<pre class="debug-panel"> state: {{ clawState }} claw: ({{ claw.x }}, {{ claw.y }}) target: ({{ target.x }}, {{ target.y }}) lastGrab: {{ lastGrabResult }} </pre>正式发布时用import.meta.env.DEV控制是否渲染即可。
最后再分享一条经验:这个项目做完之后,我建议你试着往里面加一个“多娃娃形状”的扩展,每一种娃娃用不同的 SVG 绘制,然后调不同的抓取偏移量。这一下子就会引出新的问题——不同高度娃娃的抓取点不一样,爪子的下爪深度也要跟着变化。能把这个扩展做出来,你基本上就把组件化设计和参数建模的能力彻底练到位了。抓娃娃机最大的价值就在这里:它足够小,一天能做完;但它又足够完整,能逼你把交互项目的核心问题全部过一遍。