事情的起因很简单:我想做一个让人"浪费时间"的小游戏。结果我自己在开发过程中浪费的时间,比任何玩家都多。
🤔 起因:一个沙雕想法的诞生
某天摸鱼的时候,我突然冒出一个想法------
"有没有一种游戏,就是把手指放在屏幕上不松开,看谁能坚持更久?"
就这?对,就这。
你别笑,我认真的。想想看,这个玩法有多完美:
- 零门槛:会用手机就会玩,不需要任何游戏经验
- 强社交:和好友比"谁更持久",标题自带传播属性(别想歪)
- 杀时间:用户主动把时间浪费在你的游戏上,还乐在其中
我当时就觉得这个idea成了,于是打开了 Cursor,叫上了 AI 这个 24 小时在线、不要工资、不用交社保的"同事",开整。
📋 第一步:让 AI 帮我写游戏方案
作为一个有追求的开发者,我不会上来就莽代码。我先让 AI 帮我出了一份详细的游戏策划方案。
我大概描述了一下我的想法,AI 就给我输出了一份 360 行的 Markdown 策划文档,比我上学写的毕业论文都认真。里面包含:
- 核心玩法规则(手指触摸、泡泡击破)
- 6 种难度递增的阶段设计(从每 3 秒 1 个泡泡到每 0.3 秒 1 个,丧心病狂)
- 3 种正面 Buff + 2 种负面泡泡 + 里程碑彩虹泡泡
- 好友排行榜 + 全国排行榜(日榜/周榜/总榜 × 4 个维度)
- 防挂机验证 + 心跳反作弊
- 分享复活机制
- 甚至连运营推广建议都给我写了
我看完方案的第一反应:这 AI 比我还了解我想做什么。
🏗️ 第二步:从 0 开始搓代码
技术栈选择
微信小游戏,Canvas 2D,原生 JS,微信云开发。没有用任何游戏引擎。
为什么不用 Cocos / Phaser 之类的引擎?因为这游戏的画面需求就是画几个圆(泡泡),上引擎属于杀鸡用牛刀,包体还大。
整个项目的文件结构长这样:
bash
├── game.js # 入口,就 3 行(初始化云开发 + new Main)
├── js/
│ ├── main.js # 游戏主循环(1081 行)
│ ├── databus.js # 全局状态管理(776 行)
│ ├── bubble.js # 泡泡类
│ ├── effects.js # 粒子特效系统
│ ├── sound.js # 音效管理(纯代码合成)
│ ├── render.js # 屏幕适配
│ ├── base/pool.js # 对象池
│ └── runtime/
│ ├── background.js # 背景渲染
│ ├── hud.js # 游戏内 HUD
│ └── screens.js # 各页面 UI(714 行)
├── open-data-context/ # 开放数据域(好友排行榜)
│ └── index.js
└── cloudfunctions/ # 云函数
├── login/
├── submitScore/
├── getRanking/
└── heartbeat/
别看游戏简单,代码量不少。AI 跟我协作得像一对老搭档,我说需求,它写代码,我改 bug,它再改 bug,完美的死循环。
游戏入口:世界上最短的入口文件
javascript
import Main from './js/main';
wx.cloud.init({
env: 'cloud1-4gcmmd7he3ad2269',
traceUser: true,
});
new Main();
没错,game.js 就这么点东西。AI 说"入口文件应该保持简洁",我深以为然。
🫧 第三步:泡泡系统 ------ 灵魂所在
泡泡是整个游戏的灵魂。不是那种实心圆形的"泡泡",我要的是那种半透明的、有光泽的、像真的肥皂泡一样的泡泡。
我跟 AI 说:"泡泡要好看,要有质感,要像真的。"
然后 AI 给了我一套多层渲染方案:
- 径向渐变主体:从中心到边缘的透明度变化
- 边缘薄膜质感:额外的环形渐变,模拟泡泡膜
- 细描边:带透明度的颜色描边
- 主高光:左上角白色弧形,模拟光源反射
- 次高光:右下角小反光点
- 特殊泡泡光晕:带脉动动画的外发光
看一下渲染代码的精髓部分:
javascript
// 泡泡主体(半透明径向渐变,更像气泡)
const gradient = ctx.createRadialGradient(
this.x - this.radius * 0.3, // 高光偏移
this.y - this.radius * 0.3,
this.radius * 0.1,
this.x, this.y, this.radius
);
gradient.addColorStop(0, this._lightenAlpha(this.color, 80, 0.35));
gradient.addColorStop(0.6, this._colorAlpha(this.color, 0.1)); // 中间几乎透明
gradient.addColorStop(1, this._colorAlpha(this.color, 0.28));
里程碑泡泡更骚,用了 HSL 色相旋转 做彩虹渐变效果:
javascript
const hue = (Date.now() / 20) % 360;
gradient.addColorStop(0, `hsla(${hue}, 100%, 90%, 0.35)`);
gradient.addColorStop(0.6, `hsla(${(hue + 40) % 360}, 90%, 70%, 0.15)`);
gradient.addColorStop(1, `hsla(${(hue + 60) % 360}, 80%, 55%, 0.3)`);
每 20 毫秒色相旋转一度,这泡泡在屏幕上简直是彩虹在跳舞。
泡泡类型大全
| 类型 | 颜色 | 效果 | 出场时机 |
|---|---|---|---|
| 普通泡泡 | 彩虹七色随机 | +1 泡泡计数 | 全程 |
| 加速泡泡 🟠 | 橙色 | 10秒内泡泡生成翻倍 | 随时 |
| 防护泡泡 🔵 | 蓝色 | 10秒内松手1秒不会死 | 随时 |
| 双倍泡泡 🩷 | 粉色 | 10秒内击破泡泡计数×2 | 随时 |
| 炸弹泡泡 💣 | 黑色 | 扣 10 个泡泡 + 震动 | 30分钟后 |
| 冰冻泡泡 🧊 | 淡蓝 | 暂停计时 5 秒 | 30分钟后 |
| 里程碑泡泡 🌈 | 彩虹色 | 鼓励文案 + 计数 | 每 5 分钟 |
| 验证泡泡 ❓ | 金色 | 防挂机验证 | 静止30秒后 |
30 分钟后出现负面泡泡,这个设计我非常满意------早期让你爽,后期让你怕。玩家的手指在屏幕上既要追泡泡,又要躲炸弹,这种纠结的快感,妙啊。
💥 第四步:粒子特效系统 ------ 让戳泡泡有仪式感
戳泡泡如果没有特效,那跟咸鱼有什么区别?
我让 AI 搞了一套完整的粒子特效系统,包含三种特效元素:
- 爆裂粒子:向四周飞散的小圆点,带微弱重力和减速
- 冲击波环:从中心向外扩散的圆环
- 缩放闪光:中心区域的径向渐变闪光
不同类型的泡泡,爆破效果还不一样:
javascript
switch (type) {
case 'normal': this._normalPop(x, y, radius, color); break; // 6~9 个粒子
case 'bomb': this._bombPop(x, y, radius); break; // 16~22 个火焰粒子
case 'freeze': this._freezePop(x, y, radius); break; // 冰晶粒子(重力极低)
case 'milestone': this._milestonePop(x, y, radius); break; // 18~24 个彩虹粒子
// ...
}
炸弹泡泡爆破是五种火焰色的粒子乱飞 + 双层冲击波 + 强闪光,配合手机震动,效果拉满。
冰冻泡泡的粒子最有意思------AI 把重力设成了 0.005(正常是 0.02~0.05),所以冰晶粒子是"轻飘飘"地飞散,视觉上确实像冰碎了的感觉。这种细节,真的是 AI 给的惊喜。
🔊 第五步:音效系统 ------ 零文件,纯代码合成
这是整个项目里我最得意的部分:所有音效和背景音乐都是用 WebAudio API 实时合成的,没有任何音频文件。
零 mp3,零 wav,零 ogg,包体直接省了一大坨。
泡泡击破音效
javascript
// 普通泡泡 "啵" 的一声
osc.type = 'sine';
osc.frequency.setValueAtTime(800 + Math.random() * 400, now); // 随机起始频率
osc.frequency.exponentialRampToValueAtTime(200, now + 0.08); // 快速下滑
gain.gain.setValueAtTime(0.15, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1); // 0.1秒衰减
就是一个 0.1 秒的正弦波快速下滑音,频率从 800~1200Hz 滑到 200Hz,听起来就是 "啵" 的一声。每次频率还随机,所以每个泡泡的破裂声都微妙不同,有种 ASMR 的快感。
炸弹爆炸音效
javascript
osc.type = 'sawtooth'; // 锯齿波(粗糙感)
osc.frequency.setValueAtTime(150, now);
osc.frequency.exponentialRampToValueAtTime(40, now + 0.3); // 低频下沉
// 还叠加了一层噪声!
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize); // 衰减白噪声
}
锯齿波低频下沉 + 衰减白噪声 = 低沉的 "轰" 一声。AI 甚至给噪声加了衰减包络,太专业了。
背景音乐:催眠又上头
BGM 是最疯狂的部分。AI 用几个振荡器合成了一首 梦幻五声音阶琶音 + 呼吸感和弦垫 + 低音根音 的背景音乐:
r
层次结构:
├── Sub Bass(C2 正弦波)------ 低音底座
├── Pad 和弦垫(三角波 × 3)------ 和弦 C → Am → F → G 循环
│ └── LFO 调制音量 ------ 呼吸效果
└── 琶音旋律(C5 E5 G5 A5 ↑↓)------ 每 375ms 一个音符
和弦每 3 秒切换一次(C → Am → F → G),琶音用五声音阶上下行,LFO 控制和弦垫的音量做呼吸效果。
最终效果:催眠、舒服、有点上头。你手指放在屏幕上,听着这个 BGM 戳泡泡,时间不知不觉就过去了。
这就是用代码"作曲"的魅力------不需要请音乐人,不需要版权费,wx.createWebAudioContext() 就完事了。
🛡️ 第六步:防作弊 ------ 和外挂斗智斗勇
做排行榜就意味着:一定有人想作弊。
我跟 AI 商量了半天,最终实现了三道防线:
第一道:客户端防挂机检测
javascript
// 手指 30 秒没动超过 5px = 疑似挂机
const idleTrigger = idleTime >= 30000;
// 每 5~10 分钟随机触发验证(防止固定时间被预判)
const periodicTrigger = now - db.lastVerifyTime >= db.nextVerifyInterval;
触发后在远离手指的位置生成一个金色验证泡泡,10 秒内不去碰就 Game Over。你得在不松开手指的情况下滑过去戳它,挂机党直接 GG。
生成位置还特意避开了手指当前位置(至少 120px 距离),确保需要主动移动:
javascript
do {
vx = margin + Math.random() * (SCREEN_WIDTH - margin * 2);
vy = topSafe + margin + Math.random() * (SCREEN_HEIGHT - topSafe - bottomSafe - margin * 2);
} while (Math.sqrt((vx - db.touchX) ** 2 + (vy - db.touchY) ** 2) < 120 && attempts < 20);
第二道:心跳机制
客户端每 30 秒发一次心跳到云函数,带上当前时长和泡泡数。成绩提交时服务端会校验:
javascript
// 上报时长与最后心跳时长偏差超过 60 秒 = 异常
if (Math.abs(duration - hbDuration) > 60000) {
isValid = false;
anomalyReason = '时长与心跳数据偏差过大';
}
// 单局超过 4 小时 = 可疑
if (duration > 4 * 60 * 60 * 1000) {
isValid = false;
anomalyReason = '单局超过4小时';
}
第三道:前后台切换检测
javascript
wx.onHide(() => {
if (this.databus.gameState === 'playing') {
this.databus.isTouching = false;
this.databus.gameOver();
}
});
切后台、息屏、接电话?直接 Game Over,没商量。所以游戏开始前我们贴心地提示用户"建议开启手机勿扰模式"。
🏆 第七步:排行榜 ------ 攀比心是最好的留存
排行榜分两种:
好友排行榜
这个是微信小游戏的特色------必须通过开放数据域实现。简单说就是微信给你一个沙箱环境,里面能拿到好友的游戏数据,但只能在这个沙箱里渲染 UI,主域看不到具体数据。
主域发消息给子域:
javascript
openDataContext.postMessage({
type: 'showRanking',
dimension: 'bestDuration',
period: 'all',
selfOpenId: this.openid,
});
子域接收消息,调用 wx.getFriendCloudStorage 拿数据,然后在独立画布上画排名列表。主域再用 ctx.drawImage(openDataCanvas) 把子域画布贴到主画布上。
这个流程说起来简单,实际踩了不少坑------比如 getFriendCloudStorage 需要用户点击上下文才能调用(微信的安全限制),隐私授权得先过...后面讲。
全国排行榜
全国排行榜用云数据库的聚合查询实现。日榜和周榜需要从 scores 表按用户分组聚合:
javascript
const aggRes = await db.collection('scores')
.aggregate()
.match({ dayKey: todayStr, isValid: true })
.group({
_id: '$_openid',
aggValue: isTotal ? $.sum(`$${scoreField}`) : $.max(`$${scoreField}`),
})
.sort({ aggValue: -1 })
.limit(50)
.end();
4 个排名维度(单局最长时长、单局最多泡泡、总时长、总泡泡)× 3 个时间周期(日榜、周榜、总榜)= 12 种排名方式。攀比心直接拉满。
😤 第八步:隐私授权 ------ 微信审核の噩梦
2023 年微信搞了个"用户隐私保护指引",小游戏必须走隐私授权流程,否则很多 API 调不通。
这块逻辑是整个项目里最"不游戏"的部分,但不做不行:
javascript
// 注册隐私授权回调
wx.onNeedPrivacyAuthorization((resolve, eventInfo) => {
// 如果本次会话已经完成过隐私授权,直接放行
if (this.privacyResolved) {
resolve({ buttonId: 'agree-btn', event: 'agree' });
return;
}
this.privacyResolve = resolve;
this.showPrivacyPopup = true; // 弹出自绘制的隐私弹窗
});
弹窗是纯 Canvas 手绘的,包括圆角白色面板、标题、正文、隐私协议链接(带下划线)、同意/拒绝按钮。按钮点击检测全靠坐标碰撞------对,就是最原始的 hitTest。
这里有个大坑:getFriendCloudStorage 不仅需要隐私授权通过,还需要在用户点击上下文中调用。也就是说:
- 隐私弹窗同意后不能立即调用(异步回调,没有点击上下文)
- 必须让用户再次点击按钮,此时隐私已生效 + 有点击上下文
- 所以代码里
_onPrivacyAgree完成后直接清除pendingAction,不执行挂起的操作
这个 bug 我调了很久才明白,AI 也是反复和我讨论了好几轮才搞定。微信的安全机制,说多了都是泪。
🎨 第九步:UI 全部手绘
整个游戏没有用一张 UI 图片(logo 和分享图除外),所有界面元素都是 Canvas 2D 绑定绘制的:
- 圆角矩形按钮(渐变填充 / 描边两种风格)
- 按钮按下动画(缩小 95% + 下移 2px + 颜色变暗)
- 脉动动画的触摸引导圈
- 半透明 HUD 信息条
- 排行榜列表(斑马纹背景 + 前三名金银铜色)
- 装饰性浮动泡泡(15 个半透明泡泡在背景缓慢漂移)
按钮按下动画的实现挺优雅的:
javascript
if (pressed) {
ctx.save();
const cx = x + w / 2;
const cy = y + h / 2;
ctx.translate(cx, cy);
ctx.scale(0.95, 0.95); // 缩小到 95%
ctx.translate(-cx, -cy + 2); // 下移 2px
}
// ... 绘制按钮 ...
if (pressed) ctx.restore();
以中心为锚点缩放,加一丢丢下移,再把颜色调暗一点,就有了很自然的按下反馈。不需要引入任何 UI 框架。
🎯 第十步:对象池 ------ 性能的保命符
泡泡这种高频创建、短命的对象,如果每次 new Bubble(),GC 会疯。所以用了对象池:
javascript
// 获取泡泡:池里有就拿,没有就 new
getItemByClass(name, className) {
const pool = this.getPoolBySign(name);
return pool.length ? pool.shift() : new className();
}
// 回收泡泡:扔回池里
recover(name, instance) {
this.getPoolBySign(name).push(instance);
}
泡泡消失时不销毁,而是回收到池里。下次生成新泡泡时从池里取,重新 init() 就行。整个游戏运行过程中,泡泡对象几乎不触发 GC。
这也是 AI 一开始就建议的架构------"长时间运行的游戏,对象池是必须的"。说得对。
🔢 一些有趣的数字
- 总代码行数:约 4000+ 行(含云函数和开放数据域)
- 游戏入口文件:3 行有效代码
- 音频文件数量:0 个
- UI 图片数量:0 个(logo 和分享图除外)
- 泡泡类型:8 种
- 排名维度:4 × 3 = 12 种
- 特效类型:6 种(普通/Buff/炸弹/冰冻/里程碑/验证)
- Buff 种类:3 正面 + 2 负面
- AI 的工作时长:不可计量(它不需要睡觉)
- 我的工作时长:不想计量(怕自己哭)
💬 和 AI 协作的一些感悟
整个项目从方案到实现,AI 承担了大概 90% 的代码量。但这不意味着我是甩手掌柜------恰恰相反,你得非常清楚你想要什么,AI 才能给你什么。
几点体会:
-
方案先行:先让 AI 出策划方案,review 之后再动手写代码。好的方案能省掉后面 80% 的返工。
-
小步快跑:不要一次性让 AI 写完所有代码。一个模块一个模块来,写完测试,再写下一个。AI 写出来的代码不一定能跑,但大部分时候方向是对的。
-
AI 很擅长"细节填充":比如泡泡的渲染层次、粒子的重力参数、音效的频率曲线这些,我只需要说"我要一个冰冻的感觉",它就能给出合理的参数。
-
AI 不擅长"平台特性":微信小游戏的隐私授权、开放数据域、点击上下文这些平台特有的坑,AI 经常踩。需要你自己去翻文档、调试、把错误信息喂给 AI 再改。
-
代码合成音乐真的很酷:这是我第一次用代码"作曲",虽然曲子很简单,但整个过程非常有创造力。推荐每个开发者都试试 WebAudio API。
🎮 最终效果
游戏名:指尖持久战
核心玩法:手指放在屏幕上,别松开。滑动手指戳泡泡。看谁坚持更久。
就是这么简单,就是这么上头。
"你就说你能坚持多久吧?"
技术栈总结:
- 前端:微信小游戏 Canvas 2D + 原生 JS
- 后端:微信云开发(云函数 + 云数据库)
- 音效:WebAudio API 实时合成
- UI:Canvas 手绘,零 UI 框架
- 特效:自实现粒子系统
- 工具:Cursor + AI
如果你觉得这篇文章有点意思,点个赞呗。毕竟我手指已经在屏幕上按了很久了(写这篇文章的时候)。
