
我最早想做的是一座可以走进去的世界杯小城。
整座城建在一颗很小的球形星球上。玩家一直往前,会回到同一个路口。路还是那条路,但遇到的人、听见的声音和最后留下的东西,会因为选择发生变化。
这就是 《球的另一边 / The Other Half》。它是一段 5 到 8 分钟的 3D 网页故事,手机和电脑都能直接玩,不用登录,运行时也不调用 AI。完整源码放在 GitHub。
它和前一篇 Last Kick 都来自同一轮「用 Codex 做游戏」实验。Last Kick 把体验压到三脚球里,《球的另一边》保留了最初那个球形星球的方向。
先看正式演示
这次项目的正式演示发布在 B 站。
也可以直接在 B 站打开。
最初想做的东西
项目一开始就确定了几个边界。背景是世界杯期间的一座 cel-shaded 小城,整座城建在一颗球形星球上。玩家是临时信使,要完成三个短故事,找回闻伯的旧票根,陪不敢进场的小满做选择,再遇到一位想匿名散步的十号球员。
推进方式只有移动、靠近、对话、踢球、接球和交付物品。世界里不放悬浮任务箭头,结局在浏览器本地生成一张比赛日明信片。
参考作品是 Abeto 的浏览器游戏 Messenger。最打动我的是它的空间结构,玩家在小小的球形星球上一直向前,最后会回到原点。
我想保留这个感受,但不能直接复刻它的画面和内容。
项目也明确避开 FIFA、真实球星姓名、肖像、球队徽章和官方赛事视觉。运行时不接 AI,优先把 5 到 8 分钟的视觉、情绪和操作做完整。
第一版为什么不成立
第一版能打开,也能走,但没有形成一个可信的世界。
画面不够精致,之前生成的小女孩图片还留在首页,场景里随便贴了几张图,人物模型很奇怪,足球氛围也很弱。更严重的是,方向键映射反了,按左边,角色会往画面右边走。
补一点阴影解决不了这些问题。第三人称游戏里,玩家一直盯着角色背影,角色不可信,整座城就像临时 Demo。操作方向一旦错了,再漂亮的世界也不值得探索。开场没有介绍、热身、当前线索和镜头说明,玩家进来只会不知道该做什么。
当时最关键的纠正,是停止追着参考作品的构图走,开始做自己的足球故事。主角换成真正的 3D 男孩,世界重建成比赛日街区,足球成为唤醒小城的第一件事。
后面经历了七轮主要迭代。角色、世界、引导、镜头、本地化和发布分别重做,最后才把三个故事连成一整圈。
保留「走一圈回来」
《球的另一边》保留了球形星球,但换掉了主题和体验目标。
参考作品里的送信,变成比赛日前替人传递物件和心意。自由漫游,变成有一条主街、但没有悬浮任务箭头的轻叙事。安静的星球,变成会被第一脚球慢慢叫醒的比赛日小城。
回到原点也是结局条件。完成三段故事后,玩家还要走回最初的白色路口,明信片才会出现。

地面是一颗真正的球。街道、建筑、人物和交互物都沿球面法线放置。玩家移动时,代码重新计算脚下的局部上方、前进方向和画面右侧,再更新角色姿态与镜头。
这也解释了第一版的左右错误。第三人称镜头下,世界坐标的左,不等于玩家看到的左。最后把移动拆成两个可以单测的向量关系。
screenRight = normal × forward
heading = routeHeading around normal by cameraYaw
输入始终相对当前画面计算。按右就是屏幕右,不会因为角色走到星球背面,或者镜头转过一个角度,再次反向。
原创男孩的资产管线
角色是第一版最大的短板。后面的处理需要人工登录 Meshy、安装 Blender,再把 Meshy 导出的带骨骼角色和多个动作文件交给项目继续整理。
从平台下载 GLB 只是第一步。不同动作文件各带一套网格、骨骼和蒙皮,直接全部加载会重复模型,动画也可能找不到同一套骨骼。
最后在 Meshy 生成并自动绑定原创男孩,下载 Walk、Running、Casual Walk、Agree Gesture、Dance、Attack 等带蒙皮动作。Blender 脚本只保留一套网格和骨骼,把动作复制到同一份角色上,再清理空对象、无用材质槽和重复数据,统一高度与脚底原点。
Meshy 的 Idle 多次添加失败,最后在 Blender 里生成了轻微呼吸和重心变化的循环 Idle。
导出的 GLB 约 6.7 MB,包含八个可用动作,Idle、Walk、Jog、Stroll、Kick、Trick、Celebrate、Agree。移动状态之间会平滑过渡,踢球、点头、花活和庆祝播放一次后,再回到移动状态。
角色模型由 Meshy 为本项目生成,并在 Blender 整理。仓库按 CC BY 4.0 提供并明确署名,代码使用 MIT License。
没有任务箭头,也要教会操作
最初坚持「没有教程、没有指引」,结果玩家不知道怎么跑、怎么转镜头、怎么找动作,也不知道剧情从哪里开始。
完全自由只对已经理解规则的人自由。第一次打开时,它只是信息缺失。
最后仍然没有放悬浮任务箭头,但增加了三层轻引导。开场页讲清玩家身份和目标,四步热身依次教移动、跑动、自由镜头和第一次踢球,当前线索只说人物与地标,不在 3D 世界上画导航线。
每一步热身都要由玩家亲自完成,不会自动代打。这样既能让玩家知道下一步做什么,也没有把探索变成跟着箭头走。
三段故事和一个小状态机
故事没有任务系统、数据库和后端。src/story.ts 只维护几个明确状态,包括闻伯是否开口、票根是否找到,以及三个故事分别做了什么选择。
没和闻伯聊过时,风铃只是街边物件。闻伯说出旧票根以后,踢中风铃才会让半张票落下来。票根交回去,小满才愿意谈自己为什么不敢进场。小满做出选择后,匿名十号才会接住玩家的传球。
三段故事完成,再回到白色路口,才进入结局。

每段故事有两个选择,但不判对错。旧票根可以补上缺角,也可以保留原来的缺口。玩家可以陪小满慢慢进门,也可以和他坐在门外听完上半场。遇到十号时,可以给建议,也可以什么都不问,只在长椅旁留一个位置。
选择只改变人物回应和最后明信片上的句子,没有积分和成就。
声音随故事变化
项目最早要求 lo-fi 音乐,但最终没有下载一首来源不清楚的背景音乐循环播放。
src/audio.ts 用 Web Audio API 在浏览器里实时生成声音。音乐是 74 BPM、32 小节的结构,调性在 D 大调和 B 小调之间移动。和弦、贝斯、鼓、旋律和磁带噪声分别使用独立通道。
完成故事后,编曲会逐步增加层次。踢球、风铃、第一次唤醒小城和对话确认也有各自的短音效。
浏览器只在玩家点击「进入比赛日」后创建 AudioContext,以适配 iOS 和 Safari 的自动播放限制。标签页恢复,或用户再次点击静音按钮时,代码也会唤醒被浏览器暂停的声音上下文。
这套方案许可清楚,没有音频请求,也能让音乐跟故事同步。代价是合成音色不如真人录音自然,不同设备的扬声器表现也会有差异。
手机端单独适配

桌面使用键盘、拖拽镜头和右上角控制面板。镜头提供跟随、近景、广角和回到角色身后的复位控制。手机使用左侧摇杆、跑动和踢球按钮,拖动画面仍然可以自由环视。
桌面设备像素比最高 1.6,手机最高 1.25。手机关闭动态阴影,并允许移除装饰性墨线轮廓。城市主体使用低面数几何体和共享 MeshToonMaterial,重复颜色复用材质。天空、店面和人物肖像统一转换成 WebP,prefers-reduced-motion 会减少界面动画。
角色只加载一份合并后的 GLB,不为每个动作重复下载模型。
当前最大的加载成本仍然是约 6.7 MB 的角色 GLB。主 JavaScript 构建后约 685 KB,gzip 后约 186 KB。下一版应该先做 Draco 或 Meshopt 模型压缩,并拆分 Three.js 主包,暂时不再增加场景物件。
中英文和两个域名
有一版首页加了中英文按钮,但英文模式仍然能看到中文标题、对话或无障碍标签。这不能算完整本地化。
正式版本第一次访问会读取 navigator.languages[0]。zh-* 使用中文,其余语言使用英文。?lang=zh 和 ?lang=en 可以覆盖默认语言,手动切换后会写入 localStorage。
英文模式的可见界面、对话、线索、元数据、标题和无障碍标签都使用英文。首次本地化完成前会隐藏页面,避免先闪出错误语言。
部署没有复制两份网站。一个 Cloudflare Worker 同时服务两个自定义域名。
两个域名都直接返回同一份静态资源,不做 301 跳转,所以地址栏会保留玩家访问时使用的域名。
明信片是这段路的结尾
走回原点以后,如果只显示「任务完成」,前面几分钟的选择就突然变成了打卡。
结局用 Canvas 把当前 3D 画面、三次选择和日期合成一张比赛日明信片。同一座城会根据玩家如何对待三个人,留下不同的三句话。

支持系统分享的设备会尝试分享图片,其他环境可以保存图片或再次走一圈。生成和分享都在浏览器本地完成,不上传玩家数据。
测试、构建和上线
3D 世界使用 Three.js。页面、引导、语言、故事状态和明信片由 TypeScript 与原生 DOM 实现,Vite 负责构建,Web Audio API 负责音乐和音效。
公开源码目前有 2 个 Vitest 文件、7 个测试,覆盖故事状态、左右移动和镜头角度。Playwright 旅程测试会在真实 Chromium 中完成浏览器语言识别、镜头旋转、跑动、四步热身、三段故事、回到原点和明信片,并再跑一遍手机视口。
原项目复盘记录,发布前运行了单元测试、生产构建和 Cloudflare dry run。部署完成后,两个域名都返回 HTTP 200,不重定向,并加载同一份构建资源。
本地运行可以执行。
git clone https://github.com/makerjackie/the-other-half.git
cd the-other-half
npm install
npm run dev
发布前使用的命令是。
npm test
npm run build
npm run deploy:dry
npm run deploy
项目里曾经有浏览器自动录屏脚本,但最终发布视频改为手动录制并上传 B 站。公开仓库排除了录屏脚本、临时输出、设计审查图和内部笔记,只保留产品代码、可复现测试和角色处理脚本。
AI 没有在运行时扮演 NPC,也没有为玩家临时写剧情。它参与的是需求整理、Three.js 球面坐标、镜头、动画混合、故事状态、Web Audio、Blender 脚本、测试、部署和多轮修正。
一轮轮修正最费时间。每次都要把「方向反了」「人物很怪」「不知道下一步」「镜头不能转」「英文里还有中文」变成具体的实现和验收条件。
如果只看最终代码,很容易把它概括成一个 Three.js 球形世界。真正让项目成立的,是几个更普通的判断。参考作品只能拆出体验原理,不能照着画面复刻。第三人称角色需要完整资产管线。操作错误要先修坐标模型。没有箭头可以,但不能没有开场、热身和线索。本地化也要放进真实浏览器里验收。
在线走进《球的另一边》,或者查看 GitHub 源码。

Comments
Comments appear after approval.