例程:贪吃蛇(2D 网格 + 定步长推进)
对标版本:渲染库 v1.3.0、XTC v1.0-rc.3。
实机演示
源代码
// 由玄铁铸造厂官方内置AI(DeepSeek-Flash)开发。
// 贪吃蛇 — 玄铁渲染库 demo
// 编译运行: xtc.exe pao 贪吃蛇.xt
// 操作: 方向键 / WASD 移动,R 重开,ESC 退出
//
// 顶部常量 自检帧数: 0 = 正常游玩;改成非 0(如 120) 则跑完指定帧数后
// 自动截图(snake_shot.png)退出,便于无人值守验证渲染链路。
引 "./lib/渲染/渲染.xt" 予 R
常 列数 = 24
常 行数 = 16
常 格 = 32
常 HUD高 = 36
常 窗宽 = 列数 * 格
常 窗高 = 行数 * 格 + HUD高
常 步长 = 0.13 // 每步间隔(秒)
常 自检帧数 = 0 // 0=正常游玩,>0=跑 N 帧后截图退出
型 游戏 {
设 蛇
设 食物
设 方向
设 待转
设 待转有 = 假
设 得分 = 0
设 最高 = 0
设 死了 = 假
设 计时 = 0.0
函 造() {
此.重开()
}
// 把蛇重置为 3 节,居中朝右
函 重开() {
设 头列 = 列数 / 2
设 头行 = 行数 / 2
此.蛇 = [
{"列": 头列, "行": 头行},
{"列": 头列 - 1, "行": 头行},
{"列": 头列 - 2, "行": 头行}
]
此.方向 = "右"
此.待转 = "右"
此.待转有 = 假
此.得分 = 0
此.死了 = 假
此.计时 = 0.0
此.放食物()
}
// 在空格上随机放一个食物
函 放食物() {
设 试 = 0
当 试 < 500 {
设 列 = R.取随机数(0, 列数 - 1)
设 行 = R.取随机数(0, 行数 - 1)
设 占 = 假
遍历 段 于 此.蛇 {
若 段["列"] == 列 且 段["行"] == 行 { 占 = 真 ; 断 }
}
若 非 占 {
此.食物 = {"列": 列, "行": 行}
返 空
}
试 = 试 + 1
}
此.食物 = {"列": 0, "行": 0}
}
// 读键盘:方向键转向(禁止 180 度回头)
函 读键盘() {
设 候选 = ""
若 R.键刚按下?(R.键上) 或 R.键刚按下?(R.键W) { 候选 = "上" }
若 R.键刚按下?(R.键下) 或 R.键刚按下?(R.键S) { 候选 = "下" }
若 R.键刚按下?(R.键左) 或 R.键刚按下?(R.键A) { 候选 = "左" }
若 R.键刚按下?(R.键右) 或 R.键刚按下?(R.键D) { 候选 = "右" }
若 候选 != "" 且 非 此.相反?(候选) {
此.待转 = 候选
此.待转有 = 真
}
}
函 相反?(新方向) {
若 此.方向 == "上" 且 新方向 == "下" { 返 真 }
若 此.方向 == "下" 且 新方向 == "上" { 返 真 }
若 此.方向 == "左" 且 新方向 == "右" { 返 真 }
若 此.方向 == "右" 且 新方向 == "左" { 返 真 }
返 假
}
// 走一格:前进一步,撞墙或撞自己则结束,吃到食物则计分
函 步进() {
若 此.待转有 {
此.方向 = 此.待转
此.待转有 = 假
}
设 头 = 此.蛇[0]
设 列 = 头["列"]
设 行 = 头["行"]
若 此.方向 == "上" { 行 = 行 - 1 }
若 此.方向 == "下" { 行 = 行 + 1 }
若 此.方向 == "左" { 列 = 列 - 1 }
若 此.方向 == "右" { 列 = 列 + 1 }
若 列 < 0 或 列 >= 列数 或 行 < 0 或 行 >= 行数 {
此.撞死()
返 空
}
// 尾巴这一帧会让位,所以最后一节不算碰撞
设 i = 0
设 身体节数 = 此.蛇.长度 - 1
当 i < 身体节数 {
若 此.蛇[i]["列"] == 列 且 此.蛇[i]["行"] == 行 {
此.撞死()
返 空
}
i = i + 1
}
此.蛇.插(0, {"列": 列, "行": 行})
设 吃到 = 列 == 此.食物["列"] 且 行 == 此.食物["行"]
若 吃到 {
此.得分 = 此.得分 + 10
若 此.得分 > 此.最高 { 此.最高 = 此.得分 }
此.放食物()
} 否 {
此.蛇.删(此.蛇.长度 - 1)
}
}
// 撞死:标记结束,并让蛇身收缩一节(视觉上停住)
函 撞死() {
此.死了 = 真
此.蛇.删(此.蛇.长度 - 1)
}
}
// ---- 绘制辅助 ----
函 格X(列) { 返 列 * 格 }
函 格Y(行) { 返 行 * 格 + HUD高 }
函 画背景() {
R.清背景(16, 20, 30, 255)
设 行 = 0
当 行 < 行数 {
设 列 = 0
当 列 < 列数 {
若 (列 + 行) % 2 == 0 {
R.绘矩形(格X(列), 格Y(行), 格, 格, 22, 27, 41, 255)
} 否 {
R.绘矩形(格X(列), 格Y(行), 格, 格, 18, 23, 35, 255)
}
列 = 列 + 1
}
行 = 行 + 1
}
}
函 画食物(游, 帧) {
设 偏移 = 帧 % 20
若 偏移 > 10 { 偏移 = 20 - 偏移 }
设 半径 = 9 + 偏移 / 2
设 中心X = 格X(游.食物["列"]) + 格 / 2
设 中心Y = 格Y(游.食物["行"]) + 格 / 2
R.绘圆(中心X, 中心Y, 半径, 255, 205, 70, 255)
R.绘圆(中心X - 3, 中心Y - 3, 3, 255, 255, 210, 255)
}
函 画蛇(游) {
设 i = 0
当 i < 游.蛇.长度 {
设 段 = 游.蛇[i]
设 x = 格X(段["列"])
设 y = 格Y(段["行"])
若 i == 0 {
R.绘圆角矩形(x + 2, y + 2, 格 - 4, 格 - 4, 40, 120, 255, 170, 255)
// 两只眼睛,朝向由前进方向决定
若 游.方向 == "右" {
R.绘圆(x + 22, y + 11, 3, 12, 16, 28, 255)
R.绘圆(x + 22, y + 21, 3, 12, 16, 28, 255)
}
若 游.方向 == "左" {
R.绘圆(x + 10, y + 11, 3, 12, 16, 28, 255)
R.绘圆(x + 10, y + 21, 3, 12, 16, 28, 255)
}
若 游.方向 == "上" {
R.绘圆(x + 11, y + 10, 3, 12, 16, 28, 255)
R.绘圆(x + 21, y + 10, 3, 12, 16, 28, 255)
}
若 游.方向 == "下" {
R.绘圆(x + 11, y + 22, 3, 12, 16, 28, 255)
R.绘圆(x + 21, y + 22, 3, 12, 16, 28, 255)
}
} 否 {
// 越靠尾越暗,形成渐变
设 深 = 40 + i * 6
若 深 > 130 { 深 = 130 }
R.绘圆角矩形(x + 3, y + 3, 格 - 6, 格 - 6, 40, 60, 90 + 深, 200 + 深 / 2, 255)
}
i = i + 1
}
}
函 画界面(游, 字体) {
// HUD
R.绘矩形(0, 0, 窗宽, HUD高, 26, 32, 48, 255)
R.绘直线(0, HUD高, 窗宽, HUD高, 60, 80, 120, 255)
R.绘字体文字(字体, "得分 " & 字(游.得分), 12, 8, 20, 1, 255, 255, 255, 255)
R.绘字体文字(字体, "最高 " & 字(游.最高), 150, 8, 20, 1, 160, 190, 230, 255)
R.绘字体文字(字体, "方向键 / WASD 转向 R 重开 ESC 退出", 320, 8, 20, 1, 130, 150, 190, 255)
若 游.死了 {
// 半透明遮罩 + 结算
R.绘矩形(0, HUD高, 窗宽, 窗高 - HUD高, 0, 0, 0, 160)
设 标题 = "游戏结束"
设 标题宽 = R.量字体文字(字体, 标题, 40, 2)
R.绘字体文字(字体, 标题, (窗宽 - 标题宽) / 2, HUD高 + 170, 40, 2, 255, 90, 90, 255)
设 提示 = "得分 " & 字(游.得分) & " 按 R 再来一局"
设 提示宽 = R.量字体文字(字体, 提示, 24, 1)
R.绘字体文字(字体, 提示, (窗宽 - 提示宽) / 2, HUD高 + 230, 24, 1, 235, 235, 245, 255)
}
}
// ---- 主流程 ----
设 游 = 造 游戏()
R.初始化窗口(窗宽, 窗高, "贪吃蛇 · 玄铁渲染")
设 字体 = R.加载系统字体("微软雅黑", 20)
设 帧 = 0
当 非 R.窗口应关闭?() {
R.开始绘图()
画背景()
游.读键盘()
若 R.键刚按下?(R.键R) { 游.重开() }
若 非 游.死了 {
游.计时 = 游.计时 + R.取帧时间()
若 游.计时 >= 步长 {
游.计时 = 游.计时 - 步长
游.步进()
}
}
画食物(游, 帧)
画蛇(游)
若 字体 != 0 {
画界面(游, 字体)
} 否 {
R.绘文字("NO FONT", 12, 8, 20, 255, 80, 80, 255)
}
R.结束绘图()
帧 = 帧 + 1
若 自检帧数 > 0 且 帧 >= 自检帧数 {
R.截图("snake_shot.png")
断
}
}
R.释字体(字体)
R.关闭窗口()
示("贪吃蛇结束,得分 " & 字(游.得分) & ",最高 " & 字(游.最高) & ",共 " & 字(帧) & " 帧")一、它是什么
经典贪吃蛇:24×16 格棋盘,方向键/WASD 转向,吃到食物 +10 分,撞墙或撞自己结束,R 重开、ESC 退出。棋盘与蛇全部用圆角矩形/圆绘制,蛇尾有渐暗效果,蛇头有朝向眼球。
| 操作 | 键 |
|---|---|
| 转向 | 方向键 或 W A S D(禁止 180° 回头) |
| 重开 | R |
| 退出 | ESC / 关闭窗口 |
| 无人值守截图 | 把顶部 常 自检帧数 = 0 改成 120,跑满 120 帧后自动截图退出 |
二、编译与运行
# 在源码所在目录(它按相对路径引库)
xtc.exe pao 贪吃蛇.xt # 编译后立即运行(等价 go run)
xtc.exe 铁 贪吃蛇.xt -sc 贪吃蛇.exe && ./贪吃蛇.exe注意这份源码第一行是:
引 "./lib/渲染/渲染.xt" 予 R相对路径是相对源文件本身,所以要么在带 lib/渲染 的目录里编译,要么改成裸包名 引 "渲染" 予 R 让编译器走铁铺解析。同理它旁边那份逻辑测试用 引"./lib/..."(无空格),两种写法都合法。
三、结构:一个 型 装下全部状态
型 游戏 {
设 蛇 // 数组,[0] 是头;每节是 {"列": i, "行": j}
设 食物 // {"列": i, "行": j}
设 方向 // 当前方向:"上"/"下"/"左"/"右"
设 待转 // 排队的转向
设 待转有 = 假 // 有没有排队的转向
设 得分 = 0
设 最高 = 0
设 死了 = 假
设 计时 = 0.0
函 造() { 此.重开() }
函 重开() { ... }
函 放食物() { ... }
函 读键盘() { ... }
函 相反?(新方向) { ... }
函 步进() { ... }
函 撞死() { ... }
}这是本份例程最值得抄的组织方式:游戏状态与方法放在同一个 型 里,绘制函数留在外面。好处有三:
- 状态可整体构造(
造 游戏()),天然支持"重开 = 重新造"或"重开 = 调重开()"; - 逻辑测试可以只
引这个类型、完全不碰窗口(见第六节); - 绘制函数只读状态,不会偷偷改状态。
四、三个关键机制
1. 转向缓冲:待转 / 待转有
函 读键盘() {
设 候选 = ""
若 R.键刚按下?(R.键上) 或 R.键刚按下?(R.键W) { 候选 = "上" }
... 四个方向同理 ...
若 候选 != "" 且 非 此.相反?(候选) {
此.待转 = 候选
此.待转有 = 真
}
}为什么不当场改 方向?因为步进是按固定节奏走的(见下条),一帧内可能收到按键但还没到步进时刻。把转向塞进"待转"队列、等步进时再生效,可以避免"同一格内连按两次导致自杀",也让手感和节奏解耦。
相反?() 挡掉回头:
函 相反?(新方向) {
若 此.方向 == "上" 且 新方向 == "下" { 返 真 }
若 此.方向 == "下" 且 新方向 == "上" { 返 真 }
... 左右同理 ...
返 假
}它比较的是当前方向而不是
待转——蛇在一步之内连续两次按键(上→左)时,第二次的"相反判定"仍以生效方向为准。这是有意的宽松处理,写自己的版本时要想清这一点。
2. 定步长推进:步长 + 累加帧时间
常 步长 = 0.13 // 每步间隔(秒)
// 主循环里:
若 非 游.死了 {
游.计时 = 游.计时 + R.取帧时间()
若 游.计时 >= 步长 {
游.计时 = 游.计时 - 步长 // 减去一个步长,不是清零
游.步进()
}
}两个细节:
- 减而不是清零:帧时间有零头(例如 0.016s 一帧),攒够 0.13 就前进一步、把余数留下,长期平均步长才准确。写成
计时 = 0会让实际速度偏慢。 - 帧时间用
取帧时间():它返回上一帧到这一帧的秒数,与游戏逻辑同源,不要自己去测时.现()的差值(那种写法会把渲染耗时算进去)。想做"高速模式"只需把步长调小。
3. 碰撞:尾巴这一帧会让位
// 尾巴这一帧会让位,所以最后一节不算碰撞
设 i = 0
设 身体节数 = 此.蛇.长度 - 1
当 i < 身体节数 {
若 此.蛇[i]["列"] == 列 且 此.蛇[i]["行"] == 行 { 此.撞死() ; 返 空 }
i = i + 1
}如果吃不到食物,尾巴会在同一步被删掉(此.蛇.删(此.蛇.长度 - 1)),所以"头走到当前尾巴所在格"是合法的——判定必须排除最后一节,否则贴着自己尾巴走就会误判死亡。
移动的写法是"插头 + 删尾":
此.蛇.插(0, {"列": 列, "行": 行}) // 新头插到 [0]
设 吃到 = 列 == 此.食物["列"] 且 行 == 此.食物["行"]
若 吃到 {
此.得分 = 此.得分 + 10
若 此.得分 > 此.最高 { 此.最高 = 此.得分 }
此.放食物() // 吃到:不删尾 → 变长一格
} 否 {
此.蛇.删(此.蛇.长度 - 1) // 没吃到:删尾 → 长度不变
}插(0, ...) 与 删(长度-1) 是数组的两个基础方法;"头插尾删"让蛇的移动变成 O(1) 的两步操作,不需要整体搬移。
五、绘制:吃满 2D 图元
函 格X(列) { 返 列 * 格 }
函 格Y(行) { 返 行 * 格 + HUD高 } // 顶部留 HUD 条把"格坐标 → 像素坐标"收敛成两个函数,全篇绘制都不再出现手动乘法。
- 棋盘底纹:双层循环画 32×32 交替色矩形,
(列 + 行) % 2判断奇偶——这是最省事的棋盘格写法。 - 食物:
绘圆两层(外圈 + 左上高光小圆),半径按帧 % 20做呼吸动画(偏移 > 10时折返,形成往返而不循环跳变)。 - 蛇身渐变:越靠尾越暗,
设 深 = 40 + i * 6
若 深 > 130 { 深 = 130 } // 钳位,防止尾部越来越白
R.绘圆角矩形(x + 3, y + 3, 格 - 6, 格 - 6, 40, 60, 90 + 深, 200 + 深 / 2, 255)- 蛇头眼睛:按
方向分四种情况画两个小圆——纯装饰,但让"当前朝向"一眼可见,比箭头更直观。 - HUD 与结算:
绘圆角矩形当面板底,绘字体文字写字,绘直线当分隔线。结算遮罩用半透明黑:
R.绘矩形(0, HUD高, 窗宽, 窗高 - HUD高, 0, 0, 0, 160) // 第四参是 alpha- 文字居中:先用
量字体文字(字体, 标题, 40, 2)量出宽度再算(窗宽 - 标题宽) / 2——绘字体文字没有居中参数,居中要自己量。这也是为什么 HUD 文本都要走字体句柄:内置位图字体不认中文。
六、自检:把逻辑抽出来单独测
同一目录下的 贪吃蛇.逻辑测试.xt(138 行)是一份不引渲染库、不开窗口的纯逻辑测试:它把 型 游戏 简化成 型 棋盘(去掉了渲染相关字段、食物位置硬编码),然后按剧本推进并打印结果:
设 盘 = 造 棋盘()
示("[T1] 初始 头=" & 盘.头位置() & " 长=" & 字(盘.蛇.长度) & " 得分=" & 字(盘.得分))
盘.步进(); 盘.步进()
示("[T2] 右行2步 头=" & 盘.头位置() & " 长=" & 字(盘.蛇.长度) & " 死=" & 字(盘.死了) & " 得分=" & 字(盘.得分))
盘.转向("左"); 盘.步进()
示("[T3] 请求左转后 头=" & 盘.头位置() & " 方向=" & 盘.方向 & " 死=" & 字(盘.死了))
...覆盖:初始状态、普通前进(长度不变)、禁止回头、吃到食物(长度 +1、得分 +10)、撞墙、撞自己。输出纯 ASCII、只打印结果行,因此可以直接喂给基线比对工具(tools/regress.py 那种逐行 diff 的路子)。
另一种自检是主文件顶部的"自动截图":
常 自检帧数 = 0 // 0=正常游玩,>0=跑 N 帧后截图退出
...
若 自检帧数 > 0 且 帧 >= 自检帧数 {
R.截图("snake_shot.png")
断
}改成 120 编译,就会跑 120 帧、存一张 PNG、自动退出——无人值守环境下验证渲染链路的最短路径(不依赖人眼、不卡在等用户关窗口)。仓库根目录那份 snake_shot.png 就是这么来的。
建议做法:所有带窗口的例程都留一个"跑 N 帧 → 截图 → 退出"的开关,让 CI/LLM 能自动验图。
七、踩坑记录
- 相对引库路径:
引 "./lib/渲染/渲染.xt"相对源文件解析。把源码挪到别处而不改这行,就会报找不到模块。 - 中文必须走字体句柄:
绘文字用的是 raylib 内置位图字体(仅 ASCII),中文会变成空白或方块;加载系统字体("微软雅黑", 20)失败时返回 0,所以要像本文件那样若 字体 != 0 { 画界面(...) } 否 { R.绘文字("NO FONT", ...) }兜底。 - 窗口尺寸由常量推导:
常 窗宽 = 列数 * 格、常 窗高 = 行数 * 格 + HUD高。改棋盘大小只改列数/行数/格三个常量,窗口与布局自动跟着变——比到处写死 768 好得多。 R.取随机数(0, 列数 - 1)放置食物时最多试 500 次(防止棋盘快满时死循环),失败就退化到(0,0)。- 结束时
撞死()会主动删掉尾节(此.蛇.删(此.蛇.长度 - 1))让蛇"视觉上停住",这是表现层处理,别误当成逻辑 bug。