两天复刻微信跳一跳:Unity WebGL 上线与全球排行榜全记录
在线试玩:jump.outsider-studio.cloud(手机竖屏体验最佳)。
上线第一天就有真实玩家打榜,当前榜首 58 分。这篇文章记录从「装 Unity」到「全球排行榜上线」的完整过程,重点不是玩法复刻,而是只有走完整条链路才会踩到的坑。
技术栈一览
- 客户端:Unity 6000.3 LTS + URP,WebGL 构建,Brotli 压缩
- 排行榜:Node 22 单文件 API(零依赖,
node:sqlite内置库),systemd 常驻 - 部署:腾讯云轻量服务器,nginx + certbot(Let’s Encrypt),rsync 推静态产物
- 开发方式:Unity CLI + MCP,AI Agent 在活编辑器里毫秒级执行 C#(eval)
Day 1:从 0 到可玩
单文件起步,运行时生成一切
第一个可玩版本只有一个 JumpGame.cs(约 330 行):蓄力、抛物线跳、落点判定、平台生成、相机跟随、连击计分、结束重开。所有视觉(棋子、平台、天空盒)都是运行时 CreatePrimitive 生成的,零美术资产。
这个选择后来被证明有利有弊:迭代极快,但也埋下了「着色器剥离」的坑(后文)。
AI 闭环调试法
整个开发没有依赖人工点鼠标测试。脚本内置 #if UNITY_EDITOR 调试钩子:
#if UNITY_EDITOR
public float DebugCenterPower; // 指定蓄力时长
public float DebugJumpPower; // 直接以某力度起跳
public string DebugState; // 当前游戏状态
public void DebugRestart() {} // 重开
#endifAgent 通过 MCP 在活编辑器里 eval 这些钩子驱动游戏流程,配合 ScreenCapture.CaptureScreenshot 截图自检:正中落点 +2、连击 +4、蓄力过头坠落、结束面板弹出,全部自动验证。
一个反直觉的坑:eval 往返延迟 0.3-0.9 秒,像边缘摇晃(0.55s)这种快速事件,用 sleep 轮询一定会错过。要么后台并发发指令 + 定时截图,要么把截图调用精确卡在事件窗口内。
打磨:v0.3 美术重做与 v0.4 手感包
玩法跑通后进入反馈驱动的打磨期,两个版本修掉的问题按价值排序:
平台 y 坐标每代累积漂移(最恶性)。NextPlatformPos 拿盒体中心当逻辑中心,每代平台累积 -0.3 偏移,5 代之后平台沉到地板下面,表现为「跳着跳着没格子了」。修复是一行:from.y 强制归零。这类 bug 在短局测试中完全不可见,必须 8 连跳以上的压测才暴露。
美术与手感:棋子三层造型、平台双色盖板、程序化天空盒、落地回弹、飘分金字、白圈扩散、全合成音效(蓄力升调/落地叮/坠落)、边缘摇晃状态(落点超出平台面但在容差带内,棋子绕水平轴翻倒 85° 再坠落,不再直愣愣掉下去)、落地尘埃粒子。
平台预生成:从「当前+下一格」改为「当前+前方 3 格+身后 2 拖尾」,相机拉远,屏幕内始终可见前方 2-3 格——这是原版跳一跳「看得见下一个」手感的来源。
音频内存泄漏:音效 clip 每次落地新建,长跑必漏。预生成 4 个复用;BGM 用 C-E-G-C 四正弦叠加做和弦垫,sin 包络首尾归零实现无缝循环,整个音频系统零音频文件。
WebGL 三道坎
Web 发布是把「编辑器里好好的」变成「浏览器里能跑」的过程,三道坎每一道都是产物级问题。
坎一:洋红画面(着色器剥离)
构建出来的 WebGL 打开全屏洋红——Unity 的「缺 shader」警告色。根因:全部材质是运行时 Shader.Find("Universal Render Pipeline/Lit") 创建的,场景里没有任何资产引用这个 shader,打包时剥离器直接把它裁了。编辑器里正常,构建里全灭。
修法:Resources 强制引用——新建 Assets/Resources/RefURPLit.mat(外加透明变体),Resources 目录资产必进构建,shader 变体随之保留。代码零改动。
验证方法升级:不看加载进度条,直接 brotli 解压 WebGL.data.br grep shader 名。进度条动了不代表渲染对了,产物里有才算数。
坎二:中文全部消失
记分板上的中文在 WebGL 里一个不显示,只剩 ASCII 数字。根因:Unity 内置 LegacyRuntime.ttf(Liberation Sans)没有 CJK 字形;编辑器里能显示是因为走了 OS 字体回退(PingFang),而 WebGL wasm 没有 OS 字体 API。
修法:Noto Sans SC(OFL 协议可嵌入)用 fontTools 子集化——UI 全部汉字约 40 个 + ASCII,16MB 砍到 85KB,放 Assets/Resources/Fonts/ 强制进构建。
经验:Unity WebGL 任何非 ASCII 文本都必须自带字体文件,不能指望系统回退;子集化是中文字体的标配做法,全量 CJK 字体 10MB+ 不可接受。
坎三:多设备适配
复查「手机/电脑/iPad 都能玩吗」发现两个硬伤:
- 触屏输入缺失:新 Input System 下手机触摸走
Touchscreen.current,Mouse.current完全收不到事件。补 Touchscreen 分支,编辑器无触摸设备时安全回退鼠标。 - iPad 走错分支:iPadOS 13+ Safari 默认伪装 Mac(UA=Macintosh),官方模板的
iPhone|iPad正则漏判,iPad 被塞了 960x600 桌面固定画布。修法:MacIntel + maxTouchPoints>1补判,另加Mobile通配 Android 微信内置浏览器。7 组设备 UA×触控矩阵单测全过。
模板改动要持久化:直接改 Builds/ 产物会在下次打包被覆盖。官方模板拷入 Assets/WebGLTemplates/Jump,在模板里改(另加 DPR 封顶 2——iPhone 3x 全分辨率渲染,手机 GPU 扛不住)。
排行榜:一个零依赖的 Node 后端
排行榜需求被刻意压到最小:游客制(无注册无登录)、只升不降、全球前 20。
架构同样压到最小:
- 身份:
PlayerPrefs存 UUID + 自动昵称「游客XXXX」 - API:Node 22 单文件,零 npm 依赖,数据库用
node:sqlite内置库 - 端点:
POST /api/score(只升不降 upsert + 返回名次)、GET /api/top、/api/me、/api/health - 防刷:玩具级——分数上限校验 + IP 60 次/分钟限流
- 客户端:结束自动上报,面板显示「全球第 N 名」;网络失败静默降级,不影响游戏
// 只升不降的核心就一条 SQL 的事前判断
const row = db.prepare('SELECT best FROM scores WHERE pid = ?').get(pid);
if (row && score <= row.best) { /* 不更新,只返回现有名次 */ }开发期一个值得说的设计是 ApiBase 双轨制:编辑器里走 http://127.0.0.1:8787 本地联调,构建产物走正式域名常量——部署时改一处,不用动逻辑。验证方式是编辑器 Play 模式真实跑一局:得分 2 → 上报 → 面板显示「全球第 1 名」,再 curl 构造第二个玩家验证排序和只升不降。
上线:nginx、certbot 与一个权限坑
原计划用 Caddy(自动证书省事),动手前查了眼服务器现状:80/443 早已被 nginx 1.24 + certbot 占用,上面跑着博客、NewAPI、Sub2API、Mihomo 四个服务。方案当场作废,改为与现有约定一致:
- DNS 加 A 记录。必须灰云(DNS only)——橙云(CF 代理)会破坏 certbot 的直连验证,这台服务器两周前刚从 CF 代理切到 Let’s Encrypt 就是为了让国内用户直连。
conf.d/jump.conf写两个 server block:jump 静态托管/opt/jump-web,jump-api 反代127.0.0.1:8787。certbot --nginx --redirect一把申请证书并配好 80→443 跳转。- 游戏产物
rsync Builds/WebGL → /opt/jump-web。rsync 后文件权限是 600,nginx 读不了直接 403,必须chmod -R a+rX——这次部署唯一卡住的点,报错和原因隔了一层,值得记住。 - WebGL 的
.br文件必须配Content-Encoding: br,.wasm配application/wasm,/Build/目录 immutable 缓存 +index.htmlno-cache。
验证全在服务器侧做:本机开着代理,dig 返回的是 fake-ip(198.18.x.x),DNS 测试会被污染。产物级复核:解压 WebGL.data.br 确认里面嵌的是 jump-api. 正式域名而不是 127.0.0.1。
还有一个缓存隐患顺手修了:构建开 nameFilesAsHashes,产物文件名带哈希。否则 /Build/ 是 immutable 缓存,重新部署后老用户永远拿旧文件。哈希文件名 + index.html no-cache 才是正确组合。
真机三连修:模拟器测不到的东西
上线当晚用户真机试玩,报回三个问题——每一个都是「编辑器联调时测不出来」的。
榜按钮点不开,点按钮还会起跳(双层 bug)。第一层:Canvas 没加 GraphicRaycaster、场景没建 EventSystem,按钮的 onClick 从未被触发过——之前联调是 eval 直接调函数,绕过了整条事件链,所以一直没暴露。第二层:游戏输入直接轮询鼠标,不做 UI 遮挡判断,点按钮的同一次按下也开始蓄力。修法是机制性的:建 GraphicRaycaster + EventSystem(新输入系统下必须用 InputSystemUIInputModule),全屏透明 InputCatcher 层压 UI 最底——uGUI pointer 事件只发最上层命中对象,点按钮时事件天然落不到游戏层。
这次的教训升级成规则:UI 功能的验证必须走真实事件链。ExecuteEvents 触发点击 + RaycastAll 验证命中几何 + 反射查内部状态。「直接调函数验证」等于没测点击。
棋子「没贴合平台」。数值验证先跑:5 次连续落地,棋子底盘 bounds 与平台顶面 gap = -0.031(平台下沉动画让棋子嵌进去 3mm),数学上根本不存在「飘」。真正的元凶是 50° 灯光把影子平推了 0.9 个身位——影子离体,看起来像悬空。修:灯光仰角 50°→62°,影子缩短三分之一。视觉「穿帮」类问题先数值验证再动美术,这次差点为不存在的浮空改几何。
移动端 UI 整体偏小。竖屏 CanvasScaler 参考分辨率从 1920x1080 改成 1080x1920(此前手机 UI 被整体缩到 0.61 倍),榜单面板、字号、按钮全部重新给尺寸。桌面横屏分支不动。
移动端性能降载
手机浏览器卡顿的根因判断:填充率和阴影。场景全动态,阴影贴图每帧整张重绘;竖屏 DPR 2 下约 130 万像素过 URP;模板默认 HDR + 后处理 + MSAA 全开。
降载清单(#if UNITY_WEBGL && !UNITY_EDITOR):阴影分辨率 Low、关 HDR(帧缓冲带宽减半)、关相机后处理、MSAA x1、pixelLightCount=1、shadowDistance 40→20、级联 2→1(视野就 20 个单位,无损)、DPR 上限 2→1.5(少渲 44% 像素,平涂低多边形风格锐度损失无感)。
顺手加了个诊断工具:URL 带 ?fps=1 显示实时帧率。排查性能问题让用户报数字,而不是「卡」。
经验清单
- WebGL 验证必须产物级。加载进度条动 ≠ 渲染对。解压构建产物 grep 关键资产(shader、字体、API 域名),一抓一个准。
- UI 验证必须走真实事件链。调函数等于没测点击。
- 视觉问题先数值验证。bounds 算清楚再决定动美术还是动灯光。
- 中文字体子集化是标配,WebGL 无系统字体回退。
- rsync 之后先 chmod,nginx 403 不一定是配置问题。
- immutable 缓存配哈希文件名,否则老用户永远拿旧包。
- AI 闭环调试(eval 钩子 + 截图自检)对游戏这类「要手感」的工程同样成立,但快速事件要卡住时序窗口。
后续
短期:排行榜加日榜/周榜,平台皮肤再扩几种。中期:公众号「阅读原文」挂跳一跳引流。这局游戏本身是小品,但它把「Unity 工程 → WebGL 构建 → 后端 API → 域名证书 → 真机打磨」整条链路跑通了一遍——下次再做大的,每一步都是熟路。
本项目全程由 AI Agent(Claude Code)在 Unity 编辑器内直接驱动开发与调试,包括本文提到的全部修复。