这阵子在公司里带了个小项目,是拿 Vue 写一个模拟抓娃娃机的 H5 小游戏。不是接那种第三方 SDK 的抽奖转盘,而是从零自己实现一个带抓钩、奖池、概率控制的完整抓娃娃流程,整件事做完之后,我觉得有不少东西值得沉淀下来。如果你也想用 Vue 做类似的交互型小游戏,或者说你对"前端怎么去模拟一套物理手感接近真实的机器"这件事感兴趣,那这篇内容应该能帮你少走不少弯路。
先说背景:这个项目本身没有太复杂的后端依赖,核心是一个纯前端的模拟器。你可以理解为,把线下抓娃娃机的"投币—移动抓钩—下爪—抓取—判定结果"整条链路,复刻到浏览器里。最终做出来的效果要能让人在手机上玩,并且有那种"差一点就抓到了"的惋惜感,也有"终于出货"的爽快感。这里面最难的并不是画几个组件,而是怎么在浏览器环境里模拟出机器的机械感、随机感和反馈感。
我会把整个项目的拆解、数据结构、组件划分、动画状态机、概率控制、性能优化这些全部走一遍。文中凡是涉及代码的,都会放关键片段,并且解释每一段的设计意图。没有废话,直接开始。
1. 项目起意与核心玩法设计
1.1 需求拆解:到底在做一台什么样的"抓娃娃机"
接到需求的第一件事,不是打开编辑器,而是先想清楚一个问题:这台"抓娃娃机"到底是给谁玩的、在什么场景下玩、玩起来要有怎样的节奏感。
最初的需求描述只有一句话:"Vue 做一台抓娃娃机,能在手机上玩,能抓出东西来。"这种描述放在产品会上谁都听得懂,但真正落地的时候会发现,这句话里藏着至少五个子问题:
- 抓钩怎么移动?是上下左右四向按键,还是触屏拖拽?
- 抓钩什么时候自动下降?是玩家手动点"抓取"按钮,还是移动到位后自动下爪?
- 下爪之后怎么判定有没有抓到?是全靠随机,还是根据钩子与娃娃的相对位置计算?
- 抓到之后怎么上升?会不会在中途掉落?
- 奖池里放多少个娃娃?抓完之后要不要补充?
这些问题不提前定清楚,写代码的时候就会反复改。尤其是第一和第三个问题,直接决定了游戏手感。
我们最终确定的方案是:抓钩在顶部沿 X 轴自由移动,Y 轴默认固定在顶部,玩家通过左右按键(或拖拽摇杆)控制横移,也可以微调前后位置(如果场景是三维的),点击"下爪"按钮后,抓钩以固定速度下落,碰到底部或者碰到娃娃后自动收爪并上升。判定逻辑不是纯随机,而是"物理判定为主、概率修正为辅"。这样玩家能明显感觉到钩子越靠近娃娃,抓中的概率越高,而不是在已经碰到娃娃的情况下还会落空。
这个设计思路的背后逻辑很简单:抓娃娃机的"爽感"来源于操作感与不确定性的平衡。如果全随机,玩家会觉得被戏弄;如果全物理,玩家又会觉得难度太高。我们要的是"技术上有可能、概率上不亏损"的中间态,让玩家觉得是自己手气问题,而不是代码问题。
1.2 技术选型:为什么用 Vue 而不是 canvas 直写或纯原生 JS
既然项目名字就带着 Vue,那框架本身没有太多悬念。但这里要说的不是"因为需求是 Vue 所以用 Vue",而是 Vue 其实非常合适做这种类型的交互游戏,只是很多人误以为游戏必须用 canvas 或者 WebGL。
这个项目的绝大部分视觉元素都可以用 DOM + CSS 来呈现。抓钩、娃娃、奖池、按钮,这些本质上就是一组有绝对定位的图片或 div。DOM 方案的好处是:事件系统现成、调试方便、样式控制灵活、也不需要处理 canvas 重绘的脏矩形逻辑。
但如果是纯 DOM 做,也有个绕不开的问题:高频的位移更新和动画。如果你用 setInterval 每 16ms 去改一次 left/top,并且更新频率不稳定,DOM 的布局抖动会非常明显。所以我们要在控制层用 Vue 的响应式状态,但在动画执行层用 requestAnimationFrame 直接操作 DOM 或者使用 CSS transform。
具体分工是这样的:
- Vue 负责状态机:当前是 idle、moving、dropping、grabbing、rising、settling 中的哪个阶段。
- Vue 负责 UI 层:按钮的禁用态、分数显示、结果弹窗。
- 动画层单独抽一个游标控制器:用 rAF 循环读取目标坐标,以插值方式逐步逼近,而不是依赖 Vue 的响应式数据改写去触发每一次渲染。
这里有一个重要的 Vue 经验:如果你把 left/top 放在 data 里,并且每帧都改,Vue 确实能渲染出来,但在低端手机上会非常卡,因为每一次响应式更新都会触发虚拟 DOM diff 和样式重算。正确做法是把这类高频临时数据放到非响应式对象里,或者直接存成 DOM style 属性,只在动画结束、需要同步 UI 时才通过 Vue 更新一次状态。
我不是说 canvas 方案不行,而是想说:对于这种 2D 平面抓娃娃机,Vue + DOM + CSS transform 的性价比最高,开发效率也高。如果你的项目后面要支持三维视角、多角度旋转、粒子特效,那再考虑引入 PixiJS 或 Three.js 也不迟。
1.3 玩法边界设定:可玩性、难度曲线与奖池逻辑
抓娃娃机最容易犯的毛病就是"闷玩"。玩家玩了两分钟抓不到东西,立刻流失。我们要在玩法层就设定好压力阈值。
我们的做法是在奖池里放置了三种难度档位的奖品:
- 普通小玩偶:抓取概率高,体积小,适合作为新手福利。
- 稀有玩偶:体积中等,抓测概率略低,给中期玩家一些挑战。
- 隐藏大奖:体积大、造型特殊,概率设置成"物理判定成功后再过一道随机门槛",保证不会频繁出。
难度曲线的核心设计不是把概率写死,而是把"抓钩接触判定"与"抓取成功概率"分开。物理接触判定是可复现的:钩子碰到娃娃的包围盒且位置满足一定重合范围,才进入抓取成功流程。然后,在成功流程里再叠加一个权重随机,这个权重的值会随着玩家连续失败次数动态调整。也就是说,连续失败很多次之后,系统会悄悄把隐藏大奖的抓取成功率往上提一点,让玩家在即将放弃的临界点里得到一次奖励。
这种动态概率调节在运营层面有个专业说法叫"pity system(保底机制)"。很多抽卡游戏和抓娃娃程序都在用。我们的实现里没有直接暴露这个参数,而是在代码层做了调试开关,方便测试。
奖池逻辑也要提前想好:抓出来的娃娃要不要从奖池里移除?如果移除,奖池会越抓越少;如果不移除,玩家会找到特定位置无限刷。我们的方案是:每个娃娃有一个 visible 属性和一个 weight 属性,被抓走后 visible 变为 false,weight 归零,相当于从物理空间消失,但数据还留着,方便统计。
这些看起来像是产品经理该操心的事,但实际上作为开发者,如果你不先把边界条件定义清楚,你写出的代码一定会在某个不预期的时刻蹦出离奇的 bug。
2. 数据结构与组件架构
2.1 数据结构:一上来就要把状态模型想清楚
我见过不少新手做这种小项目时,直接在组件 data 里东一个变量西一个变量,最后代码膨胀到完全没法维护。抓娃娃机看似简单,其实状态量非常多:抓钩坐标、抓钩阶段、目标娃娃、剩余金币、抓取次数、保底计数、奖池列表、摆动角度、动画帧句柄……如果一开始不划分清楚,后面加功能的时候会很想把电脑砸了。
我先说最终使用的数据模型,你可以直接照着抄这个思路改。
第一块是"游戏配置":
const GAME_CONFIG = { maxBubbles: 30, // 奖池同时存在的娃娃数量 dropHeight: 420, // 抓钩可下探的最大高度(px) moveSpeed: 240, // 水平移动速度 px/s dropSpeed: 180, // 下降速度 px/s riseSpeed: 120, // 上升速度 px/s swingRange: 6, // 抓钩摆动角度范围(deg) grabCooldown: 800, // 每次下爪后的冷却时间(ms) baseSuccessRate: 0.42, // 物理判定成功后的基础成功率 luckyRate: 0.3 // 隐藏大奖的额外门槛 };第二块是"每个娃娃的数据结构":
{ id: "plush_001", emoji: "🐻", name: "小熊", x: 120, // 在奖池中的横坐标 y: 90, // 在奖池中的纵坐标 size: 48, // 碰撞体积(直径) visible: true, weight: 1, // 当前权重,用于概率 rarity: "common" // common / rare / epic }第三块是"运行时状态":
const state = reactive({ stage: 'ready', // ready | moving | dropping | grabbing | rising | settling | fail hookX: 260, hookY: 20, targetId: null, grabbed: false, coins: 10, totalPlays: 0, consecutiveFails: 0, rateBoost: 1 });之所以用 reactive 来维护运行时状态,是因为 UI 层需要实时同步这些数据给玩家看。但注意,像 hookX 这种高频变化的字段,我实际在动画过程中没有直接改它,而是先在一个游离于 Vue 之外的临时对象里累加,动画结束后一次性同步回 state。这样既保留了响应式能力,又不至于每帧触发渲染。
2.2 组件拆分:抓钩、传送门、奖池、控制面板
组件拆分的粒度决定了后续维护的幸福感。我把整个界面分成了四块:
- ClawMachine.vue:最外层壳子,负责整体布局、状态机调度。
- HookController.vue:抓钩组件,接收父级下发的 hookX/hookY,渲染钩子和摆臂,不关心业务规则。
- PrizePool.vue:奖池区域,负责渲染所有娃娃,管理娃娃的位置和显隐。
- ControlPanel.vue:按钮区、金币区、结果播报区,所有可点击的操作都收敛到这里。
这个拆分思路的核心是"单向数据流,职责分离"。抓钩组件不应该知道奖池里有哪些娃娃,它只负责"有人告诉我你的位置在哪,我就渲染在哪"。奖池组件也不应该处理抓取逻辑,它只负责"谁该显示谁该隐藏"。抓取逻辑全部收敛到 ClawMachine 这个顶层容器里。
为什么这么拆?因为抓娃娃机的状态流转是全局性的。抓钩下降的过程中,奖池、控制面板、动画状态都要联动。如果每个子组件各自为战,就会出现"钩子已经回到顶部了,奖池里的娃娃还没消失"这种撕裂感。把所有业务规则集中到顶层,下层只做渲染,这样的思路在任何前端项目中都适用。
组件之间的通讯我用了依赖注入加 props,而不是事件总线。事件总线在这个项目里也能用,但用多了你会发现很难追踪数据来源。顶层通过 provide 提供游戏上下文,子组件通过 inject 只读使用,不允许子组件随意修改共享状态。这样调试的时候,一旦状态出错,直接看顶层容器里的逻辑即可。
2.3 状态通信与事件总线:Vue 组件之间怎么协作
这个项目最核心的协作场景是:玩家点击下爪按钮后,控制面板要通知顶层容器"该动了";顶层容器收到指令后,修改响应式 state.stage;抓钩组件 watch 到这个 stage 变化后,开始自身的动画逻辑;奖池组件在收到抓取结果后,把对应娃娃隐藏;动画结束后,控制面板再展示结果。
这里可能有人会问:抓钩动画不是用非响应式对象控制吗,怎么还能用 watch?
答案是:watch 只监听阶段变化,不监听坐标变化。比如你 watch(() => state.stage, (newVal, oldVal) => { ... }),当 stage 从 ready 变为 moving 时,HookController 里的某个方法会被触发,这个方法再去启动 rAF 循环,而 rAF 循环内部直接操作一个普通对象 handle,不再经过 Vue 渲染。这样两个层面各司其职,既保证了"该响应的地方响应",又避免了"不该响应的地方频繁渲染"。
还有一个容易忽视的细节:点击下爪按钮时要做好防抖和状态校验。如果玩家在抓钩上升过程中再次点击下爪,控制面板直接忽略。这个校验不能只写在按钮层,要在顶层容器里写一个 canOperate 判断函数,所有操作前统一调用。
下面是这个判断函数的核心逻辑:
function canOperate() { return ['ready', 'settling', 'fail'].includes(state.stage) && state.coins > 0; }只有机器空闲且玩家有币时,下爪操作才被允许。这套机制在多人并发操作时尤其重要,虽然我们的项目只是单人游戏,但这种防御性写法很容易被忽略,建议保证一开始就写好。
3. 核心功能实现与关键代码
3.1 抓钩的运动控制:三轴移动的坐标管理与速度控制
抓钩运动看起来很简单,不就是改 left 和 top 吗?但一旦涉及到手感,问题就来了。
我实现的第一个版本是:按住右键,抓钩向右匀速移动,松开就停。后来测试发现,这种"数字量化"的操作方式非常生硬,玩家的体验是"感觉在操作一台坐标机,而不是抓娃娃机"。
后来我改成了速度模型加惯性模拟。核心逻辑如下:
let velocity = 0; let targetVelocity = 0; const ACCELERATION = 8; // 加速度 const FRICTION = 0.92; // 摩擦力系数 function updateLoop(deltaTime) { if (inputState.rightPressed) { targetVelocity = GAME_CONFIG.moveSpeed; } else if (inputState.leftPressed) { targetVelocity = -GAME_CONFIG.moveSpeed; } else { targetVelocity = 0; } velocity += (targetVelocity - velocity) * ACCELERATION * deltaTime; velocity *= FRICTION; hookHandle.x += velocity * deltaTime; hookHandle.x = clamp(hookHandle.x, HOOK_MIN_X, HOOK_MAX_X); }这个模型的好处是:按下方向键时加速度逐步建立,松开时因为摩擦衰减,抓钩不会立刻停在原地,而是会有一小段滑行。这种滑行非常讨巧,它会让玩家觉得"机器是有惯性的",从而增加操作难度的真实感。如果完全没有惯性,玩家可以非常精准地定位,游戏就失去了挑战性。
注意一个细节:deltaTime 不能直接用帧间隔的毫秒数,否则不同刷新率设备上的移动速度会不一致。我用的是归一化的秒数,即 16.67ms 对应 0.0167。这样 120Hz 的屏幕和 60Hz 的屏幕,实际移动速度是一致的。
3.2 下降/抓取/上升的动画状态机
抓娃娃机的整个流程就是一个典型的状态机,我在代码里用的是一个阶段枚举,配合 setTimeout 或 rAF 驱动的阶段切换。
流程如下:
- ready:等待玩家按下操作按钮。
- moving:抓钩沿 X 轴自由移动,Y 保持不变。
- dropping:抓钩以 dropSpeed 向下移动,直到碰到娃娃的碰撞盒或到达 dropHeight。
- grabbing:若碰到娃娃,进行物理判定和概率加权,判断是否抓住。
- rising:若是抓住,则抓钩带着娃娃以 riseSpeed 上升回到顶部;若未抓住,则空钩上升。
- settling:抓钩回位,娃娃从奖池隐藏(如果成功),播放结果动画。
- fail:如果未抓住,短暂展示"差一点"提示,然后回到 ready。
这个状态机的核心抽象是“update 函数”。每一帧不管处于哪个阶段,都调用一个统一的 update 函数,update 内部根据当前阶段执行不同的逻辑,然后检查是否需要切换阶段。
下面的代码是下降阶段最关键的碰撞检测:
function checkCollision() { const hookBox = { x: hookHandle.x - HOOK_WIDTH / 2, y: hookHandle.y - HOOK_HEIGHT / 2, w: HOOK_WIDTH, h: HOOK_HEIGHT }; for (const plush of prizePool) { if (!plush.visible) continue; const plushBox = { x: plush.x - plush.size / 2, y: plush.y - plush.size / 2, w: plush.size, h: plush.size }; if (rectIntersect(hookBox, plushBox)) { state.targetId = plush.id; return true; } } return false; }碰撞检测用的是矩形相交,而不是像素级精确判定。因为娃娃大部分都是圆形的或者形状不规则的,理论上用圆形碰撞更准确,但矩形碰撞已经足够满足游戏体验了。真正让玩家觉得"抓到了但还是掉了"的,其实不是碰撞精度,而是后续的概率门槛。
3.3 奖励判定逻辑:概率、夹取点与掉落机制
抓到娃娃后的判定最让人纠结。如果纯靠物理碰撞,那么一个熟练的玩家很快就能掌握规律,每次都直奔同一个娃娃。如果纯靠概率,玩家又会觉得没有操作感。所以我的方案是两阶段判定:
第一阶段是物理有效命中。判断钩子与娃娃的重叠面积是否达到一定阈值。这个阈值我们用 overlapRatio 表示:
function overlapRatio(hookBox, plushBox) { const xOverlap = Math.max(0, Math.min(hookBox.x + hookBox.w, plushBox.x + plushBox.w) - Math.max(hookBox.x, plushBox.x)); const yOverlap = Math.max(0, Math.min(hookBox.y + hookBox.h, plushBox.y + plushBox.h) - Math.max(hookBox.y, plushBox.y)); return (xOverlap * yOverlap) / Math.min(hookBox.w * hookBox.h, plushBox.w * plushBox.h); } function isPhysicalValid(hookBox, plushBox) { return overlapRatio(hookBox, plushBox) >= 0.35; }重叠面积越大,第一阶段的成功率越高。这个参数直接决定了玩家"操作越准,越容易抓到"的直觉。
第二阶段才是概率加权。在物理有效命中的基础上,再乘上基础成功率,以及保底机制带来的增益:
function calculateGrabSuccess(plush) { let rate = GAME_CONFIG.baseSuccessRate; if (plush.rarity === 'rare') { rate *= 0.6; } else if (plush.rarity === 'epic') { rate *= 0.25; } // 保底机制:连续失败次数越多,成功率越高 rate += state.consecutiveFails * 0.05; rate *= state.rateBoost; return Math.min(rate, 0.97); }这个函数最终返回一个 0~0.97 之间的概率值。然后调用 Math.random() 与之比较,决定是否抓取成功。
第一版我直接用了随机数,后来测试发现了一个很严重的问题:Math.random() 在短时间内被调用非常多次,同一个流程里有多处用到随机数,最终概率分布会被"稀释"。所以我改为:先算出目标娃娃的概率,再在进入 grabbing 阶段前预生成一个全局结果布尔值,后续流程统一读取这个结果。这样做的好处是,无论如何重试、无论如何刷新,同一个流程内的结果是一致的。
掉落机制也得做:就算抓取成功,也不是百分百在上升过程中不掉下来。我增加了一个"挣扎判定":上升过程中,每隔 300ms 进行一次小概率脱落检测。这个检测在代码里模拟了娃娃在钩子上的晃动幅度,如果当前上升速度过快,就更容易掉。当然,这纯粹是为了增加戏剧张力,实际概率控制在非常低的水平,不能让玩家觉得"明明成功还会被系统阴掉"。
3.4 积分系统与反馈播报
玩家在抓娃娃机里最需要的即时反馈就是:我这次到底成没成、我仓库里多了什么、我还剩几个币。
积分系统我直接用了 Vue 的 reactive state,UI 层通过 computed 展示。关键点是"投币逻辑":
- 每次下爪消耗 1 枚金币。
- 可以通过观看短广告或每日签到获取金币(这个功能后续再接,第一期先用固定的初始金币)。
- 抓到普通玩偶时,如果奖池里该玩偶已经不存在,则自动转换成等值金币。
反馈播报使用了一个极简的 toast 队列。每次操作结束后,往队列里 push 一条消息,比如"手气不错,抓到小熊了!"或者"差一点!再试一次吧"。这些消息通过 Transition 组件做滑入滑出动画。
在写反馈系统时,我学到的一个经验是:反馈消息要分"奖励型"和"鼓励型",不能一刀切用同一种语气。奖励型的消息文字更短、更直接;鼓励型的消息应该让玩家觉得自己只是差一点点,而不是被系统针对了。
const messageQueue = ref([]); function pushMessage(text, type = 'info') { messageQueue.value.push({ id: Date.now(), text, type }); setTimeout(() => { messageQueue.value.shift(); }, 2500); }这个队列比较简单,但效果很稳。later 可以扩展成支持多行、支持自定义样式。
4. 渲染性能优化与踩坑实录
4.1 更新频率与渲染性能
抓娃娃机的动画中最频繁的操作就是抓钩水平移动和下降。我最初把 hookX/hookY 放到 reactive 里,然后直接用 v-bind 绑定 style,结果测试手机(一台两年前的安卓中端机)上帧率掉到 30 帧以下,屏幕有明显的卡顿感。
问题很好定位:Vue 的响应式系统会在每次 set 后触发副作用执行,而动画循环每秒执行 60 次,等于每秒做了 60 次 vdom diff + style 重算。哪怕只是 tiny 的样式改动,也会触发浏览器做样式重算(recalculate styles),如果页面还有其他布局操作,性能就崩了。
解决办法是把动画过程中的坐标放到一个独立的"临时句柄"对象里,这个对象完全不参与 Vue 的响应式系统:
// 非响应式 const hookHandle = { x: 260, y: 20 };动画循环里每帧更新 hookHandle.x / hookHandle.y,然后直接操作 DOM 的 transform 属性:
function applyHookTransform() { const el = hookElement.value; if (el) { el.style.transform = `translate3d(${hookHandle.x}px, ${hookHandle.y}px, 0)`; } }translate3d 会触发 GPU 合成,性能比修改 left/top 好很多。等到动画阶段结束时,再把 hookHandle 的最终值同步到 state.hookX/state.hookY,供其他 UI 展示使用。通过这套组合,低端手机也能稳定跑满 60 帧。
你可能会有疑问:既然最后要同步回去,为什么不直接一步到位。原因是大多数场景下,UI 上并不需要每帧都知道抓钩的精确坐标。只有放在"结果确认"阶段时,我们才需要把坐标同步回 Vue 渲染层,用于其他组件的联动计算,这个频次非常低。
4.2 动画插值:Tween 而不是 Transition
抓娃娃机的下爪和上升,速度是恒定的吗?如果恒定,代码最简单,但手感会非常呆板。真实抓娃娃机里,下降阶段会因为重力原因越来越快,上升阶段因为有电机拉动,起始慢、中段快、末尾慢。
这种变速效果用 CSS transition 很难精确控制,因为 transition 的贝塞尔曲线只能作用在"单次状态变化"上,对连续变化的动画无能为力。所以我用了 Tween 插值方案。
具体做法是:每个动画阶段不是一个匀速过程,而是一个基于时间函数 easeInOutCubic 的速度插值过程。比如下降阶段,速度从 0 开始加速到 maxSpeed,再稍微减速;上升阶段同理。
function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }实际使用时,我把整个下降过程拆分成 30 帧,每帧的位移步长根据 easeInOutCubic 生成的权重来分配。这样动画过程是变速的,玩家会感觉到一种"设备运行中的机械感"。
一句话总结:不要让抓钩像一个绝对匀速的电梯上下,要让它有轻微的加速感与收尾的停顿感,这会极大提升真实感。
4.3 浏览器限制与帧率稳定
项目后期测试时发现两个和浏览器限制相关的问题,值得专门提出来。
第一个是 requestAnimationFrame 在后台标签页会被自动暂停。如果玩家把页面切到后台再切回来,会发现抓钩停在原地,甚至动画状态机进入死循环。解决方法是监听 pagevisibility 事件,页面隐藏时强制重置状态机为 ready,清除所有动画句柄。
第二个是移动端浏览器对触摸事件的处理。在 iOS Safari 上,如果玩家快速连点"下爪"按钮,可能会触发双击缩放或者误触。解决方法是给按钮加 touch-action: manipulation,并阻止默认事件。
这两个问题在桌面浏览器上几乎不会遇到,但一旦发布到 H5 环境,就会变成很严重的体验问题。不要心存侥幸,发布前一定要拿真机测这两个场景。
5. 常见问题与排查技巧
5.1 抓钩位置偏移问题及修复
在我做完第一版后,团队测试给了一个很一致的反馈:抓钩"看起来"在某个位置,但实际判定碰撞的位置比视觉偏右了 10 个像素左右。
排查后发现是样式问题。我的抓钩视觉主体是一个包含 anchor 和 claw 元素的容器,容器本身宽高是 100px,但视觉上钩子的尖端实际占据的位置偏左;碰撞检测却始终以容器左上角为锚点,导致左右偏差。
修复办法是引入一个visualAnchorX和visualAnchorY的常量,用来表示碰撞盒相对于容器左上角的位置偏移。计算碰撞检测时,统一减去偏移量即可。
这里给个建议:任何涉及图像元素和物理碰撞的项目,都要在初期就定义好"锚点"。锚点可以是中心点,也可以是某个角,但一旦定了,所有组件都必须用同一个锚点。不要在代码里一部分用左上角、一部分用中心点,那会是噩梦。
5.2 概率一致性与随机数的坑
随机数在游戏开发中是个经典坑。我一开始的抓取判定逻辑是在"抓取结束"的那一刻调用 Math.random(),后来发现问题:如果用户快速操作,同一个流程里可能出现多次判定,而每次判定都会重新生成随机结果,导致概率分布异常。
解决方案是前面提到的"预生成结果":
const grabResult = Math.random() < finalRate;只有这个值在流程开始时固定下来,后续所有对这个流程的判断都用同一个变量。这样,无论流程过程中发生什么(比如用户刷新、重试、回到初期),结果不再改变。
另外一个与随机相关的坑是:不要用随机数来决定"哪个娃娃被抓住"。如果玩家指着小熊去抓,最后系统随机给一个大白鹅,体验会非常奇怪。必须固定为"玩家当前重叠的娃娃",随机只能决定抓不抓得住,而不是抓的什么。
5.3 多设备适配
抓娃娃机的奖池区域和抓钩位置在移动端用小屏展示时,很容易出现错位。尤其是全面屏手机的底部安全区会遮挡一部分显示内容。
我的做法是使用视口单位来控制整体布局。奖池组件的高度用 vh 计算,抓钩的水平范围也用 vh 和 vw 换算保证比例统一。
但如果全部用 vw/vh 会导致小屏上按钮太小、大屏上按钮太大。所以我引入了一个scaleFactor的处理,根据屏幕宽度计算一个缩放系数,用到按钮、图标等固定控件上:
const scaleFactor = computed(() => { return Math.min(window.innerWidth / 375, 1.2); });这个系数不会影响核心的物理引擎逻辑,只是视觉上的缩放。因为物理引擎使用的是一个固定的逻辑像素坐标系,不随屏幕尺寸变化,最终只是渲染层做缩放。这样保证在 iPhone SE 和 iPad 上,游戏逻辑是完全一致的,只是视觉呈现的尺寸不同。
5.4 实战避坑清单
根据我这次开发的经验,整理一份避坑清单,对做同类游戏开发的同学会有帮助:
- 不要在动画循环里通过 Vue 的响应式数据更新坐标,用非响应式句柄 + DOM transform。
- 碰撞判定要统一使用"逻辑坐标",不要在视觉层做变换后再算。
- 每个动画阶段结束后,必须清空上一阶段的定时器和 rAF 句柄,防止内存泄漏。
- 概率计算要集中在一个函数里,并且单流程只允许生成一次结果。
- 组件之间不要互相进行深度嵌套通信,能用 provide/inject 就不用事件总线,事件总线在项目变大后非常难受。
- 抓钩移动的惯性系数要单独做成配置,方便测试调试时快速调整手感。
- 移动端按钮一定要加 touch-action: manipulation,防止双击缩放。
- 保底机制在测试阶段可以做成一个 URL 参数,方便测试人员直接验证。
这些都是我在开发过程中实际踩过的坑,写出来就是为了让你不用再踩一遍。每一条背后的逻辑都很简单,但如果你没有提前意识到,调试的时候会花掉不少时间。
6. 个人体会与后续扩展
做完这个 Vue 抓娃娃机项目后,最大的体会是:一个看起来"很简单"的小游戏,想要做得有手感、有留存、有话题性,实际涉及到的知识版图非常大。Vue 只是最表层的工具,真正决定游戏好坏的是底层的数据模型、状态机设计、概率策略以及性能优化。
尤其是"状态机"这一块,我建议所有做类似项目的开发者都不要嫌麻烦。哪怕你只是做一个简单的下爪动画,也应该先把"什么时候能下爪、什么时候不能下爪、下爪之后可能进入哪几个状态"梳理清楚。否则随着功能的增加,你的代码会陷入无穷的 if 嵌套,直到完全没法维护。
如果以后想往这个方向继续扩展,我可能会考虑再加一条链条:从一个单人小游戏,改造成"大厅 — 房间 — 对战"的模式,支持双人抢同一个娃娃。到那一步,状态机的复杂度和同步逻辑会进一步提升,但核心架构——"单机状态机 + 逻辑坐标 + 概率预生成"——应该仍然适用。
最后分享一个小技巧:你可以在控制面板的右上角藏一个"调试模式"入口,点击三次 Logo 后开启面板,显示当前概率、保底上限、每只娃娃的位置坐标、以及抓取判定的实时结果。这对后续调难度、找 bug、做测试都非常有用。我这个项目里就用这个调试面板,把"太难了"和"太简单了"的反馈在十分钟内就调到了合适区间。你要是自己做,强烈建议也留一个。