下载 App
【聚光灯】牧羊人 开发日志#3 AI x PICO-8 完整工作流分享
修改于10/0443 浏览综合

前篇日志:
今日关卡制作灵感续上了,目前关卡数来到20个,贴其中一个风道迷宫的关卡截图,有小时候玩宝可梦时候在山洞冰面滑来滑去的感觉。

点击试玩:
好了人类看的日志内容结束了,如果你也想用ai做pico-8游戏的话,可以给你的机器伙伴继续阅读下面的内容~
(注:ai还是只能cover程序的部分,关卡设计不来的,我关卡都是手搓;ai 最开始自告奋勇帮我做关卡可完成验证,结果我测过的关卡他说自己枚举测试无法通关,菜……)


和 AI 一起做 PICO-8 卡带:一张卡从零到交付的完整工作流
分享我们做 PICO-8 游戏时固定走的一条流水线。核心思路:除了"好不好玩",其它问题全部交给命令回答。
每做一张卡带都按同一套顺序走:规格 → 生成素材 → 无头跑 → 断言回归 → 截图看图 → 查预算 → 一键盘带/网页包 → 人工终检。
0. 一句话总览
docs/specs/ 写下规则
│
▼
<game>.p8 的 __lua__ ──── 唯一的代码来源
│ (边写边 pico8 -x 无头跑;语法错会挂死,所以一定套 timeout)
▼
assets/gen_data.py ──── 美术 + 音频,用脚本就地重写 __gfx__/__sfx__/__music__
│
▼
tests/ 无头断言回归 ──→ p8count 预算 ──→ bot 试玩截图(我直接看图)
│
▼
tools/export.sh 一条命令:重建数据段 → lint → 回归 → 刷封面 → 导出 .p8.png → 冒烟
│
▼
package_web.ps1 / build_bin.py ── 网页版(掌机壳 + 触屏)与四平台独立 exe
│
▼
人工终检:GUI 里跑、CTRL-P 看 CPU、耳朵听音色、手柄摸手感
1. 工程长在 PICO-8 的虚拟盘里
不做"源码在别处、跑的时候再拷进 PICO-8"这种往返搬运,直接把工程放在 PICO-8 的虚拟盘:
carts/<game>/
<game>.p8 卡带本体,自包含:代码 + gfx + map + sfx + music
tests/ test_lib.lua、test_*.p8、bot_*.p8、run_tests.sh
assets/ 生成器(像素画、音符、封面)——卡带运行时永远不读这里
tools/ build.sh / export.sh / playtest.sh / lint_*.py / 关卡编辑器
docs/specs/ 设计规格
docs/plans/ 实现计划与验证记录
shots/ 无头截图产物(生成物)
好处是 load <game> 和 SPLORE 直接就能跑,导出也不用改路径。
硬约束:卡带必须自包含(内部不带 #include),否则 SPLORE 加载和导出都会出问题。只有测试卡和 bot 卡才用 #include ../<game>.p8。
我们有一个最小脚手架 carts/_template/(自带 12 条断言和一条跑通的无头试玩),新游戏就 cp -r 过去改名字——它 README 里列着的坑都已经踩过了。
2. 代码怎么写
先写规格,再写代码
新功能先落 docs/specs/<game>.md:规则、数值表、状态机、关卡格式。做完的阶段和踩的坑记到 docs/plans/。
这一步看着多余,其实最省事。Shepherd 的 M0 是手工推演策划案的关卡,先发现 3 处按规则不成立的地方、改了数据才动手写代码——省掉了"做完了发现关卡无解"的返工。
写代码时的三条铁律
- 状态全放在一个 g 表里,new_game() 可重复调用。 测试靠重新调 new_game() 重置世界。
- 所有输入只走 btn()/btnp()。 这是无头测试能驱动游戏的前提,没有第二条路:测试 stub 掉这两个函数就等于接管了玩家。任何用 stat() 读键盘的写法都会让测试失效。
- 不要定义与内置同名的全局变量。t(就是 time())、add、del、all、stat、count 都踩过。
PICO-8 Lua 的方言坑(动手前先知道)
坑 说明
没有 sort()、没有 pcall() 自己写插入排序;cartdata() 包不了保护
for i,v in all(t) 只给值 v 是 nil,要下标得用 pairs
del(t) 不能当弹栈 撤销栈因此吃过 nil,弹最后一个用 deli(t)
数字是 16.16 定点,范围 ±32767.99999 99999 环绕成 ≈ -31073;要在算术之前钳制,事后 min() 补不回来
>>/<</shr()/shl() 是定点数移位 13>>1 == 6.5,不是整数位运算;位测试用 ch&(1<<b)~=0
单行 if (cond) stmt 是简写形式、不带 end 它吞掉整行剩下的内容,那个多余的 end 就变成语法错
cos/sin 吃 0..1 不是 0..2π,而且 sin 方向是反的
按钮 0..3 = 左/右/上/下 与"上/右/下/左"的方向表不是同一套下标,得做映射
无头跑是常态,但必须套 timeout
bash scripts/p8run.sh cart.p8 # 内部:timeout + pico8 -x + grep 错误
Lua 语法错误会让 pico8 进程永久挂住(不退出、不报错),所以所有无头调用一律套 timeout,并把 syntax error / runtime error / program too large / could not #include / could not load 当失败信号。
注意 runtime error 只打印一行、进程仍然退出 0,光看退出码会漏。另外绝不要用 pico8 -h 之类去探参数:任何未知 flag 都会弹 GUI。
3. 素材怎么写:用生成器,不手改数据段
这是我觉得最值得分享的一条。
.p8 里的 __gfx__ / __sfx__ / __music__ 是十六进制文本,手改既难读又易错(每行长度都是硬约束:__gfx__ 128 个 hex、__sfx__ 168、__music__ 11)。所以我们的分工是:
- <game>.p8 的 __lua__ 是代码的唯一来源,生成器从不碰它。
- 美术和音频住在一个 Python 文件里(assets/gen_data.py):8×8 精灵就是 "33333333" 这样的字符串数组,'.' 表示 0 号色(透明);音符也是数据表。bash tools/build.sh 把它就地写回卡的三个数据段。
# assets/gen_data.py 里的样子
SPR = {
0: [ # grass A
"33333333", "333b3333", "33333333", "333333b3",
"33333333", "3b333333", "33333333", "3333b333",
],
...
}
事后看这完全是赚的:
- 可复现:删掉数据段重新生成,逐字节一样。
- 可 diff:改一个像素是一行字符串的 diff。
- 可程序化:风炮的四个朝向不是手画的,是"灰鼓+橙箭头"旋转生成的;两种草地平铺是同一套像素的变体。
- 图片就是反馈:gfx_preview.py 把精灵表导成 PNG,我(AI)直接看图确认像素画对不对,而不是靠读 hex 猜。
几个具体做法:
- 从参考图反推结构。比如做 logo 时从参考图量出"5×5 格子、四角为空",再写脚本生成,而不是手工凑。
- 像素风比平滑渐变省一个数量级。5px 美术格 + 硬边 + NEAREST 放大后,面板图从 21/37KB 降到 1.8/2.4KB。
- 音频:4 个声道里 BGM 只占 3 个,留 1 个给音效。这是踩过大坑的:music() 的 mask 是"保留给音乐"的声道,一旦被 sfx() 显式插进来,那个声部的音乐就断很久——听起来像掉帧,其实 CPU 很低。tools/lint_audio.py 专门守这条规则。
- 封面 __label__ 从画面生成。PICO-8 拒绝导出没有 __label__ 段的卡带(please capture a label first)。做法:无头跑标题屏 → extcmd("screen") 截图 → make_label.py 把 128×128 截图按调色板映射回 hex 写进 __label__。同一张封面同时用在标题画面和 .p8.png 封面。
- 关卡由编辑器产出。浏览器里的关卡编辑器(单文件 HTML,含同规则试玩)导出 JSON,tools/import_levels.py 写回 lvls={...} 那段。关卡可通关性由人负责,测试只查数据完整性。
- 文字必须 ASCII。PICO-8 内置字体没有中文字形(自己做点阵字库会吃掉大量 token);而且卡带头两行注释会变成导出 .p8.png 的 title/byline,混进中文就是乱码——tools/lint_ascii.py 把这条钉死。
4. 做什么检查:五层,每层都能无人值守出结论
① 能加载、能跑
p8run.sh:套 timeout 跑一遍,grep 掉所有错误关键字。这一层挡掉语法错、runtime error、token 超限、include 失败。
② 行为对不对:无头断言回归
tests/test_*.p8 一组测试卡,每张的结构是:
#include ../game.p8 -- 游戏 include 在前,测试覆盖定义在后
#include test_lib.lua -- stub 掉 btn/btnp/music/sfx,捕获游戏的 _update/_draw
function _init()
cartdata("game_tests") -- 一次运行只能调一次,用测试专用 key
tcase("move right") fresh() step(5,{[1]=true}) check(g.p.x>x0,"x grew")
...
printh("TESTS DONE ok="..ok.." fail="..fail)
extcmd("shutdown")
end
harness 提供 tcase / check / step(n, keys) / fresh(seed) / finish()。测试在 _init 里一次跑完(不是在帧里),打出 PASS:/FAIL: 行和最后一行的 TESTS DONE ok=N fail=M,再 extcmd("shutdown") 干净退出。run_tests.sh 逐张跑,要求 fail=0 且必须看到干净的 TESTS DONE(没看到也算失败——避免"什么都没跑"却报绿)。
Shepherd 现在是 2359 条断言:羊的规则、交互、地图边界、关卡数据完整性、音频格式。测试卡按组拆开,因为 8192 token 上限包含 include 进来的游戏代码(一个 5000 token 的游戏只剩约 900 token 写测试)。
test_lib.lua 里三个反直觉但必须做的点:
- fresh() 必须清空按键状态(键状态是模块级变量)。上个用例结尾按住的键会残留、吃掉下个用例的第一次 btnp();症状是"tap scores 少记一次分",而 sfx 日志看起来完全正常。
- step() 里要同时调 _update 和 _draw,让绘制代码里的错误也能浮出来。
- Windows 上 printh() 输出带 INFO: 前缀。要 sed 's/^INFO: //'剥掉前缀,而不是 grep -v '^INFO:'——那样过滤会把你所有测试结果一起删掉(表现:跑了但一行输出都没有)。RUNNING: 才是唯一的噪音。
③ 画面对不对:让 bot 玩给我看
写一个 bot 卡:#include 游戏、stub 掉输入、按脚本/策略跑 N 帧、在关键节点 extcmd("screen") 截图,最后 extcmd("shutdown")。跑完我直接读那些 PNG 判断画面。
bash tools/playtest.sh shepherd.p8 tests/bot_play.p8 150 # 截图落到 shots/
这个反馈环效率非常高:风格不对、元素错位、文字跑到一行、羊没进圈,都是"看一眼就知道"。而且 shots/*.png 同时是分享素材和回归证据。
注意两个坑:① helper/bot 卡 #include 只带代码,不带 gfx/map/sfx/music,所以开头要 reload(0x0000,0x0000,0x3000,"../game.p8") 把精灵表拉进来,否则渲染出来是一片沙色、没有精灵;② 截图编号会顺延,重跑前先删旧的 shots/<bot>_*.png,否则你看到的是上一轮的图。
④ 预算够不够:token / char / compressed
PICO-8 没有运行期 API 能读这三个数(info() 只能画在屏幕上),所以 p8count.sh 生成一张临时探针卡,#include 目标卡 + info() + 截图,把 TOKENS /8192、CHARS /65535、COMPRESSED /15360 拍下来读。回归脚本里固定跟一步。
⑤ 数据与格式
- 关卡数据完整性(尺寸/字符合法/玩家起点唯一/有羊有圈);
- 编辑器那套 JS 模拟器用 quickjs 直接跑单测(21 条),比开浏览器轻;
- 需要 DOM/键盘/像素级断言时才上真浏览器:PowerShell 起 headless Edge + CDP。
无头测不出来的东西(很重要,别自欺)
- CPU 用不了无头测。stat(1) 恒返回 0.0007;-x 还硬限速 30fps,空卡和满负载游戏都是 ~34ms/帧(实测 60 帧 3.3s、600 帧 21.7s,空卡和完整游戏一模一样),-foreground_sleep_ms 0 也解不开。所以只能按工作量估算,最终靠 GUI 里 CTRL-P 确认。
- 音频听不出来。-x 不跑混音器,stat(46..49) 停在最后一次赋值的声道上不再清零。只能验格式/音高范围/长度对齐/是否指定了声道;音色、有没有掐断 BGM,只能耳朵听。
5. 交付:一条命令出成品
tools/export.sh 就是我们的"发版"按钮,五步,任何一步失败就停:
1/5 美术音频数据段 lint_audio(声道划分)→ lint_ascii(被渲染的文字)→ 重建 → 同步编辑器精灵
2/5 回归测试 tests/run_tests.sh 必须 ALL TESTS PASSED
3/5 封面 清旧截图 → 无头跑标题屏 → make_label.py 写 __label__
4/5 导出 png 卡带 pico8 <game>.p8 -export "-f <game>.p8.png"
5/5 冒烟 p8run.sh 再跑一遍本体,无输出即无错
产物:
产物 说明
<game>.p8 源卡带(自包含:代码 + 数据段)
<game>.p8.png 带封面的 PNG 卡带,SPLORE 直接能加载、可以贴 BBS
web/<game>_html/ 网页版,file:// 双击就能玩
<game>_single.html 单文件网页版(手机发给别人很方便)
<game>_web.zip 分享包
网页版和独立可执行文件各有一条命令:
# 网页版 + 验证:掌机壳(像素风触屏方向键 + 按压反馈)+ 自动播放
python scripts/build_web.py path/to/game.p8
powershell -File scripts/webcheck.ps1 -Cart path/to/game.p8 -Mobile # 手机视口 + 注入触摸
powershell -File scripts/webcheck.ps1 -Cart path/to/game.p8 -Desktop # 桌面窗口形状
# 一步到位:构建 → 桌面校验 → 手机校验 → 单文件 → zip
powershell -File scripts/package_web.ps1 -Cart path/to/game.p8
# 四平台独立可执行文件(Windows/Linux/macOS/树莓派,自带运行时不依赖 PICO-8 安装)
python scripts/build_bin.py path/to/game.p8
网页版的验证是断言而不是眼看:#canvas 从 0×0 变成真实尺寸(说明卡带启动了)、相隔 1.5 秒的两张截图 MD5 不同(说明帧在推进)、手机模式下注入触摸按摇杆真实中心命中死区。桌面模式还有一条:页面无法 resize 自己的窗口(window.resizeTo 在普通标签页里是 no-op),所以外壳改为"发布期望的窗口形状 + 在拿到的窗口里居中",并用 1280×800 / 722×1282 / 800×1280 三种尺寸断言不溢出。
卡没变的话重建是逐字节相同的(md5 不变)——因为外壳永远从一份全新的 stock 导出重建,而不是在上一次构建的基础上打补丁。
6. 最后一道关是人
流水线能证明"这张卡没坏",证明不了"这张卡好玩"。所以每轮末尾都留出人验的位置:
- CPU:GUI 里跑,CTRL-P。0.1 左右舒服,>0.8 在慢机器和网页播放器上就要掉帧。
- 音色/音乐:耳朵。BGM 有没有被音效掐断,只有听。
- 手感:手柄/键盘。键盘事件在浏览器里没法用合成事件覆盖,所以键盘手感必须人工。
- 关卡可通关性:这是设计者的活,测试不替你做。
所以我们的循环长这样:我写代码、生成素材、跑断言、截图、查预算、打包 → 人玩游戏、听声音、看 CPU、做设计判断 → 反馈回来我再改。 AI 负责所有能自动化的部分,人只负责审美和判断。
7. 分享几个最省时间的坑
症状 原因 解法
pico8 -x 永远不返回 Lua 语法错误会挂死进程 一律套 timeout;syntax error 当失败
退出码 0 但测试什么都没跑 runtime error 只打印一行,仍退出 0 grep 输出里的 runtime error
跑了但一行输出都没有 你把 ^INFO: 过滤了(Windows 上前缀里装着全部结果) sed 's/^INFO: //' 剥前缀,只滤 RUNNING:
用例少算一次 fresh() 没清按住的方向键,btnp() 被上个用例吃掉 fresh() 里 reset 输入 + 清日志
could not #include file 然后挂死 #include 路径相对卡带,且加载失败会挂 helper 卡放在游戏旁边;一律套 timeout
helper 卡渲染出沙色、没有精灵 #include 只带代码,不带 gfx/sfx/music 开头 reload(0x0000,0x0000,0x3000,"../game.p8")
黑色精灵全是洞、透出地板 spr() 里 0 号色是透明的 用别的颜色填底,配 palt(0,false) palt(3,true)
大坐标下距离全错、计时器瞬间触发 16.16 定点溢出 在算术之前钳制
program too large 8192 token 上限把 include 的代码也算进去 拆成多张测试卡共享一个 test_lib.lua
please capture a label first 卡带没有 __label__ 段,PICO-8 拒绝导出 无头截标题屏 → 生成 __label__
网页版一直不开始 默认模板 p8_autoplay = false 在等点击,而启动回调绑在容器上不是 canvas 构建脚本里设 autoplay 并主题化外壳
8. 想复现这套流程
需要:PICO-8 0.2.7、uv(跑 Python 工具,Pillow 走镜像装)、PowerShell(浏览器验证要真 Edge),以及一份把这些约定写下来的技能说明。
# 开新游戏
cd <PICO-8 虚拟盘>/carts
cp -r _template mygame && cd mygame
mv _template.p8 mygame.p8 && sed -i 's/_template/mygame/g' tests/*.p8 tests/run_tests.sh
bash tests/run_tests.sh # 12 条断言应该全绿
# 日常循环
bash tools/build.sh # 改完美术/音频后重建数据段
bash tests/run_tests.sh # 回归(同时报 token 预算)
bash tools/playtest.sh mygame.p8 tests/bot_play.p8 150 # 无头试玩 + 截图
# 发版
bash tools/export.sh
powershell -File package_web.ps1 -Cart mygame.p8
一句话总结:把所有能自动化的东西都压到"一条命令 + 一个退出码",
把无头做不到的三件事(CPU、音色、手感)明确标出来交给人工。
这样 AI 才能真正持续地做卡带——因为每一步它都能自己看到结果,而不是猜。













