Skip to content

例程:贪吃蛇(2D 网格 + 定步长推进) ​

对标版本:渲染库 v1.3.0、XTC v1.0-rc.3。

实机演示 ​

源代码 ​

xuantie
// 由玄铁铸造厂官方内置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 帧后自动截图退出

二、编译与运行 ​

bash
# 在源码所在目录(它按相对路径引库)
xtc.exe pao 贪吃蛇.xt          # 编译后立即运行(等价 go run)
xtc.exe 铁 贪吃蛇.xt -sc 贪吃蛇.exe && ./贪吃蛇.exe

注意这份源码第一行是:

xuantie
引 "./lib/渲染/渲染.xt" 予 R

相对路径是相对源文件本身,所以要么在带 lib/渲染 的目录里编译,要么改成裸包名 引 "渲染" 予 R 让编译器走铁铺解析。同理它旁边那份逻辑测试用 引"./lib/..."(无空格),两种写法都合法。

三、结构:一个 型 装下全部状态 ​

xuantie
型 游戏 {
    设 蛇          // 数组,[0] 是头;每节是 {"列": i, "行": j}
    设 食物        // {"列": i, "行": j}
    设 方向        // 当前方向:"上"/"下"/"左"/"右"
    设 待转        // 排队的转向
    设 待转有 = 假  // 有没有排队的转向
    设 得分 = 0
    设 最高 = 0
    设 死了 = 假
    设 计时 = 0.0

    函 造() { 此.重开() }
    函 重开() { ... }
    函 放食物() { ... }
    函 读键盘() { ... }
    函 相反?(新方向) { ... }
    函 步进() { ... }
    函 撞死() { ... }
}

这是本份例程最值得抄的组织方式:游戏状态与方法放在同一个 型 里,绘制函数留在外面。好处有三:

  1. 状态可整体构造(造 游戏()),天然支持"重开 = 重新 造"或"重开 = 调 重开()";
  2. 逻辑测试可以只 引 这个类型、完全不碰窗口(见第六节);
  3. 绘制函数只读状态,不会偷偷改状态。

四、三个关键机制 ​

1. 转向缓冲:待转 / 待转有 ​

xuantie
函 读键盘() {
    设 候选 = ""
    若 R.键刚按下?(R.键上) 或 R.键刚按下?(R.键W) { 候选 = "上" }
    ... 四个方向同理 ...
    若 候选 != "" 且 非 此.相反?(候选) {
        此.待转 = 候选
        此.待转有 = 真
    }
}

为什么不当场改 方向?因为步进是按固定节奏走的(见下条),一帧内可能收到按键但还没到步进时刻。把转向塞进"待转"队列、等步进时再生效,可以避免"同一格内连按两次导致自杀",也让手感和节奏解耦。

相反?() 挡掉回头:

xuantie
函 相反?(新方向) {
    若 此.方向 == "上" 且 新方向 == "下" { 返 真 }
    若 此.方向 == "下" 且 新方向 == "上" { 返 真 }
    ... 左右同理 ...
    返 假
}

它比较的是当前方向而不是 待转——蛇在一步之内连续两次按键(上→左)时,第二次的"相反判定"仍以生效方向为准。这是有意的宽松处理,写自己的版本时要想清这一点。

2. 定步长推进:步长 + 累加帧时间 ​

xuantie
常 步长 = 0.13      // 每步间隔(秒)

// 主循环里:
若 非 游.死了 {
    游.计时 = 游.计时 + R.取帧时间()
    若 游.计时 >= 步长 {
        游.计时 = 游.计时 - 步长      // 减去一个步长,不是清零
        游.步进()
    }
}

两个细节:

  • 减而不是清零:帧时间有零头(例如 0.016s 一帧),攒够 0.13 就前进一步、把余数留下,长期平均步长才准确。写成 计时 = 0 会让实际速度偏慢。
  • 帧时间用 取帧时间():它返回上一帧到这一帧的秒数,与游戏逻辑同源,不要自己去测 时.现() 的差值(那种写法会把渲染耗时算进去)。想做"高速模式"只需把 步长 调小。

3. 碰撞:尾巴这一帧会让位 ​

xuantie
// 尾巴这一帧会让位,所以最后一节不算碰撞
设 i = 0
设 身体节数 = 此.蛇.长度 - 1
当 i < 身体节数 {
    若 此.蛇[i]["列"] == 列 且 此.蛇[i]["行"] == 行 { 此.撞死() ; 返 空 }
    i = i + 1
}

如果吃不到食物,尾巴会在同一步被删掉(此.蛇.删(此.蛇.长度 - 1)),所以"头走到当前尾巴所在格"是合法的——判定必须排除最后一节,否则贴着自己尾巴走就会误判死亡。

移动的写法是"插头 + 删尾":

xuantie
此.蛇.插(0, {"列": 列, "行": 行})     // 新头插到 [0]
设 吃到 = 列 == 此.食物["列"] 且 行 == 此.食物["行"]
若 吃到 {
    此.得分 = 此.得分 + 10
    若 此.得分 > 此.最高 { 此.最高 = 此.得分 }
    此.放食物()                        // 吃到:不删尾 → 变长一格
} 否 {
    此.蛇.删(此.蛇.长度 - 1)            // 没吃到:删尾 → 长度不变
}

插(0, ...) 与 删(长度-1) 是数组的两个基础方法;"头插尾删"让蛇的移动变成 O(1) 的两步操作,不需要整体搬移。

五、绘制:吃满 2D 图元 ​

xuantie
函 格X(列) { 返 列 * 格 }
函 格Y(行) { 返 行 * 格 + HUD高 }     // 顶部留 HUD 条

把"格坐标 → 像素坐标"收敛成两个函数,全篇绘制都不再出现手动乘法。

  • 棋盘底纹:双层循环画 32×32 交替色矩形,(列 + 行) % 2 判断奇偶——这是最省事的棋盘格写法。
  • 食物:绘圆 两层(外圈 + 左上高光小圆),半径按 帧 % 20 做呼吸动画(偏移 > 10 时折返,形成往返而不循环跳变)。
  • 蛇身渐变:越靠尾越暗,
xuantie
设 深 = 40 + i * 6
若 深 > 130 { 深 = 130 }               // 钳位,防止尾部越来越白
R.绘圆角矩形(x + 3, y + 3, 格 - 6, 格 - 6, 40, 60, 90 + 深, 200 + 深 / 2, 255)
  • 蛇头眼睛:按 方向 分四种情况画两个小圆——纯装饰,但让"当前朝向"一眼可见,比箭头更直观。
  • HUD 与结算:绘圆角矩形 当面板底,绘字体文字 写字,绘直线 当分隔线。结算遮罩用半透明黑:
xuantie
R.绘矩形(0, HUD高, 窗宽, 窗高 - HUD高, 0, 0, 0, 160)   // 第四参是 alpha
  • 文字居中:先用 量字体文字(字体, 标题, 40, 2) 量出宽度再算 (窗宽 - 标题宽) / 2——绘字体文字 没有居中参数,居中要自己量。这也是为什么 HUD 文本都要走字体句柄:内置位图字体不认中文。

六、自检:把逻辑抽出来单独测 ​

同一目录下的 贪吃蛇.逻辑测试.xt(138 行)是一份不引渲染库、不开窗口的纯逻辑测试:它把 型 游戏 简化成 型 棋盘(去掉了渲染相关字段、食物位置硬编码),然后按剧本推进并打印结果:

xuantie
设 盘 = 造 棋盘()
示("[T1] 初始 头=" & 盘.头位置() & " 长=" & 字(盘.蛇.长度) & " 得分=" & 字(盘.得分))

盘.步进(); 盘.步进()
示("[T2] 右行2步 头=" & 盘.头位置() & " 长=" & 字(盘.蛇.长度) & " 死=" & 字(盘.死了) & " 得分=" & 字(盘.得分))

盘.转向("左"); 盘.步进()
示("[T3] 请求左转后 头=" & 盘.头位置() & " 方向=" & 盘.方向 & " 死=" & 字(盘.死了))
...

覆盖:初始状态、普通前进(长度不变)、禁止回头、吃到食物(长度 +1、得分 +10)、撞墙、撞自己。输出纯 ASCII、只打印结果行,因此可以直接喂给基线比对工具(tools/regress.py 那种逐行 diff 的路子)。

另一种自检是主文件顶部的"自动截图":

xuantie
常 自检帧数 = 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。

基于 VitePress 静态构建 · 文档内容与仓库 GUIDE/ 同源

玄铁玄铁 XUANTIE · 中文系统级编程语言MARK 玄·折 120×120 · STROKE 8 · NO CURVES