1. 从一句提示词到可玩原型:冰球游戏生成的核心逻辑
第一次看到"Codex 一次生成冰球游戏"这个说法,我本能地是怀疑的。原因很简单:冰球游戏虽然规则不复杂,但它同时涉及物理碰撞、实时输入响应、计分逻辑、AI 对手行为、渲染循环这几块内容,任何一块处理不好,出来的东西都只是个"能动的方块",根本谈不上"游戏"。但实际动手试过之后,我发现这件事确实成立,前提是你得理解它到底在生成什么、以及你该怎么描述需求。
所谓"一次生成",并不是说模型凭空变出一个商业级游戏,而是指用一段结构清晰的提示词,让 Codex 这类代码生成模型直接产出一个可运行的最小可玩原型(MVP)。这个原型通常是一个单文件或两三个文件的 HTML + JavaScript 项目,打开浏览器就能玩:球在冰面上滑动、球杆能击球、球撞墙反弹、进球后比分更新。它不追求美术和音效,追求的是"逻辑闭环"。
这件事的价值在哪?我总结下来有三点。第一,它把"从想法到能跑的东西"这个过程的成本压到了极低,你不需要先搭框架、配环境、查 API,直接拿到一份能跑的代码,再在上面改。第二,它是学习游戏循环和物理模拟的绝佳样本,因为代码量小、结构清晰,你能一眼看懂每一帧发生了什么。第三,它适合做快速验证,比如你想测试某个击球手感、某个 AI 难度曲线,用生成的原型改参数比从零写快得多。
适合读这篇内容的人,我大致分三类:一是刚接触前端或游戏开发、想找个完整小项目练手的新手;二是已经会写代码、但没做过实时交互类程序、想理解游戏循环的开发者;三是想用代码生成工具提效、但不知道怎么把需求描述清楚的老手。这三类人关注的点不一样,我会在后面的章节里分别照顾到。
需要先说明一点:本文不会贴出某一次具体的生成结果原文,因为每次生成的代码细节都会不同,贴死代码反而误导。我更想讲清楚的是这类项目背后的通用结构、关键参数的取舍逻辑、以及实测中真正会踩的坑。你拿着这套思路,无论用哪个代码生成工具,都能得到差不多的结果。
2. 冰球游戏的物理内核:为什么碰撞处理决定了成败
2.1 冰球运动的简化模型与真实物理的差距
冰球在真实冰面上的运动,涉及摩擦系数、空气阻力、旋转、球杆形变等一堆因素。但游戏原型里,我们几乎从不做真实物理,而是做足够像的近似。这个"足够像"的边界在哪,是决定游戏手感的第一道关卡。
最基础的模型是:球有位置 (x, y) 和速度 (vx, vy),每一帧更新x += vx * dt、y += vy * dt,碰到边界就把对应方向的速度取反。这个模型简单到十行代码就能写完,但它有个明显问题——球永远不会停。真实冰球会因为摩擦慢慢减速,游戏里如果球一直匀速飞,玩起来会很飘,击球后球满场乱窜,体验很差。
所以第二步要加阻尼(damping)。常见做法是每帧给速度乘一个略小于 1 的系数,比如vx *= 0.995。这个 0.995 不是随便定的,它和帧率强相关。如果游戏跑在 60 帧每秒,那每秒速度衰减到0.995^60 ≈ 0.74,也就是一秒后还剩七成多速度,手感偏"滑";如果改成 0.99,一秒后只剩0.99^60 ≈ 0.55,球会明显更快停下来,手感偏"黏"。我实测下来,冰球这种题材用 0.992 到 0.996 之间比较舒服,具体看你想让节奏快还是慢。
这里有个新手常犯的错误:把阻尼写成"每帧减一个固定值",比如vx -= 0.1。这样做的后果是速度会线性衰减到零然后停住,看起来还行,但一旦速度方向改变或者速度很小的时候,会出现抖动甚至反向。正确做法永远是乘性衰减,保证速度趋近于零但不会穿越零。
2.2 碰撞检测:圆形近似与边界反弹的取舍
冰球本身是个扁圆柱,但在俯视视角下,它就是个圆。球杆、球门、场地边界,全都可以用圆和矩形来近似。这个近似带来的最大好处是碰撞检测极其简单。
球和边界的碰撞,本质是判断球的圆心加上半径有没有超出场地范围。假设场地是[0, W] × [0, H],球半径是 r,那么:
- 当
x - r < 0时,说明球撞到左墙,令x = r、vx = -vx; - 当
x + r > W时,撞到右墙,令x = W - r、vx = -vx; - 上下墙同理。
注意这里有个细节:先把球的位置修正回边界内,再反转速度。如果只反转速度不修正位置,球可能卡在墙里,下一帧继续触发碰撞,速度反复反转,表现为剧烈抖动。这个坑我在早期项目里踩过不止一次,排查了半天才发现是位置没归位。
球和球杆的碰撞稍微复杂一点,因为球杆是移动的。最简做法是把球杆也当成一个圆,判断两圆圆心距离是否小于两半径之和。如果小于,就计算碰撞法线(两圆心连线方向),然后沿法线方向给球一个冲量。冲量大小可以简单地取球杆当前速度在法线方向的投影,这样"挥得越快、球飞得越快",手感自然。
但这里有个隐藏问题:如果球杆移动很快,一帧内可能从球的一侧直接穿到另一侧,导致"穿透"——检测不到碰撞。解决办法有两种:一是限制球杆最大速度,二是做连续碰撞检测,把这一帧的移动路径当成线段,判断线段和圆是否相交。原型阶段我一般用第一种,简单可靠;如果要做精细手感,再上第二种。
2.3 帧率无关:dt 的正确用法
上面所有更新都写成x += vx * dt而不是x += vx,这个dt是"距上一帧经过的时间",单位通常是秒。为什么必须乘 dt?因为不同设备刷新率不同,有的 60Hz、有的 120Hz、有的 144Hz。如果不乘 dt,120Hz 设备上球的速度会是 60Hz 的两倍,游戏直接失衡。
dt 的获取方式,在浏览器里用requestAnimationFrame回调自带的时间戳最准:
let lastTime = 0; function loop(timestamp) { const dt = (timestamp - lastTime) / 1000; lastTime = timestamp; update(dt); render(); requestAnimationFrame(loop); }这里有个必须处理的边界情况:切标签页回来时 dt 会异常大。比如你切走十秒再切回来,第一帧的 dt 可能是 10 秒,球会瞬间飞出屏幕。标准做法是给 dt 设上限,比如dt = Math.min(dt, 0.05),超过 50 毫秒就按 50 毫秒算。这一行代码能救掉无数诡异 bug,强烈建议每个做实时交互的人都记住。
3. 让 AI 对手"像个人":行为逻辑的设计与调参
3.1 从"追球机器"到"有节奏的对手"
最简单的 AI 就是让球杆永远朝球的方向移动,球在哪它追到哪。这个逻辑三行代码就能写完,但玩起来极其无聊——AI 要么永远打不到球(速度不够),要么永远能打到球(速度够快且无延迟),没有中间状态。
要让 AI"像个人",核心是引入反应延迟和预判误差。具体做法是:AI 不是每帧都读取球的真实位置,而是维护一个"它以为的球位置",这个位置每隔一段时间(比如 100 到 200 毫秒)才更新一次,更新时还叠加一点随机偏移。这样 AI 的移动会带一点点滞后和偏差,玩家就能通过变向、假动作骗过它。
这个"更新间隔"是调节难度的关键旋钮。间隔越小,AI 反应越快、越难打;间隔越大,AI 越迟钝。我一般把简单难度设成 250 毫秒、中等 150 毫秒、困难 80 毫秒。80 毫秒已经接近人类顶尖反应,再低就不像人了。
3.2 防守与进攻的权重分配
只会追球的 AI 有个致命问题:它永远在球后面,一旦球到了它身后,它就彻底失位。真实玩家会做的是"回防"——当球在自己半场时,站在球和自家球门之间;当球在对方半场时,才主动出击。
实现这个逻辑,需要给 AI 定义一个"目标位置",而不是直接追球。目标位置的计算可以这样:
- 如果球在 AI 的防守半场,目标位置 = 球位置和自家球门位置的中点,偏向球门一侧;
- 如果球在进攻半场,目标位置 = 球位置稍微靠前一点,准备击球。
这样 AI 就有了"站位"的概念,看起来会聪明很多。权重怎么分,取决于你想让 AI 偏保守还是偏激进。偏保守就把防守权重调高,AI 更多时间待在后场;偏激进就调低,AI 会压上进攻。这个参数没有标准答案,得靠实际对战感受来调。
3.3 击球时机的判断
AI 追到球附近之后,什么时候"挥杆"也是个学问。如果一碰到球就击打,AI 会显得很机械;如果加一点判断,比如"只有当球在球杆前方、且距离小于某个阈值时才击球",就会自然很多。
更进一步,可以给 AI 加一个"瞄准"逻辑:击球时不是简单地把球推出去,而是朝对方球门方向给一个偏向的冲量。这样 AI 的进攻会有目的性,而不是乱打。实现上就是在计算冲量方向时,把"球到球门的方向"和"球杆到球的方向"做一个加权混合,权重决定 AI 的"准头"。
我实测下来,AI 的准头参数比速度参数更影响体验。一个速度中等但会瞄准的 AI,比一个速度很快但乱打的 AI 好玩得多。所以调难度时,优先调准头和反应延迟,速度反而放在最后调。
4. 提示词工程:怎么描述才能一次生成可跑的代码
4.1 需求描述的颗粒度:太粗和太细都出问题
用代码生成工具做游戏,提示词的质量直接决定结果。我试过两种极端:一种是只写"帮我做一个冰球游戏",结果生成的东西五花八门,有的用 Canvas、有的用 DOM、有的甚至用 CSS 动画,逻辑完整度参差不齐;另一种是把每一行代码都规定死,结果模型被约束得太死,反而写出一堆冗余代码,还容易自相矛盾。
比较靠谱的颗粒度是:规定技术栈、规定核心机制、规定交互方式,但不规定具体实现细节。比如:
- 技术栈:单个 HTML 文件,用 Canvas 2D 渲染,原生 JavaScript,不引入任何外部库;
- 核心机制:球有速度和阻尼,撞墙反弹,球杆跟随鼠标,击球用圆形碰撞检测;
- 交互:鼠标控制己方球杆,AI 控制对方球杆,先进 5 球者胜;
- 附加要求:显示比分,进球后重置球位置,有简单的开始/重开按钮。
这样描述,模型既有明确的边界,又有发挥空间,生成出来的代码通常结构清晰、能直接跑。
4.2 必须显式声明的几个点
有几个点如果不写清楚,生成结果大概率会出问题,我列一下:
第一,坐标系和画布尺寸。不写的话,模型可能用 800×600,也可能用 1000×500,你后续改起来还得重新算比例。我一般直接指定 900×600,宽高比接近真实冰场。
第二,帧率处理方式。必须明确要求"用 requestAnimationFrame 并基于 dt 更新",否则模型很可能写成固定步长,导致不同设备速度不一致。
第三,碰撞后的位置修正。这个太细节,模型不一定主动做,但不说的话球会卡墙。可以在提示词里加一句"碰撞后要把球的位置修正到边界内,避免卡住"。
第四,AI 的难度参数。如果不说,模型可能给一个固定难度的 AI,你想调都没地方调。要求"AI 的反应延迟和移动速度用变量控制,方便调整难度"。
4.3 生成之后的第一次运行:先看什么
代码生成出来,别急着玩,先按顺序检查几件事:
- 打开浏览器控制台,看有没有报错。语法错误、未定义变量这类问题,控制台会直接告诉你。
- 看球会不会动。如果球不动,多半是循环没启动,或者 dt 算错了。
- 看球撞墙会不会卡。如果卡住,就是位置修正没做。
- 看球杆能不能击球。如果不能,检查碰撞检测的半径设置,可能球杆半径太小,或者坐标没对齐。
- 看 AI 会不会动。如果 AI 不动,检查 AI 更新逻辑是不是被条件挡住了。
这五步走完,基本能定位 90% 的问题。剩下的 10% 通常是手感问题,那就得靠调参数了。
5. 实测中最容易翻车的五个细节
5.1 球杆跟随鼠标时的坐标偏移
用鼠标控制球杆,最直觉的写法是把鼠标的屏幕坐标直接赋给球杆。但 Canvas 有它自己的坐标系,如果 Canvas 在页面上不是从 (0,0) 开始,或者有 CSS 缩放,鼠标坐标和 Canvas 坐标就对不上,表现为球杆和鼠标之间有个固定偏移。
正确做法是用getBoundingClientRect()拿到 Canvas 在页面中的实际位置和尺寸,然后做换算:
const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY;这个换算看起来啰嗦,但它是唯一能同时处理"Canvas 有偏移"和"Canvas 被 CSS 缩放"两种情况的方法。我见过太多项目因为省了这几行,导致球杆永远对不准。
5.2 进球判定与重置的时序问题
进球判定本身很简单:球心进入球门区域就算进球。但重置的时序容易出问题。如果进球后立刻重置球位置,但同一帧里球的速度还没清零,下一帧球又会从重置点飞出去,看起来像"球瞬移了一下又飞走"。
正确做法是:进球后先把球的速度清零,再重置位置,并且加一个短暂的"暂停"状态(比如 0.5 秒),期间不接受击球、不更新球位置,只显示进球提示。这个暂停状态还能顺便解决"连续进球"的 bug——如果球在球门里反复触发判定,比分会疯涨。
5.3 高速球穿透球门线
如果球速很快,一帧内可能从球门线前直接飞到球门线后,如果判定逻辑只检查"球心是否在球门区域内",就会漏判。解决办法是把球门判定区域做得比视觉上的球门稍大一点,或者用线段相交的方式判断球这一帧的移动路径有没有穿过球门线。原型阶段用前者就够了,把判定区域往外扩个几像素,成本低效果好。
5.4 AI 卡在角落出不来
AI 追球时如果球停在角落,AI 会一直往角落挤,然后卡在墙边抖动。这是因为 AI 的目标位置可能超出场地范围,而 AI 的移动没有做边界限制。修复很简单:给 AI 的位置也加上和球一样的边界约束,让它永远待在场地内。另外可以加一个"如果 AI 和目标位置距离很近但一直没击到球,就随机偏移一下目标"的逻辑,避免死循环。
5.5 比分显示与游戏状态的同步
比分、游戏状态(进行中/已结束)、球的位置,这三者必须同步更新。常见 bug 是:比分到了 5 分,游戏结束了,但球还在动,玩家还能继续击球。修复方式是在游戏循环的最前面加一个状态判断,如果状态是"已结束",就跳过所有更新逻辑,只渲染最终画面。这个判断要放在最前面,不能放在中间,否则总有一部分逻辑会漏掉。
6. 从原型到"能玩一下午":值得做的几项增强
6.1 击球反馈:让手感"有重量"
原型阶段的击球是无声无息的,球被推出去就完了。加一点反馈,体验会完全不同。最简单的反馈是视觉反馈:击球瞬间在球的位置画一个扩散的圆圈,或者让球杆短暂变色。稍微进阶一点是屏幕震动:击球时让整个画布偏移一两像素再弹回,模拟冲击感。
再往上就是音效。不需要真实音效文件,用 Web Audio API 合成一个短促的"啪"声就行,几行代码的事。音效对手感的提升是巨大的,我做过对比测试,同一个游戏加音效前后,主观"打击感"评分能差一倍。
6.2 球的拖尾与冰面质感
冰球在冰面上滑动,视觉上应该有一点"痕迹"。实现方式是在球的历史位置画一串逐渐透明的小圆,形成拖尾。拖尾长度和透明度衰减速度可以调,拖尾越长,速度感越强。
冰面质感则可以用一层半透明的浅蓝色叠加,再画几条淡淡的弧线模拟冰面划痕。这些纯装饰性的东西不影响逻辑,但对"这是个冰球游戏"的代入感帮助很大。原型阶段可以不做,但如果你打算把这个项目拿去展示,这几笔值得加。
6.3 难度曲线:让 AI 越打越强
固定难度的 AI,玩几局就腻了。可以做一个简单的难度递增:每进一球,AI 的反应延迟减少一点、移动速度增加一点。这样前期轻松、后期紧张,玩家会有"越打越难"的紧迫感。
参数上,我一般让反应延迟每球减少 10 毫秒,速度每球增加 2%,上限设在初始值的 1.5 倍左右。超过这个上限,AI 会变得不可战胜,反而不好玩。这个上限值需要根据你自己的反应速度来定,没有统一标准。
6.4 移动端适配:触屏操作的坑
如果想让手机也能玩,触屏操作是必须的。触屏和鼠标最大的区别是:手指会挡住球杆,而且触屏没有"悬停"状态,手指一离开屏幕,球杆就失去控制。
解决办法有两个:一是把球杆的跟随位置往上偏移一点,让球杆出现在手指上方,避免被挡住;二是手指离开屏幕后,球杆保持在最后位置不动,而不是归零。这两个调整做完,触屏体验会好很多。另外,触屏的touchmove事件需要preventDefault(),否则页面会跟着滚动。
7. 这类"一次生成"项目的通用方法论
7.1 生成的是骨架,不是成品
把 Codex 生成冰球游戏这件事抽象一下,它的本质是:用自然语言描述一个逻辑闭环,让模型把闭环翻译成代码。模型擅长的是把清晰的逻辑转成语法正确的代码,不擅长的是判断"这个逻辑好不好玩"。所以生成出来的永远是骨架,手感和趣味性得你自己填。
理解这一点很重要,因为它决定了你的预期。如果你期待一次生成就能得到商业级游戏,那必然失望;如果你把它当成"省掉搭框架的时间",那它的价值就非常大。我自己的用法是:生成原型,然后花 80% 的时间调参数和加反馈,这 80% 才是真正决定游戏好不好玩的部分。
7.2 提示词是可以迭代的资产
第一次生成的提示词,几乎不可能完美。我的做法是把提示词存下来,每次生成后根据结果修改,改个三五轮,就能得到一份"稳定产出可用原型"的提示词模板。这份模板的价值,比任何一次具体的生成结果都高,因为它可以复用到同类项目上。
比如冰球游戏的提示词模板,稍微改改就能用于空气曲棍球、乒乓球、甚至简单的足球游戏。核心结构(场地、球、球杆、AI、计分)是通用的,变的只是具体规则。
7.3 参数化思维:把"感觉"变成"数字"
游戏开发里最值钱的能力之一,是把"手感不对"这种模糊感受,翻译成具体的参数调整。球太飘?调阻尼。AI 太强?调反应延迟。击球没力?调冲量系数。每一个感受背后,都对应一个可以量化的参数。
这个能力不是天生的,是靠反复调参练出来的。我的建议是:每做一个项目,都把关键参数集中定义在代码顶部,用有意义的变量名,加注释说明取值范围和影响。这样你调参的时候不用满代码找,改一个数字就能立刻看到效果。这个习惯坚持下来,你对"参数和手感的关系"会越来越敏感。
7.4 什么时候该放弃生成、自己写
代码生成不是万能的。当需求涉及复杂的自定义物理、精细的动画状态机、或者高度特定的美术风格时,生成的代码往往需要大改,改的成本可能比自己写还高。判断标准很简单:如果生成的代码你需要改动超过 50%,那就别改了,自己重写。
冰球游戏这种逻辑相对简单、结构相对固定的项目,是代码生成的甜区。但如果你要做的是带剧情、带多角色、带复杂关卡的游戏,生成的原型只能当参考,真正的实现还得自己来。认清工具的边界,才能用好工具。
8. 我在这个项目里踩过的真实坑
说几个具体的、文档里不会写的坑。
第一个是球杆的碰撞半径。我一开始把球杆半径设得和视觉大小一样,结果发现击球特别难,因为视觉上球杆看起来碰到了球,但实际碰撞半径差一点点。后来我把碰撞半径设成视觉半径的 1.2 倍,手感立刻对了。这个"碰撞体比视觉体稍大"的技巧,在动作游戏里是通用做法,因为玩家对"看起来碰到了"的容忍度比"实际碰到了"高。
第二个是AI 的移动速度上限。我一开始没给 AI 设速度上限,结果 AI 追球时速度无限大,一帧就瞬移到球旁边,看起来像作弊。加上速度上限之后,AI 的移动有了"惯性感",反而更像人。这个上限值我最后定在玩家球杆最大速度的 0.9 倍,AI 略慢于玩家,但反应更快,形成互补。
第三个是进球后的球速清零。前面提过,但我要强调一下:这个 bug 特别隐蔽,因为大部分时候球速不快,清零不清零看不出来。只有当球高速入门时,才会出现"球进门后又弹出来"的诡异现象。我排查了半小时才定位到,就是因为没清零。所以进球处理里,速度清零这一行,千万别省。
第四个是Canvas 的 devicePixelRatio。在高分屏上,如果 Canvas 的 width/height 属性和 CSS 尺寸不一致,画面会模糊。解决办法是把 Canvas 的实际像素尺寸设成 CSS 尺寸乘以window.devicePixelRatio,然后用ctx.scale()缩放绘图上下文。这个处理不做,在高分屏上整个游戏都是糊的,非常影响观感。
第五个是游戏循环的启动时机。我一开始在脚本加载完就启动循环,结果有时候 Canvas 还没渲染出来,第一帧的尺寸是 0,导致后续所有坐标计算都错。后来改成在window.onload之后再启动,问题消失。这个坑在本地开发时不一定遇到,但部署到线上、网络慢的时候就会暴露。
这些坑的共同点是:它们都不影响"代码能不能跑",只影响"跑起来好不好"。而恰恰是这些"好不好"的细节,决定了一个原型能不能变成真正能玩的东西。代码生成工具能帮你跳过"能不能跑"的阶段,但"好不好"这一关,还是得自己过。