用 Codex 做游戏:三脚点球和一颗球形星球

我用 Codex 做了一款只有三脚射门的 3D 点球,和一段 5 到 8 分钟的球形星球网页故事。这篇复盘两个作品从无聊第一版到能玩能分享的过程。

2026年7月11日 · 更新于 2026年7月11日 · Jackie

Last Kick 的 120 分钟生死球开场

GPT-5.6 出来后,我最想做的不是再写一个聊天框,也不是给网站接一个 API。我想看看它能不能做出一个真的让人有感觉、愿意录下来发出去的网页作品。

那一轮我把脑洞收成了两个游戏。

一个是 Last Kick,三脚球、一堵墙、一个会记住你的门将,把体验压进几十秒里。另一个是 《球的另一边 / The Other Half》,一颗球形星球上 5 到 8 分钟的比赛日故事。

两个项目定了同一条边界:AI 只参与开发,玩家打开网页后不调用模型,也不需要输入文字、上传图片或录制语音。3D 场景、判定、动作、音效和结果图都在浏览器本地完成。

先玩再看

游戏类型最值得看的地方
Last Kick3D 点球三脚球、会记住你的门将、镜头、声音和进球反馈
球的另一边3D 治愈故事球形星球、自由漫游、角色动画和三段小故事
BlockWar 方块战争多人实时战略类似 generals.io 的占领玩法、房间、同步和重连
01MVP Games联机桌游合集多人房间、回合状态和一组可以直接玩的桌游
狂扁笨笨横版街机动作向经典清版动作游戏致敬,处理移动、攻击、敌人和碰撞
愤怒的笨笨物理投射蓄力、弹道和关卡反馈
笨笨弹弹休闲弹射抛物线、命中反馈和轻量关卡
笨笨大冒险平台闯关移动、跳跃、敌人和关卡节奏

下面把前面两个展开写。

一、Last Kick:三脚球和会记住你的门将

手机和电脑都能直接玩,不需要登录。完整源码放在 GitHub,使用 MIT License。

先看完整演示

视频保留了开场、蓄力、三种射法、门将扑救、进球反馈和最后的挑战结果。

如果播放器没有正常显示,可以直接去 B 站观看

不接运行时 AI

项目很早就定下了一条边界。AI 只参与开发,玩家打开网页后不会调用模型,也不需要输入文字、上传图片或录制语音。

3D 场景、射门判定、门将动作、音效和分享图都在浏览器本地完成。没有服务端推理和按次计费,模型接口出问题也不会让足球停在半空中。

我不想为了证明它是 AI 做的,硬塞一个聊天框进去。AI 负责创意、代码、测试、素材判断和迭代,浏览器负责把作品跑起来。

第一版能踢,但很无聊

第一版很快就出来了。有球门,有足球,按住、拖动、松手就能射门,门将也会扑。

但我的反馈很直接。

哎呀,我有点难过,因为你这个很无聊,这操作起来没有任何感受啊,没有任何让人觉得很兴奋的点,这个创意也不太行。

技术上能跑只过了最低标准。玩家松手的那一刻,镜头、声音、运动、等待和结果要一起给出反馈。

能射门,不等于还想再射一脚。

后面的迭代停止增加球员、地图和比赛,把问题缩小成一次拖拽射门,能不能在十几秒内让人紧张起来。

不做粗糙的小人

我很担心模型做出一批粗糙的拟真人物。脸和动作都不可信,最后只会像一个临时 3D Demo。

所以门将改用切面几何体,观众也不再是一个个人。看台由上万张折叠卡片组成,平时像一圈压住情绪的纸片。进球后,它们一起翻成珊瑚红,再像机械波一样向外散开。

项目还试过发光信号柱和纸鸟群。静帧都能看,但真正踢起来,折叠卡片最接近「一万人同时起身」的感觉,所以最后保留了它。

由程序生成的球场、门将和折叠观众

整个场景由程序生成,没有外部人物模型。Three.js 在这里不只负责画出 3D 物体,镜头、灯光、雨、球网和看台都可以围着射门的瞬间一起变化。

Vozinha 和门将记忆

球门对面如果只是一个无名 NPC,玩家很难在意他扑不扑得到。

我当时想到佛得角门将 Vozinha。项目把开场设在加时赛第 120 分钟,比分 1 比 1。玩家只有三脚,Vozinha 站在门前,而且会记住上一脚。

代码会保存上一脚的方向和射法。如果继续用同一种射法打同一侧,门将就会提前向那边移动。换边,或者改踢弧线和勺子,球门才会重新打开。

代码真的会执行这条规则,不是等到结果页再随机写一句「门将看穿了你」。不到一分钟的小游戏也因此有了一个对手。

三种射法不用随机判定

游戏一度太容易,随手一拖就能进。第一次可能挺爽,第二次就没意思了。

我也不想在后台掷骰子。玩家明明踢得一样,这次进、下次不进,只会觉得游戏在耍赖。

最后用了确定性判定,每种射法都有自己的力量和落点窗口。

射法有效方式常见失败
爆射力量至少 64%,压向左下或右下力量不足,或者打得太正
弧线力量至少 52%,瞄准更宽的远角弧度不够,进入门将覆盖区
勺子力量控制在 45% 到 78%,走中路太轻被没收,太重撞横梁

蓄力时,瞄准区和力量条会提示偏轻、合适或过量。射丢后会说明具体原因,包括力量不够、位置不对、勺子过头,或者重复套路被门将读到。

用于调参的脚本把新手输入建成一个模拟模型,算出的单脚进球率约为 13.9%,三脚只进零到一个球的概率约为 94.7%。这不是玩家统计,只是为了避免完全凭感觉调难度。

难,但失败原因要看得懂。这样射丢以后,下一脚还有可以调整的地方。

出脚前后的两秒

后面一轮改动,几乎都集中在出脚前后的两秒钟。

按住足球后,镜头慢慢推近,环境声收窄,心跳随力量加快。进入有效区间时,瞄准和力量反馈改变颜色。松手的一瞬间短暂静音,镜头再切到飞行路径。

进球后,球网变形,灯光和曝光抬起,看台翻色,镜头震动。真实球场录音叠在 Web Audio 合成的击球、风切和低频上。

弧线球入网后的灯光、球网和看台反馈

失败也有不同反馈。扑救是手套撞击和人群叹息,门柱是金属声和回弹,撞横梁的节奏更干脆。

雨声最早使用合成噪声,太吵,听起来更像坏掉的收音机。后来换成本地托管的 CC0 轻雨录音,并把音量压到只剩环境底色。进球欢呼和扑救后的失望声也使用了本地 CC0 素材,来源和处理方式保留在源码仓库里。

开场从教程改成比赛

有一版开场把三步操作写得很大,选择射法、按住足球、松手射门。

信息没错,但看起来像产品说明书。放进短视频以后,观众第一秒先看见了教程,比赛反而被压在后面。

后来开场改成转播画面,先出现 120 分钟、1 比 1 和剩余三脚,再给一句很短的挑战。操作提示放回球旁边,等玩家真的按下去时再出现。

网站没有变成一个更复杂的游戏,只是把玩家进入它的方式改对了。

分享的是这一局的结果

最早的分享按钮只会打开系统面板。能分享,但用户看不到自己发出去的东西。

后来每局结束,浏览器会生成一张 1080 × 1920 的竖版战绩图。图里有最后一脚的真实 WebGL 画面、进球数、射法、力量、称号和 lastkick.01mvp.com

支持文件分享的手机会带着图片打开系统分享面板。其他浏览器会保存 PNG,并复制挑战链接。

Last Kick 在浏览器里生成的竖版挑战战绩图

自动录制也翻过车

为了自动制作横版和竖版视频,我最早让脚本连续抓取浏览器截图,再交给 FFmpeg 合成视频。

文件信息看起来正常,1080p、30fps,码率也不低。真正播放起来却发糊、卡顿。

检查时间轴后才发现,活动画面实际只抓到了大约 17 到 20 帧。编码器补成 30fps,只是在重复已有画面。低分辨率 WebGL 画布再被放大,边缘和文字也会变软。

最后发布的视频由我直接录制,没有继续使用不合格的自动成片。以后要重做自动录制,可以用 ScreenCaptureKit 这类原生窗口录制连续画面,或者用固定时间步逐帧渲染。验收也不能只看分辨率和 fps 标签,还要检查重复帧、帧间隔和真实画布尺寸。

技术、测试和上线边界

页面和交互使用 React 19、TypeScript。3D 场景使用 React Three Fiber 和 Three.js,Zustand 管理三脚挑战、上一脚记忆和阶段切换,Web Audio 负责声音,Canvas 生成分享图。

游戏的正式站点部署在 lastkick.01mvp.com,静态资源和音频由 Cloudflare Workers Static Assets 提供。运行时没有数据库、登录和 AI 接口。

公开源码包含一个难度校准脚本,用来验证三种射法的有效窗口、重复套路判定和新手输入模型。相同输入会连续运行 100 次,确认结果不依赖随机数。仓库还保留了 TypeScript 构建、Cloudflare dry run、部署命令、音频来源和 MIT License。

本地运行可以执行。

git clone https://github.com/makerjackie/lastkick.git
cd lastkick
pnpm install
pnpm dev

部署前使用的命令是。

pnpm test:difficulty
pnpm build
pnpm deploy:dry-run
pnpm deploy

一条完整 Prompt 没有直接生成成品。真正影响结果的是那些很具体的反馈,第一版很无聊,观众不要做成粗糙小人,雨声太吵,射门太容易,失败原因看不懂,分享前看不到图片,视频只有参数像高清。

这些反馈没有指定函数和文件,而是说清了哪里不对,以及最后要验收什么。

Last Kick 最早只是一次 GPT-5.6 能力测试。最后留下来的,是一个更普通的开发过程。先允许第一版很差,再把最大的体验问题一件件修掉。

二、《球的另一边》:一颗球形星球

手机和电脑都能直接玩,不用登录。完整源码放在 GitHub

先看正式演示

这次项目的正式演示发布在 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,包含八个可用动作,IdleWalkJogStrollKickTrickCelebrateAgree。移动状态之间会平滑过渡,踢球、点头、花活和庆祝播放一次后,再回到移动状态。

角色模型由 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 的自动播放限制。标签页恢复,或用户再次点击静音按钮时,代码也会唤醒被浏览器暂停的声音上下文。

这套方案许可清楚,没有音频请求,也能让音乐跟故事同步。代价是合成音色不如真人录音自然,不同设备的扬声器表现也会有差异。

手机端单独适配

390 × 844 手机视口下的开场、语言切换和触屏入口

桌面使用键盘、拖拽镜头和右上角控制面板。镜头提供跟随、近景、广角和回到角色身后的复位控制。手机使用左侧摇杆、跑动和踢球按钮,拖动画面仍然可以自由环视。

桌面设备像素比最高 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 球形世界。真正让项目成立的,是几个更普通的判断。参考作品只能拆出体验原理,不能照着画面复刻。第三人称角色需要完整资产管线。操作错误要先修坐标模型。没有箭头可以,但不能没有开场、热身和线索。本地化也要放进真实浏览器里验收。

这些小实验真正证明了什么

AI 没有让游戏开发变得没有门槛,但它把「我不会做游戏」变成了「我先做一个很小的版本试试」。

回头看,两个项目里真正影响结果的都不是某一句 Prompt,而是那些很具体的反馈。第一版很无聊,不要做成粗糙小人,雨声太吵,射门太容易,方向反了,不知道下一步做什么,英文里还有中文。

这些反馈没有指定函数和文件,只是说清了哪里不对、最后要验收什么。AI 能把执行接过去,但它不会自动知道什么叫好玩,也不知道一段球形星球上的散步要怎样让人不迷路。你仍然要试玩、否定、收口。

一个小实验不需要账号、付费、运营和长期内容。它只要让一个核心玩法成立,让别人打开后愿意多玩一次,就已经完成任务。如果其中某个玩法真的有人喜欢,再把它升级成完整产品。

发表评论

登录后即可参与评论。

去登录