前阵子整理前端练手项目,我把抓娃娃游戏用纯 HTML+CSS+JavaScript 完整实现了一遍。这个项目不依赖任何框架,也不需要后端,直接把一个 index.html 拖进浏览器就能玩。整套逻辑其实不大,核心代码不到两百行,但麻雀虽小五脏俱全:状态机、碰撞检测、CSS 动画与 JavaScript 协作、伪随机平衡手感,这些前端高频知识点都能在这个项目里找到落点。
抓娃娃这类小游戏,乍一看很简单:左右移动吊臂、下爪、夹住、松开。可真要把“机械感”和“游戏感”做出来,遇到的问题远比想象中多。比如爪子的夹取判定怎么写才自然?爪臂回程的时候为什么抖得厉害?按钮快速连点会不会让逻辑直接崩掉?这些坑我在做这个项目时都踩过一遍。这篇文章会把设计思路、完整代码、参数调优经验一次性讲清楚。不管是刚入门想找练手 Demo,还是想扩展成更复杂的抓娃娃模拟,都可以直接参考。
1. 项目需求拆解:先搞清楚抓娃娃机到底在做什么
做游戏之前,我习惯先站在玩家视角完整过一遍操作链路,再站在机器视角把它翻译成程序逻辑。抓娃娃看起来简单,其实是一个典型的有限状态机问题。
1.1 玩家视角下的操作链路
玩家看到的流程是这样:
- 左右移动吊臂,让爪子对准目标玩偶。
- 按“下爪”按钮,吊臂垂直下降。
- 爪子下降到底后闭合,尝试夹住玩偶。
- 无论是否夹到,爪子都会上升,回到机器顶部。
- 如果夹到了,吊臂会移动到出货口,松开爪子,玩具掉进出货口,得分加一。
- 如果没夹到,爪子空着回到初始位置,本局结束。
这个流程里有个很容易被忽略的点:机器不是所有时候都在响应玩家操作。比如爪子正在下降时,玩家如果再按左右键,吊臂不应该横向移动;如果拍下爪按钮,也不应该再次触发下降。这就是状态机存在的意义。
1.2 机器视角下的状态转换
我把整个游戏拆成了几个明确状态:
| 状态 | 允许的操作 | 说明 |
|---|---|---|
free | 左右移动、下爪 | 玩家自由控制阶段 |
dropping | 无 | 爪子下降中,锁定横向移动 |
grabbing | 无 | 爪子到底,正在做抓取判定 |
lifting | 无 | 爪子带着结果上升 |
toExit | 无 | 抓到玩偶后,吊臂向出货口移动 |
releasing | 无 | 松开爪子,玩偶掉落 |
back | 无 | 吊臂回到待机位 |
每个状态之间是严格单向流转的。比如dropping结束后只能进入grabbing,不能直接跳到back。用状态机管理,好处特别明显:不需要到处写“是否可点击”的开关,只需要判断一个state变量就够了。后面我在“踩坑”里会专门讲连点导致的状态错乱,就是靠这个结构解决的。
1.3 技术选型:为什么用原生三件套而不是 Canvas
有人问我,抓娃娃这种动画场景为什么不用 Canvas?原因有三:
一是教学价值。DOM 结构用浏览器开发者工具直接能看到节点树,逻辑对应关系非常直观。初学者能很快把“页面上的爪子”和“内存里的armX变量”对应起来。Canvas 虽然性能上限更高,但所有交互逻辑都要自己算绘制坐标,对新人来说是双重负担。
二是CSS 能力足够。这个项目中的爪子开合、按钮涟漪、边框流光,全都是 CSS 就能搞定的,JavaScript 只需要负责状态和坐标,视觉层交给 CSS 去渲染,分工干净。
三是性能完全够。本地一个小游戏,页面元素总量不超过 50 个,DOM 操作频率也不高,requestAnimationFrame轻松跑满 60 帧,没有任何性能压力。
如果你以后想做一个多玩法、多物体碰撞的大型抓娃娃机,再考虑 Canvas 或者游戏引擎也不迟。作为项目分析,原生三件套就是最优解。
2. HTML 结构与 CSS 视觉还原:把机器“画”出来
抓娃娃机的视觉还原,一大半功劳在 CSS。结构上需要把“柜体、玻璃内仓、吊臂、爪子、玩偶、出货口、控制面板”这几个层次分清楚。
2.1 三层嵌套的 DOM 骨架
我习惯这样设计 DOM:
<div class="machine"> <div class="machine-title">抓 娃 娃</div> <div class="playzone" id="playzone"> <div class="crane" id="crane"> <div class="crane-head"></div> <div class="claw-column" id="clawColumn"> <div class="claw" id="claw"> <div class="finger l"></div> <div class="finger r"></div> </div> <div class="grab-bag" id="grabBag"></div> </div> </div> <div class="drop-zone">奖品出口</div> </div> <div class="control"> <button class="btn" id="btnLeft">左移</button> <button class="btn drop" id="btnDrop">下爪</button> <button class="btn" id="btnRight">右移</button> <div class="score">得分 <span id="score">0</span></div> </div> </div>这里最关键的是.crane和.claw-column的父子关系。.crane负责横向移动,.claw-column挂在它下面,负责纵向移动。这样横向位移和纵向位移互不干扰,逻辑和表现是一致的。
玩偶由 JavaScript 动态插入.playzone,每个玩偶有独立坐标,方便做碰撞检测。
2.2 用 CSS 造出机器的“质感”
机器外壳不是简单一个灰色矩形,而是几个视觉层次叠加出来的:
柜体质感用线性渐变和多重阴影模拟:
.machine { background: linear-gradient(180deg, #2c3a52, #20283a); border: 3px solid #46587a; box-shadow: 0 20px 60px rgba(0,0,0,.65), inset 0 0 30px rgba(0,0,0,.35); }流光边框用background-size加动画实现,类似霓虹灯扫过的效果:
.machine::before { content: ""; position: absolute; inset: -10px; border-radius: 34px; background: linear-gradient(90deg, #f7c948, #f06292, #6ea8fe, #f7c948); background-size: 300% 100%; animation: borderFlow 5s linear infinite; z-index: -1; filter: blur(18px); opacity: .45; } @keyframes borderFlow { to { background-position: 300% 0; } }标题文字渐变是常见的字体渐变技巧,让文字颜色像绸缎一样流动:
.machine-title { background: linear-gradient(90deg, #fff, #f7c948, #ff8fb3, #fff); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: titleShine 3s linear infinite; }按钮涟漪光圈扩散是很多前端页面都用过的动效:用户点击按钮的瞬间,从触点扩散出一个光圈。CSS 里可以用:active::after加一个动画实现,不需要额外 JS。
2.3 布局与尺寸适配
项目里我直接把整个机器设计成固定尺寸:宽 640、高 760,中间游戏区宽 580、高 430。这样在 1440×810 的桌面屏幕下能完整显示,不用考虑复杂的响应式布局。
如果要适配更小的屏幕,可以再加一层缩放:
@media (max-height: 800px) { .machine { transform: scale(0.85); } }注意transform: scale()缩放不会改变页面布局占位,所以使用时外面最好包一个容器并手动调整外边距。这是很多人容易忽略的小细节。
3. JavaScript 核心逻辑:坐标碰撞、状态机与伪随机平衡
这一章是整个项目最值得仔细讲的部分。抓娃娃机的“手感”好不好,完全取决于三个东西:下降和移动速度、碰撞检测范围、抓取成功率。
3.1 用坐标距离做碰撞,而不是getBoundingClientRect
很多人写网页碰撞检测,第一反应是getBoundingClientRect()拿两个元素的位置再判断是否相交。这在元素少、频率低的场景下没问题,但在requestAnimationFrame每帧都跑的游戏里,会带来不必要的布局计算压力。而且不同元素的坐标基准不同,还要做一堆相减运算。
我的做法是给每个玩偶存一个绝对坐标,直接用坐标中心距离判断。比如玩偶中心坐标是(tx, ty),爪子的中心坐标是(cx, cy),当它们之间的距离小于阈值时就视为“爪子已经够到玩偶”:
const dx = tx - cx; const dy = ty - cy; const canGrab = Math.hypot(dx, dy) < CAPTURE_RADIUS;Math.hypot是计算欧氏距离的简洁方法,等价于Math.sqrt(dx * dx + dy * dy)。
这种方案的另一个好处是:玩偶被抓走后,它的坐标不会因为 DOM 位置变化而漂移。整个碰撞判定完全不依赖浏览器布局,逻辑清晰、可调试性高。
3.2 伪随机抓取:如何把成功率控制在“刚刚好”的区间
抓娃娃机如果每次都成功,玩家很快就腻;如果十次都失败,玩家会觉得被戏弄。真实街机在可玩性上做过大量平衡,我在这里做了一种简易的“保底随机”方案。
核心思路是:先检测爪子和玩偶是否碰撞,再投一次骰子决定这次能不能夹住。
let gripRate = 0.58; let failStreak = 0; function tryPick() { if (Math.random() < gripRate || failStreak >= 3) { failStreak = 0; return true; } failStreak++; return false; }这个逻辑里最有价值的是failStreak。纯Math.random()看起来是公平的,但玩家体验会很差:连续失败 5 次以上就会烦躁。加上“连续失败 3 次后必中”的保底,玩家不会一直空手而归,又不会觉得机器在放水。
抓取成功率的调参经验:
| 参数 | 参考值 | 手感倾向 |
|---|---|---|
GRIP_RATE | 0.4~0.8 | 越低越难夹 |
FAIL_STREAK_LIMIT | 3~5 | 越小越容易保底出货 |
CAPTURE_RADIUS | 48~64 | 越大判定越宽松 |
DROP_SPEED | 1.8~2.6 | 越大下降越快越难瞄准 |
3.3 吊臂运动:为什么用transform而不是left
很多新手会直接用element.style.left = armX + 'px'。这在功能上没错,但不够优雅。修改left会触发布局计算,在每帧运行的动画循环里,如果同时更新多个元素的left,容易造成卡顿和频繁回流。
更推荐的做法是用transform: translateX()来做位移。因为transform不会改变文档流的布局信息,元素位移动画能直接交回给浏览器合成器处理,性能更好。在代码里只需要一行:
craneEl.style.transform = `translateX(${armX}px)`; clawColumn.style.transform = `translateX(-50%) translateY(${clawY}px)`;这样做也带来了一个额外的好处:所有坐标都保存在 JS 变量里,DOM 只负责“展示”,数据的唯一来源是程序状态。后期调试时,只要看变量数值,就能知道整个游戏执行到哪一步了。
3.4 状态机流转的主循环骨架
整个游戏运行在一个requestAnimationFrame循环里。核心代码只有几十行:
function step() { if (state === 'free') { // 左右移动,参数 dx 由键盘或按钮产生 } else if (state === 'dropping') { clawY += DROP_SPEED; if (clawY >= MAX_DROP) { clawY = MAX_DROP; state = 'grabbing'; handleGrab(); } } else if (state === 'lifting') { clawY -= LIFT_SPEED; if (clawY <= 0) { clawY = 0; state = holdingToy ? 'toExit' : 'back'; } } else if (state === 'toExit') { armX -= TO_EXIT_SPEED; if (armX <= 0) { armX = 0; state = 'releasing'; setTimeout(releaseToy, 500); } } else if (state === 'back') { clawEl.classList.remove('closed'); armX += BACK_SPEED; if (armX >= HOME_X) { armX = HOME_X; state = 'free'; } } applyCrane(); requestAnimationFrame(step); }大家可以看到,每个状态里只做一件事,做完就直接切换到下一个状态。这种写法非常容易扩展,以后想加“左右摇晃”“倒计时模式”,只需要在对应状态里加代码,不会把原有的逻辑搅乱。
4. 调参、踩坑与手感优化:真实开发里才会遇到的细节
代码写完只是第一步。真正让这个项目“像街机”的,是后面这一连串踩坑和调参。
4.1 爪子回程时“抖”个不停
第一次跑通游戏时,我发现爪子上升过程中会轻微震动,像接触不良一样。排查了很久,最后发现是两个问题叠加:
一是同时在多个地方改同一个动画元素的位置。我在step()里更新clawY,又在applyCrane()里给clawColumn设置 transform,还有一段 CSS 给它加了transition: transform .2s。结果就是:每帧 JS 把爪子拉到新位置,CSS transition 又要花 0.2 秒平滑过去,于是它永远追不上目标点,画面就成了抖动。
解决方案很明确:直线运动时不要给运动元素加 transition,只在需要“模拟惯性”或“掉落”时才临时打开。这也是很多游戏引擎里“动画驱动”和“逻辑驱动”不能混用的典型案例。
4.2 抓到的玩偶在上升过程中“穿模”
刚开始抓到的玩偶会直接飘在爪子旁边,看起来像浮空,没有“被夹住”的感觉。原因是我在抓取后把玩偶继续留在.playzone里,只更新它的坐标,结果它和移动中的爪臂各走各的。
正确做法是:抓到玩偶后,把玩偶节点移动到.grab-bag这个容器里。.grab-bag本身就是爪子的子容器,它跟着爪子走。玩偶移进去之后,不用额外管坐标,自然会“贴”在爪子上。这样 DOM 结构和视觉效果完全一致。
4.3 按钮连点导致状态机混乱
另一个高频率问题是:玩家疯狂按“下爪”,会在一次抓取还没结束时,又触发第二次下降。我一开始在startDrop()里判断了if (state !== 'free') return,结果发现按钮的pointerdown事件在按住时可能触发多次,而且键盘和鼠标事件叠加,状态机还是会被打乱。
最后我做了两件事:一是所有操作入口统一走if (state !== 'free') return的闸口;二是键盘的keyup事件无论什么状态都会更新keyState,但keyState只影响free状态下的移动。这样即使玩家按住不放,游戏结束这段操作也不会积累“连发”。
4.4 玩偶被清空后的补货逻辑
游戏持续玩下去,玩偶会被全部抓光。为了让游戏可持续,我在释放玩偶后调了一个spawnToy()方法,随机生成新的玩偶。生成时还要做一次“防重叠检测”,否则新玩偶可能直接叠在旧玩偶身上,看起来假,也会影响后续抓取判断。
这里有一个编程上的小坑:判断重叠时,需要对字符串坐标做数值转换。比如toy.dataset.x取出来是字符串"60",直接做减法会变成字符串拼接。踩过一次后我统一用+toy.dataset.x先转成数字。这也是“JavaScript 判断数据类型”常被提及的一个实际场景。
4.5 得分与成功率的显示优化
我还顺手在控制面板里加了得分。后面如果想统计玩家整体的抓取成功率,可以用(score / totalAttempts).toFixed(2)保留两位小数,这种前端常见的格式化函数在项目里很实用。
5. 完整可运行代码:直接复制保存为 index.html
到这里,所有关键点都讲完了。我把完整代码放在最后,全部写在一个index.html文件里,HTML、CSS、JavaScript 都有注释。复制保存后直接用浏览器打开就能玩。
操作方式:点击左移/右移按钮控制吊臂,也可以用键盘方向键;按“下爪”按钮或空格键下爪。抓到玩偶后吊臂会自动把玩偶送到出口并加分。
<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>抓娃娃机 · HTML+CSS+JavaScript 完整实现</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; background: radial-gradient(1200px 600px at 50% 20%, #1f273e, #0b0e16); font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; overflow: hidden; } .machine { position: relative; z-index: 0; width: 640px; height: 760px; background: linear-gradient(180deg, #2c3a52, #20283a); border: 3px solid #46587a; border-radius: 30px; box-shadow: 0 20px 60px rgba(0, 0, 0, .65), inset 0 0 30px rgba(0, 0, 0, .35); overflow: hidden; display: flex; flex-direction: column; align-items: center; } /* 流光边框:渐变背景平移动画 */ .machine::before { content: ""; position: absolute; inset: -10px; border-radius: 34px; background: linear-gradient(90deg, #f7c948, #f06292, #6ea8fe, #f7c948); background-size: 300% 100%; animation: borderFlow 5s linear infinite; z-index: -1; filter: blur(18px); opacity: .45; } @keyframes borderFlow { to { background-position: 300% 0; } } /* 标题文字:字体渐变流动效果 */ .machine-title { width: 100%; padding: 20px 0 12px; text-align: center; font-size: 30px; font-weight: 800; letter-spacing: 8px; background: linear-gradient(90deg, #fff, #f7c948, #ff8fb3, #fff); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: titleShine 3s linear infinite; } @keyframes titleShine { to { background-position: 200% 0; } } .playzone { position: relative; width: 580px; height: 430px; margin-top: 8px; background: linear-gradient(180deg, #1b2337, #151b2b); border-radius: 18px; border: 2px solid rgba(255, 255, 255, .08); box-shadow: inset 0 8px 24px rgba(0, 0, 0, .55); overflow: hidden; } .crane { position: absolute; left: 0; top: 0; width: 84px; height: 36px; z-index: 5; pointer-events: none; } .crane-head { width: 84px; height: 20px; background: linear-gradient(180deg, #6c7a94, #46556f); border-radius: 8px 8px 0 0; box-shadow: 0 4px 0 #384560; } .claw-column { position: absolute; left: 50%; top: 20px; width: 8px; height: 120px; background: linear-gradient(90deg, #55637c, #67758f); border-radius: 4px; } .claw { position: absolute; left: 50%; bottom: 0; transform: translateX(-50%); width: 56px; height: 48px; z-index: 3; } .finger { position: absolute; bottom: 0; width: 12px; height: 42px; border-radius: 6px 6px 6px 6px; background: linear-gradient(90deg, #aab7cc, #6e7d96); transform-origin: top center; transition: transform .15s ease; box-shadow: 0 3px 3px rgba(0, 0, 0, .4); } .finger.l { left: 6px; transform: rotate(16deg); } .finger.r { right: 6px; transform: rotate(-16deg); } .claw.closed .finger.l { transform: rotate(36deg); } .claw.closed .finger.r { transform: rotate(-36deg); } .grab-bag { position: absolute; left: 50%; bottom: 6px; transform: translateX(-50%); width: 54px; height: 68px; z-index: 2; pointer-events: none; } .toy { position: absolute; width: 52px; height: 52px; background: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, .55), transparent 40%), var(--c, #e74c3c); border-radius: 50%; border: 2px solid rgba(255, 255, 255, .35); display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 15px; text-shadow: 0 2px 3px rgba(0, 0, 0, .5); box-shadow: inset 0 -8px 0 rgba(0, 0, 0, .16), 0 8px 14px rgba(0, 0, 0, .35); z-index: 1; } .drop-zone { position: absolute; left: 0; bottom: 0; width: 130px; height: 82px; background: linear-gradient(180deg, #241f14, #14100b); border: 4px solid #4f4630; border-right: none; border-radius: 0 16px 0 0; box-shadow: inset 0 8px 18px rgba(0, 0, 0, .7); z-index: 0; display: flex; align-items: center; justify-content: center; color: #ffe4a2; font-weight: 800; font-size: 16px; } .control { display: flex; align-items: center; gap: 14px; margin: 18px 0 20px; } .btn { position: relative; overflow: hidden; width: 96px; height: 50px; border: none; border-radius: 14px; font-size: 18px; font-weight: 700; color: #fff; cursor: pointer; background: linear-gradient(180deg, #4e6a9a, #2f4370); box-shadow: 0 6px 0 #1c2947; transition: transform .08s, box-shadow .08s; touch-action: manipulation; user-select: none; } .btn:active { transform: translateY(4px); box-shadow: 0 2px 0 #1c2947; } /* 按钮涟漪光圈扩散效果 */ .btn::after { content: ""; position: absolute; left: 50%; top: 50%; width: 0; height: 0; border-radius: 50%; background: rgba(255, 255, 255, .35); transform: translate(-50%, -50%); pointer-events: none; } .btn:active::after { animation: ripple .5s ease-out forwards; } @keyframes ripple { from { width: 0; height: 0; opacity: .6; } to { width: 150px; height: 150px; opacity: 0; } } .btn.drop { background: linear-gradient(180deg, #d96c3f, #b84520); box-shadow: 0 6px 0 #7f2d14; } .btn.drop:active { box-shadow: 0 2px 0 #7f2d14; } .score { font-size: 18px; color: #ffe4a2; letter-spacing: 1px; min-width: 110px; text-align: center; } </style> </head> <body> <div class="machine"> <div class="machine-title">抓 娃 娃</div> <div class="playzone" id="playzone"> <div class="crane" id="crane"> <div class="crane-head"></div> <div class="claw-column" id="clawColumn"> <div class="claw" id="claw"> <div class="finger l"></div> <div class="finger r"></div> </div> <div class="grab-bag" id="grabBag"></div> </div> </div> <div class="drop-zone">奖品出口</div> </div> <div class="control"> <button class="btn" id="btnLeft">左移</button> <button class="btn drop" id="btnDrop">下爪</button> <button class="btn" id="btnRight">右移</button> <div class="score">得分 <span id="score">0</span></div> </div> </div> <script> const playzone = document.getElementById('playzone'); const craneEl = document.getElementById('crane'); const clawColumn = document.getElementById('clawColumn'); const clawEl = document.getElementById('claw'); const grabBag = document.getElementById('grabBag'); const scoreEl = document.getElementById('score'); const btnLeft = document.getElementById('btnLeft'); const btnRight = document.getElementById('btnRight'); const btnDrop = document.getElementById('btnDrop'); // ======== 可调参数:改这几个数字就能改变整个游戏手感 ======== const PLAY_WIDTH = 580; const ARM_W = 84; const MAX_DROP = 190; // 爪子最大下降距离 const CRANE_STEP = 2.6; // 横向移动速度 const DROP_SPEED = 2.2; // 下降速度 const LIFT_SPEED = 2.0; // 上升速度 const TO_EXIT_SPEED = 3.6; // 向出口移动速度 const BACK_SPEED = 2.8; // 回程速度 const CAPTURE_RADIUS = 56; // 抓取判定半径 const GRIP_RATE = 0.58; // 单次抓取成功率 const FAIL_STREAK_LIMIT = 3; // 连续失败保底次数 const HOME_X = PLAY_WIDTH / 2 - ARM_W / 2; // 吊臂待机位 // ======== 游戏状态变量 ======== let state = 'free'; // free | dropping | grabbing | lifting | toExit | releasing | back let armX = HOME_X; let clawY = 0; let holdingToy = null; let failStreak = 0; let score = 0; let keyState = { left: false, right: false }; // ======== 玩偶数据 ======== const colorPool = ['#e74c3c', '#f1c40f', '#2ecc71', '#3498db', '#9b59b6', '#e67e22']; const namePool = ['熊', '兔', '鸭', '鲸', '喵', '狐']; function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } function applyCrane() { craneEl.style.transform = 'translateX(' + armX + 'px)'; clawColumn.style.transform = 'translateX(-50%) translateY(' + clawY + 'px)'; } function createToy(x, y, color, name) { const toy = document.createElement('div'); toy.className = 'toy'; toy.dataset.x = x; toy.dataset.y = y; toy.style.left = x + 'px'; toy.style.top = y + 'px'; toy.style.setProperty('--c', color); toy.innerHTML = '<span>' + name + '</span>'; playzone.appendChild(toy); return toy; } function initToys() { const spots = [ [60, 70], [160, 80], [280, 75], [380, 60], [470, 55], [40, 150], [150, 160], [260, 145], [360, 155], [450, 150], [90, 240], [200, 250], [320, 240], [420, 235] ]; spots.forEach(function (pos, i) { createToy(pos[0], pos[1], colorPool[i % colorPool.length], namePool[i % namePool.length]); }); } // 游戏里玩偶被抓光后,自动补货,并做防重叠检查 function spawnToy() { if (playzone.querySelectorAll('.toy').length >= 18) return; for (let i = 0; i < 30; i++) { const x = Math.floor(Math.random() * (PLAY_WIDTH - 110) + 30); const y = Math.floor(Math.random() * 210 + 60); let ok = true; playzone.querySelectorAll('.toy').forEach(function (t) { const dx = +t.dataset.x - x; const dy = +t.dataset.y - y; if (Math.hypot(dx, dy) < 58) { ok = false; } }); if (ok) { const idx = Math.floor(Math.random() * colorPool.length); createToy(x, y, colorPool[idx], namePool[idx]); return; } } } // 抓取判定:用坐标距离模拟碰撞,并加入保底随机 function tryPick() { if (Math.random() < GRIP_RATE || failStreak >= FAIL_STREAK_LIMIT) { failStreak = 0; return true; } failStreak++; return false; } function handleGrab() { const clawCenterX = armX + ARM_W / 2; const clawCenterY = 20 + clawY + 120 + 20; let target = null; playzone.querySelectorAll('.toy').forEach(function (toy) { const tx = +toy.dataset.x + 26; const ty = +toy.dataset.y + 26; const dx = tx - clawCenterX; const dy = ty - clawCenterY; if (Math.hypot(dx, dy) < CAPTURE_RADIUS) { target = toy; } }); if (target && tryPick()) { holdingToy = target; holdingToy.style.left = '50%'; holdingToy.style.top = '0px'; holdingToy.style.transform = 'translateX(-50%)'; holdingToy.style.zIndex = '2'; grabBag.appendChild(holdingToy); } else { holdingToy = null; } clawEl.classList.add('closed'); setTimeout(function () { state = 'lifting'; }, 500); } function releaseToy() { clawEl.classList.remove('closed'); if (holdingToy) { holdingToy.remove(); holdingToy = null; score++; scoreEl.textContent = score; setTimeout(spawnToy, 300); } state = 'back'; } function startDrop() { if (state !== 'free') return; state = 'dropping'; } // ======== 主循环:状态机驱动 ======== function step() { if (state === 'free') { let dx = 0; if (keyState.left) dx -= CRANE_STEP; if (keyState.right) dx += CRANE_STEP; if (dx !== 0) { armX = clamp(armX + dx, 0, PLAY_WIDTH - ARM_W); } } else if (state === 'dropping') { clawY += DROP_SPEED; if (clawY >= MAX_DROP) { clawY = MAX_DROP; state = 'grabbing'; handleGrab(); } } else if (state === 'lifting') { clawY -= LIFT_SPEED; if (clawY <= 0) { clawY = 0; state = holdingToy ? 'toExit' : 'back'; } } else if (state === 'toExit') { armX -= TO_EXIT_SPEED; if (armX <= 0) { armX = 0; state = 'releasing'; setTimeout(releaseToy, 500); } } else if (state === 'back') { clawEl.classList.remove('closed'); armX += BACK_SPEED; if (armX >= HOME_X) { armX = HOME_X; state = 'free'; } } applyCrane(); requestAnimationFrame(step); } // ======== 键盘控制 ======== document.addEventListener('keydown', function (e) { if (e.code === 'ArrowLeft') keyState.left = true; if (e.code === 'ArrowRight') keyState.right = true; if (e.code === 'Space' || e.code === 'ArrowDown') { e.preventDefault(); startDrop(); } }); document.addEventListener('keyup', function (e) { if (e.code === 'ArrowLeft') keyState.left = false; if (e.code === 'ArrowRight') keyState.right = false; }); // ======== 按钮控制 ======== btnLeft.addEventListener('pointerdown', function (e) { e.preventDefault(); keyState.left = true; }); btnLeft.addEventListener('pointerup', function () { keyState.left = false; }); btnLeft.addEventListener('pointerleave', function () { keyState.left = false; }); btnRight.addEventListener('pointerdown', function (e) { e.preventDefault(); keyState.right = true; }); btnRight.addEventListener('pointerup', function () { keyState.right = false; }); btnRight.addEventListener('pointerleave', function () { keyState.right = false; }); btnDrop.addEventListener('click', function () { startDrop(); }); // ======== 启动 ======== initToys(); applyCrane(); requestAnimationFrame(step); </script> </body> </html>项目完成后的一点个人体会
做完这个项目后,我最直观的感受是:前端小游戏的教学价值被很多人低估了。抓娃娃机这个选题尤其有意思,它不像俄罗斯方块那样需要复杂的下落判定,也不像射击游戏那样需要精确的碰撞引擎,但它的核心机制足够完整:状态机、移动控制、碰撞检测、随机平衡、CSS 动画交互,全都能在一个文件里讲清楚。
我在调参上花的时间其实比写代码还多。GRIP_RATE从 0.9 调到 0.4,玩起来手感完全不一样;MAX_DROP短一点,玩家很容易觉得“爪子不够深”。如果各位把代码复制下来自己玩,我强烈建议先改一遍参数感受一下:把CRANE_STEP调成 5,再调回 2.2,你会发现“速度”并不等于“手感”。这种对数值的敏感度,只有亲手调过才会有。
如果你还想继续扩展,后面可以加的东西也很多:胜率统计面板、多级难度、不同的出货口位置、根据抓取次数动态调整成功率……都是很有意思的方向。这个项目就当作一个可以反复折腾的实验台吧。