线上试玩:https://meekshark.cn/game/
「泡泡实验室」(BNB Lab)是我用 Cocos Creator 3.8.8 做的一个中英双语小游戏。玩法是那类熟悉的「投放、碰撞、同类融合」——你从顶端放下泡泡,两个一样的碰在一起会合成更高一级,一路合到第九档。游戏里所有的泡泡和界面都是 Cocos 的 Graphics 在运行时画出来的,没有引用任何远程图片或模型素材。

它好玩在哪
核心机制一句话说得完:泡泡是有弹性的、会挤压变形的软体,不是硬邦邦的圆。九个档位的泡泡落进池子,堆叠、滚动、互相挤压,你眼看着一颗被压扁的泡泡慢慢弹回原状——手感上的差别比想象中大得多。
几个让它不太一样的设计:
- 搅动。按空格消耗能量搅一下果池,把堆得太高的泡泡晃下去。这是对抗「堆到危险线」的主要手段,但能量有限,融合才能回能,于是每一搅都得算账。
- 倾斜。
A/D把整个池子往一侧倾斜,靠重力让泡泡滚过去,用来把卡在边角的两颗最高档凑到一起。 - 物理模式可调。泡泡、水流、汽水三种阻尼、边缘张力和形变参数随时切换,而且和难度完全独立——手感是手感,难度是难度。

内容量
三档难度:普通难度是原始的手感和节奏,没动过;硬核把危险线放低、越线宽限缩短、能量回复变慢、搅动更贵;无限模式用普通难度的所有参数,但九档泡泡等概率掉落。
九个关卡,前八关每关只教一个机制,第九关是无限模式:
- 遇见伙伴 —— 教融合
- 搅一搅 —— 教搅动
- 别越线 —— 教危险线
- 只有小泡泡 —— 只掉前两档且不能搅动
- 清空泡泡池 —— 不能投放也不能搅动,用倾斜把两颗最高档滚到一起
- 双月野兔 —— 从头攒出两颗最高档再合体
- 有限投放 —— 60 次投放内拿到 4500 分
- 限时挑战 —— 150 秒内拿到 16000 分
- 无限模式 —— 九档泡泡等概率掉落,无目标无限制
每日挑战是固定种子的:同一天所有人拿到同一个开局池和同一串掉落序列,比的是纯操作。日榜按日期分桶。

排行榜分普通难度、硬核难度、无限模式和每日挑战四张榜,各保留前 10 名。前八关的进度只记通关、不进榜——它们的目标分只有几十到几百分,混进难度榜会把榜冲乱。
换肤:7 种主题配色(水墨、青梅、皇云紫、警戒红、电光桃、熔岩橙、松林绿)× 2 套图案系列(泡泡堂像素、泡泡堂 HD),自由组合,选择存在浏览器里。图案系列决定九档泡泡各自叫什么——默认系列下它们叫皮蛋、黑妞、小强、蓝蓝、葱头、胖墩、小乖、宝宝,最高档是月野兔。关卡文案里的档位名跟着当前系列走,所以第 6 关在默认系列下叫「双月野兔」,换个系列名字也跟着变。

操作
| 输入 | 作用 |
|---|---|
| 鼠标 / 触屏 | 移动瞄准,点击投放 |
← → |
移动瞄准线 |
Enter |
投放下一颗泡泡 |
Space |
搅动果池(消耗能量) |
A D |
倾斜果池 |
P |
暂停 / 继续 |
实现上值得一提的几点
软体求解器
每颗泡泡是 18 个质点围成的环(LabPhysics.ts,142 行)。求解用位置约束迭代:距离约束维持形状、弯曲约束保持圆润、再加一项压力约束把面积往回推——面积被压小时压力会把质点往外顶,这就是「挤压变形」和「弹性回弹」的来源。碰撞检测走的是分离轴那一套:逐条边找出最小穿透深度,再按深度推开。
三种物理模式只是同一组约束的不同参数:jelly(泡泡,张力 0.9、阻尼 0.997)、fluid(水流)、juice(汽水,张力只有 0.44,更”塌”更黏)。固定步长 1/120 秒。
难度是数据,不是代码分支
LabRules.ts 里一个 Rules 对象收齐了所有运行时旋钮——危险线高度、越线宽限、危险消退速度、能量回复、搅动消耗、投放冷却、九档出果权重、开局预置水果。三档难度就是三个对象,关卡在普通难度上覆盖其中几项,数组字段整段替换而不是逐项合并。
这样做好处很直接:没有一条 if (difficulty === 'hardcore') 散落在游戏逻辑里。加一档难度是加一个对象,关卡想关掉搅动就是把 stirEnabled 置 false。
顺带一个反直觉的发现写进了注释:硬核一度在无头测试里比普通难度分数还高,原因就是投放间隔——拉长间隔只会让池子填得更慢、活得更久。所以 dropCooldown 最终保持和普通难度一致,它属于节奏而非难度。
出果分布从公式改成权重表
原来线上的出果分布是 floor(random() ** 1.65 * 4),只出前四档。要做关卡就得让不同关卡用不同分布,所以我把它换算成了九项权重表([.432, .225, .183, .160, 0, 0, 0, 0, 0]),概率按 ((k+1)/4)^(1/1.65) - (k/4)^(1/1.65) 展开,等价到小数点后三位。等概率的无限模式就是一个全填 1/9 的表。
确定性随机
LabRandom.ts 只有 23 行,是一个确定性 PRNG。每日挑战靠它配上日期键做种子:开局池和掉落序列都由种子决定,同一天所有人完全一致。这也让无头验证成为可能——tools/sim-check.cjs 不开浏览器跑满 9 关和 3 档难度,用来看关卡目标是否可达、难度旋钮是否真的更难。里面的机器人策略很笨(有同类就贴着同类落,没有就找最空的位置),跑出来的分数是相对指标,只用于横向比较,不代表人类水平。
存档、榜单与发版
本地数据全部走 LabProfile.ts 一个出口,localStorage 里五个键:进度与解锁、四张榜的前 10、续玩记录(最多 6 份,每份带完整软体点位)、设置项、deviceId。旧版的单键存档会在首次读取时自动并成一条记录,不会丢。服务器不可用时自动退回纯本地榜,玩法不受影响。
榜单服务是零依赖的 Node 脚本(server/server.cjs,291 行),只用内置模块,数据落在纯文本的 scores.json——备份直接拷文件。默认只监听回环,对外一律经 nginx 反代。开局时 POST /api/run 领一个 token,提交分数时连同用时一起校验。
发版上踩过的坑值得一提:Cocos 的构建产物里混着两类文件——文件名带哈希 UUID 的内容永不变,但 cc.js、config.json、index.js 这些入口文件名字固定、内容每次构建都变。给它们开 immutable 长缓存,发版后老访客会拿旧的 cc.js 去要已经被清掉的哈希分块,404 之后引擎起不来,卡死在加载页。所以这两个目录用的是 no-cache(用之前先回源校验,nginx 带 ETag,内容没变就回 304),而不是 immutable。
另外加载页还加了两层兜底:一份 build-stamp.json 版本戳和 localStorage 里记的对不上,就用 fetch(url, { cache: 'reload' }) 把入口文件绕过缓存重取一遍——实测普通 fetch 拿到的还是旧内容,no-store 能读新内容但不回写缓存、等于白做,只有 cache: 'reload' 会绕过缓存并把新内容写回缓存条目。再不济,加载超过 10 秒页面会出现「强制刷新」按钮。
技术栈
- Cocos Creator 3.8.8,TypeScript,约 3500 行游戏脚本
- 渲染全靠 Cocos
Graphics运行时绘制,无远程素材 - UI 字体是 HYPixel 11px U,一款像素风格的中文位图字体
- 榜单服务:Node 18+,零依赖
- 部署:nginx alias 挂到
/game/,客户端接口地址跟着页面路径走,所以同一个构建产物放根目录或子目录都不用改配置
English Version
Play online: https://meekshark.cn/game/
Bubble Lab (BNB Lab) is a bilingual mini-game I built with Cocos Creator 3.8.8. The gameplay is the familiar “drop, collide, merge” loop — you drop bubbles from the top, two identical ones merge into the next tier, all the way up to tier nine. Every bubble and UI element in the game is drawn at runtime with Cocos Graphics; no remote images or model assets are used.
What makes it fun
The core mechanic in one sentence: the bubbles are elastic soft bodies that squash and deform, not rigid circles. Nine tiers of bubbles fall into the pool, stacking, rolling and squeezing each other, and you watch a squashed bubble slowly spring back into shape — the difference in feel is much bigger than you’d expect.
A few designs that make it different:
- Stir. Press Space to spend energy and shake the pool, knocking down bubbles stacked too high. It’s the main tool against crossing the danger line, but energy is limited and only merging refills it, so every stir has to count.
- Tilt.
A/Dtilts the whole pool to one side, using gravity to roll bubbles over — handy for bringing two top-tier bubbles stuck in corners together. - Adjustable physics modes. Bubble, Stream and Soda — three sets of damping, edge tension and deformation parameters, switchable anytime and fully independent of difficulty. Feel is feel; difficulty is difficulty.
Content
Three difficulties: Normal keeps the original feel and pacing, untouched. Hardcore lowers the danger line, shortens the crossing grace period, slows energy regen and makes stirring pricier. Endless uses all of Normal’s parameters, but all nine tiers drop with equal probability.
Nine levels — the first eight each teach one mechanic, the ninth is Endless mode:
- Meet Your Buddies — teaches merging
- Give It a Stir — teaches stirring
- Don’t Cross the Line — teaches the danger line
- Little Bubbles Only — only the first two tiers drop, no stirring
- Empty the Pool — no dropping, no stirring; tilt to roll the two top-tier bubbles together
- Double Moon Rabbit — build up two top-tier bubbles from scratch and merge them
- Limited Drops — score 4500 within 60 drops
- Timed Challenge — score 16000 within 150 seconds
- Endless Mode — all nine tiers drop with equal probability, no goal, no limits
Daily Challenge uses a fixed seed: everyone gets the same starting pool and the same drop sequence on the same day, so it’s a pure test of skill. The daily leaderboard is bucketed by date.
Leaderboards: four separate boards — Normal, Hardcore, Endless and Daily Challenge — each keeping the top 10. Progress in the first eight levels only records completion and doesn’t enter the boards: their target scores are only a few dozen to a few hundred points, and mixing them into the difficulty boards would wreck the rankings.
Skins: 7 theme palettes (Ink Wash, Green Plum, Imperial Cloud Purple, Alert Red, Electric Peach, Lava Orange, Pine Green) × 2 pattern series (BNB Pixel, BNB HD), freely combinable, stored in the browser. The pattern series decides what each of the nine tiers is called — in the default series they’re Pidan, Heiniu, Xiaoqiang, Lanlan, Congtou, Pangdun, Xiaoguai and Baobao, with the Moon Rabbit at the top. Level text uses the current series’ names, so level 6 is “Double Moon Rabbit” in the default series, and the name changes with the series.
Controls
| Input | Action |
|---|---|
| Mouse / touch | Move aim, tap to drop |
← → |
Move the aim line |
Enter |
Drop the next bubble |
Space |
Stir the pool (costs energy) |
A D |
Tilt the pool |
P |
Pause / resume |
Implementation notes
Soft-body solver
Each bubble is a ring of 18 point masses (LabPhysics.ts, 142 lines). The solver uses position-based constraint iteration: distance constraints hold the shape, bending constraints keep it round, and a pressure constraint pushes the area back — when the area is compressed, pressure pushes the points outward. That’s where the squash-and-deform and the elastic rebound come from. Collision detection uses the separating axis approach: for each edge, find the minimum penetration depth and push apart along it.
The three physics modes are just different parameters for the same constraint set: jelly (Bubble, tension 0.9, damping 0.997), fluid (Stream), juice (Soda, tension only 0.44, flatter and stickier). Fixed timestep of 1/120 s.
Difficulty is data, not code branches
A single Rules object in LabRules.ts collects every runtime knob — danger line height, crossing grace, danger decay speed, energy regen, stir cost, drop cooldown, the nine-tier drop weights, and the starting pool. The three difficulties are three objects; levels override a few fields on top of Normal, and array fields are replaced wholesale rather than merged item by item.
The benefit is direct: there isn’t a single if (difficulty === 'hardcore') scattered through the game logic. Adding a difficulty means adding an object; disabling stirring in a level is just setting stirEnabled to false.
A counterintuitive finding made it into the comments: Hardcore once scored higher than Normal in headless tests, because of the drop interval — a longer interval just fills the pool more slowly and survives longer. So dropCooldown ultimately matches Normal; it’s pacing, not difficulty.
Drop distribution: from formula to weight table
The original drop distribution was floor(random() ** 1.65 * 4), covering only the first four tiers. Levels needed different distributions, so I converted it into a nine-entry weight table ([.432, .225, .183, .160, 0, 0, 0, 0, 0]), with probabilities expanded via ((k+1)/4)^(1/1.65) - (k/4)^(1/1.65), equivalent to three decimal places. Endless mode’s equal probability is simply a table filled with 1/9.
Deterministic randomness
LabRandom.ts is just 23 lines — a deterministic PRNG. The Daily Challenge uses it with the date as the seed: both the starting pool and the drop sequence come from the seed, identical for everyone on the same day. It also enables headless verification — tools/sim-check.cjs runs all 9 levels and 3 difficulties without a browser to check that level goals are reachable and the difficulty knobs really are harder. The bot strategy inside is dumb (drop next to a matching bubble if there is one, otherwise find the emptiest spot); the scores are relative metrics for cross-comparison only, not human-level play.
Saves, leaderboards and shipping
All local data goes through one exit, LabProfile.ts, with five localStorage keys: progress and unlocks, the top 10 of the four boards, continue records (up to 6, each with full soft-body point positions), settings, and deviceId. The old single-key save is automatically merged into one record on first read — nothing is lost. If the server is unavailable, it falls back to purely local boards and gameplay is unaffected.
The leaderboard service is a zero-dependency Node script (server/server.cjs, 291 lines) using only built-in modules, with data in a plain-text scores.json — backup is copying one file. It listens on loopback only by default; everything external goes through an nginx reverse proxy. At run start, POST /api/run issues a token; score submission validates it together with the elapsed time.
A shipping pitfall worth mentioning: a Cocos build contains two kinds of files — hashed-UUID files whose content never changes, and entry files like cc.js, config.json and index.js whose names are fixed but content changes every build. Give them immutable long caching and, after a release, returning visitors will load the old cc.js, request hash chunks that have been cleaned out, hit 404s, and the engine never starts — stuck on the loading page. So those directories use no-cache (revalidate with the origin first; nginx has ETags, so unchanged content returns 304), not immutable.
The loading page has two more safety nets: if the build-stamp.json version stamp doesn’t match the one in localStorage, entry files are refetched bypassing cache with fetch(url, { cache: 'reload' }) — in practice a plain fetch still returns the old content, no-store reads the new content but doesn’t write it back to cache (pointless), and only cache: 'reload' bypasses the cache and writes the fresh content back into the cache entry. As a last resort, if loading takes over 10 seconds, a “force refresh” button appears.
Tech stack
- Cocos Creator 3.8.8, TypeScript, ~3500 lines of game code
- All rendering drawn at runtime with Cocos
Graphics, no remote assets - UI font: HYPixel 11px U, a pixel-style Chinese bitmap font
- Leaderboard service: Node 18+, zero dependencies
- Deployment: nginx alias mounted at
/game/; the client derives API paths from the page path, so the same build works at the root or in a subdirectory without config changes

评论(0)
暂无评论