上个月我把自己关在房间里,用纯原生 JavaScript + Canvas,从零写出了一个小游戏——打螺丝。微信小游戏、无引擎、纯原创、从0到上线,这套全流程走下来,踩过的坑比我想象中多得多。这篇文章不准备讲虚的,直接把我设计、开发、调优、提审、上线全过程的思路和代码片段摊开,适合想要自己独立做一款微信小游戏、又不想用现成引擎重模板的人。尤其是最近很多人问“没有引擎真能做小游戏吗”“纯手打会不会太慢”“上线是不是特别麻烦”,我可以用这段真实经历回答:能做,会慢,但踩坑后你会对游戏开发有完全不一样的理解。
1. 立项与整体设计:无引擎,到底省了什么又难在哪
1.1 为什么选原生 Canvas 而不是直接用游戏引擎
先说说选型。市面上成熟的微信小游戏引擎很多,最常见的是 Cocos、Laya,甚至还有 Unity 转小游戏的方案。但我的情况比较特殊:游戏玩法非常简单,就是拧螺丝、拔螺丝,没有复杂的物理碰撞、刚体约束、粒子特效。如果为了一个解压小游戏去套一套体积几十 MB 的引擎,反而被框架牵着走。
还有一个中间派方案:用官方提供的适配库,只做 Canvas 渲染。但我觉得打螺丝这类游戏,核心就三件事:绘制、触摸、状态刷新。原生 Canvas 完全能覆盖,还能把包体控制在几十 KB 级别。微信小游戏本身对包体有限制,主包不能太大,无引擎的启动速度也确实快一些。
我也不是完全反对引擎。引擎的价值在于复杂场景和团队协作,比如场景编辑器、动画状态机、物理系统。但对个人开发者做休闲小游戏来说,原生方案的调试链路更短,你写的每一行代码都知道在干什么。如果项目复杂度上升到需要多人协作、大规模资源管理,再迁移引擎也来得及。
| 对比项 | 原生 Canvas | 小游戏引擎 |
|---|---|---|
| 包体大小 | 10~100KB | 1~30MB 起步 |
| 上手成本 | 懂 JS 就能写 | 需要学习场景、组件、资源管线 |
| 物理能力 | 自己实现简单碰撞 | 自带物理引擎 |
| 调试难度 | 浏览器控制台直接看 | 需要理解引擎的调用栈 |
| 适合场景 | 休闲玩法、独立原型 | 中大型游戏、复杂动画 |
1.2 玩法定义:打螺丝的核心循环是怎么定下来的
“打螺丝”这个名字听起来很直白,但具体怎么打,我反复推演了几版。第一版想做成:屏幕上有一块木板,木板上有若干螺丝,玩家用手指顺时针滑动螺丝,旋转到一定角度后螺丝弹出,木板碎片散落。后来发现碎片散落需要粒子系统,纯手写太累,于是砍掉。
最终玩法定为:每一关有一块多层木板,木板由若干颗螺丝固定。玩家点击螺丝进入“拧松”状态,手指在螺丝周围做顺时针圆周滑动,螺丝对应的进度环会逐渐填满,进度满后螺丝脱落。所有螺丝都脱落后,木板自动滑落,过关。
选择“圆周滑动”而不是“快速点击”,是因为触摸的旋转手势和真实拧螺丝动作更匹配,有操作代入感。同时实现上也比打锤子、拔钉子简单:只需要跟踪手指相对螺丝圆心的角度变化。
核心循环就是:观察哪些螺丝松了 —— 选择目标 —— 手指拧动 —— 即时反馈 —— 全部脱落。这个循环非常短,单局时间控制在 30 到 90 秒,符合微信小游戏“即点即玩”的调性。难度设计上,越到后面的关卡,螺丝数量越多、松紧度越高,还会出现必须按顺序拆的螺丝组。
1.3 工程目录和资源规划:一个人怎么整理不翻车
项目虽小,工程结构也不能乱。我最终采用的目录是这样的:
project/ ├── game.js // 小游戏入口 ├── game.json // 小游戏配置 ├── src/ │ ├── main.js // 游戏主循环 │ ├── render/ │ │ ├── board.js // 木板绘制 │ │ └── screw.js // 螺丝绘制 │ ├── logic/ │ │ ├── level.js // 关卡配置 │ │ └── rotate.js // 手势旋转算法 │ ├── audio/ │ │ └── sfx.js // 音效生成与播放 │ └── utils/ │ ├── adapter.js // 适配层 │ └── event.js // 事件封装 └── assets/ // 静态图片,最后发现几乎没有资源规划是我这次最走运的部分。美术素材我全部用 Canvas 绘制:螺丝、木板、背景,全是代码画出来的。音效也直接用 WebAudio 合成:拧动时的“咔哒”声、螺丝脱落时的“叮”声,完全没有外部音频文件。这样既符合“纯原创”的要求,又免去版权风险。
注意:如果你计划做更复杂的视觉,建议提前把美术风格定了,比如 2D 像素风,代码绘制容易统一。要是美术资源外包或自己下素材,一定要确认授权和备案信息,不然后面上传审核会被打回。
2. 核心开发实现:原生小游戏骨架从小白能看懂开始
2.1 入口文件与全局环境适配
微信小游戏的入口是game.js,在全局对象里有一个wx命名空间。我最开始写的入口代码极其简单:
// game.js import { Game } from './src/main.js'; new Game().start();但真正麻烦的是屏幕适配。小游戏运行在不同分辨率下,必须自己建立一套“设计分辨率 → 物理像素”的映射。我用的是一个比较通用的做法:设定逻辑宽度为 750,高度根据屏幕比例动态计算。
// src/utils/adapter.js const WINDOW_WIDTH = 750; // 逻辑宽度 const info = wx.getWindowInfo(); const WINDOW_HEIGHT = Math.floor(WINDOW_WIDTH * info.windowHeight / info.windowWidth); const DPR = info.pixelRatio; export { WINDOW_WIDTH, WINDOW_HEIGHT, DPR };这里的核心思路是:所有游戏逻辑、触摸坐标都基于 750 宽的逻辑坐标系来计算,绘制时再把 Canvas 的 scale 设为 DPR,并让 Canvas 的物理尺寸等于窗口物理像素。如果不这么做,直接把逻辑坐标当物理坐标用,在 iPhone 13 Pro 这种 3 倍屏上,画面会明显发虚,触摸坐标也会差一截。
主循环我用requestAnimationFrame,自己维护fps统计:
// src/main.js class Game { constructor() { this.canvas = wx.createCanvas(); this.ctx = this.canvas.getContext('2d'); this.lastTime = Date.now(); } start() { this.loop(); } loop() { const now = Date.now(); const delta = now - this.lastTime; this.lastTime = now; this.update(delta); this.render(); requestAnimationFrame(() => this.loop()); } }2.2 渲染层设计:用 Canvas 画出一块能吓到朋友的木板
渲染层的核心是“按需重绘”。一开始我图省事,每帧把整个场景清空重新画,后来发现这样性能很差。优化后我把背景和静止元素缓存到一个离屏 Canvas,每帧只把离屏图形drawImage到主 Canvas,再画动态元素。
画螺丝的思路比较简单。我的螺丝由三部分组成:六角螺帽、螺杆、阴影。螺帽用六边形路径,螺杆用矩形加渐变表示深度,阴影则是半透明的圆形。
// src/render/screw.js export function drawScrew(ctx, x, y, progress) { ctx.save(); ctx.translate(x, y); // 阴影 ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; ctx.beginPath(); ctx.arc(0, 4, 22, 0, Math.PI * 2); ctx.fill(); // 螺杆:随着 progress 增大而上移并变短,模拟拧出 const lift = progress * 12; ctx.fillStyle = '#8a8f99'; ctx.fillRect(-5, -28 + lift, 10, 34 - lift); // 螺帽 ctx.fillStyle = '#b8c0cc'; ctx.beginPath(); for (let i = 0; i < 6; i++) { const angle = (Math.PI / 3) * i + (progress * Math.PI / 180); const px = Math.cos(angle) * 16; const py = Math.sin(angle) * 16; if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.closePath(); ctx.fill(); ctx.restore(); }绘制时每帧更新progress,视觉上螺丝就会一边旋转一边慢慢向上抬,进度条则画在螺丝外围。这一套下来,不需要任何图片素材,游戏画面已经有了。
2.3 交互层:手指旋转手势的识别算法
这是全项目最有技术含量的一段。触摸事件本身很简单,wx.onTouchStart、wx.onTouchMove、wx.onTouchEnd,但把手指的平面位移转化成一圈一圈的旋转量需要一点几何功夫。
我的实现步骤:
- 触摸开始时判断触点是否落在某个螺丝范围内,锁定目标。
- 触摸移动时,用
Math.atan2(dy, dx)计算手指相对螺丝圆心的角度。 - 每个事件帧计算当前角度和上一帧角度的差,累计到该螺丝的旋转进度上。
- 当累计角度达到 360 度(或者设定值),螺丝脱落。
核心代码如下:
// src/logic/rotate.js export class RotationGesture { constructor(centerX, centerY) { this.cx = centerX; this.cy = centerY; this.lastAngle = 0; this.totalDelta = 0; this.active = false; } start(x, y) { this.active = true; this.lastAngle = Math.atan2(y - this.cy, x - this.cx); this.totalDelta = 0; } move(x, y) { if (!this.active) return 0; const angle = Math.atan2(y - this.cy, x - this.cx); let delta = angle - this.lastAngle; // 处理跨 -PI / PI 边界的情况 if (delta > Math.PI) delta -= Math.PI * 2; if (delta < -Math.PI) delta += Math.PI * 2; this.lastAngle = angle; this.totalDelta += delta; return this.totalDelta; } }这里最坑的就是角度跨越问题。手指从 3 点钟方向逆时针滑到 9 点钟方向时,atan2会从约 3.14 直接跳到 -3.14,差值可能变成 -6.28,如果不做归一化,游戏会以为你反向拧了一大圈,螺丝进度直接倒退。我给这段代码起了个名字:手势跨域补偿。
实操心得:真机上的手指识别和模拟器很不一样。模拟器里手指触点很精准,真机上有一定的滑动面积,导致第一帧触点可能落在圆心的位置,
atan2(0,0)直接返回 0,需要加一层“触点是否离圆心过近”的判断,太近就忽略。
3. 玩法数值与关卡设计:螺丝怎么旋转才有手感
3.1 关卡配置表:把一棵树的螺丝安排得明明白白
打螺丝这种休闲游戏,真正的核心竞争力不是渲染,而是关卡手感。我建立了一个纯 JSON 描述的关卡配置:每关包含木板尺寸、螺丝数量、每个螺丝的位置、松紧度、旋转方向,以及时间限制。
// src/logic/level.js const LEVELS = [ { board: { width: 500, height: 300 }, screws: [ { x: 150, y: 120, tightness: 360, direction: 1 }, { x: 300, y: 200, tightness: 360, direction: 1 }, { x: 450, y: 140, tightness: 360, direction: -1 } ], timeLimit: 60 }, { board: { width: 600, height: 400 }, screws: [ { x: 180, y: 160, tightness: 540, direction: 1 }, { x: 380, y: 180, tightness: 720, direction: -1 }, { x: 280, y: 280, tightness: 1080, direction: 1 } ], timeLimit: 75 } ];tightness指需要旋转多少度才能把螺丝拧出来。第 1 关的螺丝都只要转一圈 360 度,第 2 关开始出现 540、720、1080 度,也就是要转 3 圈。direction表示正反旋转方向,有些螺丝需要逆时针拧,玩家如果方向拧反了,进度不仅不加还会倒退,这就产生了操作思考和策略感。
关卡难度上升我遵循“数量 + 时间 + 特殊结构”三线并行。到中后期加入“门栓螺丝”——必须先拆掉左右两个小螺丝,中间大螺丝才能旋转,用数组依赖实现。为了让玩家一眼能看出依赖关系,大螺丝周围会画一圈虚线提示。
3.2 手感调优:真实感来自三个凑出来的参数
无引擎不代表没有手感。我通过反复真机试玩,最后把影响手感的核心参数收敛成三个:
- 旋转灵敏度:手指移动 1 像素对应多少弧度。太灵敏容易误触,太迟钝会让玩家觉得拧不动。实测 0.012 rad/px 左右比较合适。
- 阻尼:手指停止后的一小段时间内,螺丝仍按惯性减速旋转。这个不是必须的,但少量阻尼会让操作更接近现实中的机械手感。
- 判定宽限:在触摸边界区域,允许触点轻度抖动不产生误反向。这个宽度我设为 8 逻辑像素。
另外还有一个非常反直觉的点:很多玩家在手机上是快速“划圈”,不是贴着手指数像素。为了兼容这两种操作习惯,我在判断旋转增量时加入了一个“有效圈数”的概念:如果手指绕圆心快速画出一个近似圆,即使采样点稀疏,只要连续 3 帧方向一致,就把中间丢失的角度近似估算补上。
这些参数我没有一次性调完,而是每天玩自己的游戏 20 分钟,记下“哪里手感像橡皮”“哪里转着转着突然反向”,再逐项调整。真实手感是做出来的,不是设计出来。
3.3 音效与震动:没有音频引擎也能抓住耳朵
小游戏包体要小,音效文件能免则免。我用 WebAudio 的OscillatorNode动态生成音效。比如拧动音效,其实是一个频率轻微变化的方波,时长控制在 0.05 秒;掉落音效则是一个频率逐渐下降的正弦波。核心代码如下:
// src/audio/sfx.js export function playTick() { const ctx = getAudioContext(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'square'; osc.frequency.setValueAtTime(2400, ctx.currentTime); osc.frequency.exponentialRampToValueAtTime(1800, ctx.currentTime + 0.04); gain.gain.setValueAtTime(0.3, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.05); osc.connect(gain).connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 0.05); }音频上下文在小游戏里比较特殊,它不能直接使用浏览器全局的AudioContext,需要通过wx.createWebAudioContext()创建。不同安卓机型对 WebAudio 的支持也有差异,有些需要等待用户第一次触摸后再创建音频节点,否则不发声。
震动反馈我用wx.vibrateShort,在螺丝脱落瞬间调用,震动时长参数设为 30ms。实测安卓的震动明显比 iPhone 强,所以我在安卓上关闭了持续震动,只保留脱落瞬间反馈,避免玩家觉得手麻。
提示:音效和震动是“低成本高感知”的反馈,哪怕没有美术功底,做好这两样也能让玩家觉得很完整。但一定记得给玩家设置关闭选项,尤其是震动,不是每个人都喜欢。
4. 真机调试与性能优化:踩过的坑值得单独一章
4.1 开发者工具、真机预览、性能面板的正确用法
开发调试阶段,我主要依赖微信开发者工具的“模拟器”,但模拟器只能验证功能逻辑,不能模拟真机性能。第一次真机预览时,我的游戏在 iPhone 上明显卡顿,帧率只有不到 30 FPS,而模拟器里却是满帧。原因很快查到了:我在每帧更新时频繁创建 Canvas 渐变对象,给所有螺丝画阴影。
正确的排查流程是:
- 先在模拟器里跑,确认逻辑没有报错。
- 再在真机预览,打开调试面板,查看 FPS 和内存曲线。
- 如果 FPS 低,优先检查是否每帧创建了大对象,比如渐变、路径、甚至
ctx.save()都尽量少调用。 - 使用离屏 Canvas 把静态内容预渲染,动态部分才每帧重画。
我最后把所有背景元素、木板、未激活的螺丝阴影都画到一张离屏 Canvas 上,每帧只要一次drawImage,性能立刻回到了 60 FPS。
注意:小游戏的 Canvas 是全局唯一的,开发时为了调试,你可能想创建两个 Canvas 分别显示游戏画面和调试信息。但发布版本里请移除所有调试 Canvas,否则内存占用会成倍增加,在中低端安卓机上很容易被系统杀掉。
4.2 一次从 60 FPS 掉到 20 FPS 的排查实录
有一版我加入了“螺丝脱落碎片”动画,直接在每帧里用ctx.fillRect绘制几十个小方块模拟碎片下落。代码逻辑没问题,但真机直接卡成幻灯片。我用真机调试捕获了性能数据:CPU 峰值接近 100%,像素填充率过高。
解决方案是给碎片动画单独建立一个小画布缓存,碎片每帧在缓存 Canvas 上更新,然后整体贴到主屏幕。另外,碎片数量从 50 降到 15,反正小尺寸屏幕上玩家根本注意不到数量差异。这件事让我明白:小游戏性能问题,第一排查对象永远是“无效像素重复填充”,而不是算法复杂度。
在低端安卓上,我还发现requestAnimationFrame触发频率不足 60Hz,会掉到 30Hz。这种情况下如果再叠加复杂的绘制,游戏就像慢动作。为了应对,我在主循环里加入了动态降级:如果检测到一帧耗时超过 30ms,就把阴影绘制关闭、碎片数量减半、动画帧率上限降到 30。
4.3 iPhone 与安卓的兼容性差异
两类设备的差异比我预想的大得多,列几个必须注意的:
- 分辨率:iPhone 的逻辑像素宽度从 320 到 430 都有,安卓更是从 360 到 480 不等。我只按 750 宽适配,部分超窄屏上木板会显示不全。后来我采用“等比缩放但保证 100% 可玩区域”,木板最大宽度不超过屏幕宽度的 80%,位置居中。
- 触摸灵敏度:安卓的触摸采样率普遍没有 iPhone 高,快速划圈时中间点会稀疏,手势判断容易误判。我的处理方式是提高方向连续性检查:不要求每帧都检测到旋转,只要检测到 3 次同向角度变化就继续累积。
- 系统级手势:在 iPhone 上,从屏幕边缘滑动会触发系统返回手势,导致游戏触摸中断。玩游戏时我不使用全屏手势区域,将关键操作区限制在屏幕中央,同时设置
wx.setPageStyle禁用部分系统手势(只保留最低限度)。
这段兼容性优化最耗时间,但也是上线没有第一时间被骂的根本原因。
5. 上线完整流程与常见被拒原因
5.1 账号注册、类目选择与提包的正确姿势
微信小游戏的上线流程比很多开发者想象中要繁琐,但按步骤走并不复杂。首先需要一个注册好的小游戏账号,这可以在微信公众平台完成。个人主体可以注册小游戏账号,但要注意个人主体的类目选择有限,游戏类目需要提供软著或自研说明,所以我提前准备了软件著作权申请材料。
类目我选择的是“休闲游戏”。如果你选择“工具”类目,审核会更严,因为平台会怀疑你有诱导分享或虚拟支付嫌疑。实际上,打螺丝这种玩法更像休闲游戏,选错了类目轻则提审被打回,重则影响后续能力开放。
提包时需要注意:
- 代码包必须小于 4MB,我现在整个项目才 80KB,完全没问题。
- 需要在
game.json中声明requiredBackgroundModes为["audio"],否则播放音效可能被后台静音。 - 不要在主包中放任何测试代码、打印日志、远程调试开关。
我踩过一次的坑是,首次提包时没有在后台配置“用户隐私保护指引”,审核直接拒绝,理由是“未声明收集用户信息”。实际上我的游戏只读取了触摸事件和震动设置,并没有收集任何用户信息,但平台要求只要有系统调用就要声明对应的隐私说明,而且用户协议和隐私政策必须能通过小游戏页面点击查看。
5.2 审核自测清单与常见拒审原因
整理一份我每次提审前都会过一遍的清单:
| 检查项 | 内容 |
|---|---|
| 启动流程 | 冷启动和热启动均无报错 |
| 触摸操作 | 所有按钮可点击,无死区 |
| 音效设置 | 有静音切换,默认符合系统静音 |
| 数据上报 | 使用官方埋点,无自定义用户信息采集 |
| 隐私说明 | 后台已配置隐私协议,用户可查看 |
| 分享设置 | 分享文案不包含诱导词汇 |
| 版权素材 | 全部原创,代码注释无抄袭 |
| 兼容性 | 在 iPhone 和安卓各至少一台真机测试 |
常见的拒审原因还有:wx.vibrateShort调用频率过高,被判定为骚扰性震动;游戏逻辑里出现“分享获得奖励”的入口,但个人主体小游戏不允许诱导分享;页面没有到达率提示,提审包缺少启动 Loading 画面。这些都很好解决,但每次被拒都会消耗审核时间,能一次过就一次过。
5.3 发布、灰度与上线后的数据监控
审核通过后,可以选择先全量发布还是先灰度发布。我第一次选择了全量发布,结果上线第 2 天就收到一批低端安卓机的崩溃反馈。核心崩溃原因是某些老机型不支持wx.getWindowInfo这个 API,需要降级到wx.getSystemInfoSync。
好在官方后台提供了实时日志和崩溃分析,我很快定位到问题。之后的策略改成了“5% 灰度发布再全量”,灰度 24 小时没有上升的崩溃率,再逐步放量。如果不是急上线,强烈建议用灰度。
数据监控方面,我的埋点非常简单:
- 进入游戏的设备型号、系统版本。
- 每个关卡的开始、完成、失败事件。
- 螺丝脱落后的平均耗时(衡量操作手感)。
- 分享次数和分享来源。
这些数据用官方数据分析平台的基础事件上报就能完成,不需要额外 SDK。上线第一周的数据告诉我:玩家在第 5 关流失率特别高,原因是第 5 关第一次出现“门栓螺丝”,很多玩家不知道要先拆小螺丝。于是我加入了新手引导半透明箭头,同时把第 5 关时间限制放宽了 15 秒,流失率下降了大约 18%。
发布不是终点,而是数据驱动调优的起点。打螺丝这种小游戏,前期版本最大的价值是验证玩法循环,而不是一次性把所有内容做完。
最后分享一个小技巧:无论引擎方案怎么选,一定要保留游戏内开发调试快捷键,比如双指连点 5 次跳过当前关卡。这在真机调关卡节奏时太好用了,不用每次重新打一遍前面的关卡才能测到新关卡。我后来还加了“手动设置松紧度”的调试面板,省了大量重新编译的时间。