Skip to content

「例程」 宝石猎手(3D 第三人称收集) ​

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

实机演示 ​

源代码 ​

xuantie
// 由玄铁铸造厂官方内置AI开发。
// 宝石猎手 — 玄铁渲染库 3D 狗粮试作
// 玩法:WASD/方向键 移动,空格冲刺,收集金色旋转方块;60 秒一局,R 重开,ESC 退出。
// 编译(仓库根目录):  ./build/xtc_s4.exe 铁 temp/game/宝石猎手.xt -sc temp/game/宝石猎手.exe
// 无窗口自检:        ./temp/game/宝石猎手.exe --自检
外 函 xt_get_args(): 数组
引 "渲染" 予 R

// ---------- 可调参数 ----------
设 场地半 = 25.0
设 玩速 = 9.0
设 冲刺倍 = 1.8
设 收集判定 = 1.3
设 局时长 = 60.0
设 目标数 = 24

// ---------- 纯逻辑(自检覆盖,不碰窗口) ----------
函 距离平方(ax, az, bx, bz) {
    设 dx = ax - bx
    设 dz = az - bz
    返 dx * dx + dz * dz
}

函 夹取(v, 下, 上) {
    若 v < 下 { 返 下 }
    若 v > 上 { 返 上 }
    返 v
}

// 均匀随机落点:数学.随机(20001) 给 0..20000 → 映射 [-25.0, 25.0]
函 随机坐标() {
    返 (数学.随机(20001) - 10000) / 400.0
}

函 新目标() {
    返 {"x": 随机坐标(), "z": 随机坐标(), "相位": 数学.随机(628) / 100.0}
}

// ---------- 自检:--自检 时不进窗口 ----------
函 自检() {
    设 通过 = 0
    设 失败 = 0
    若 距离平方(0.0, 0.0, 3.0, 4.0) == 25.0 { 通过 = 通过 + 1 } 否 { 失败 = 失败 + 1; 示("FAIL 距离平方(3,4)≠25") }
    若 夹取(30.0, -25.0, 25.0) == 25.0 { 通过 = 通过 + 1 } 否 { 失败 = 失败 + 1; 示("FAIL 夹取上界") }
    若 夹取(-99.0, -25.0, 25.0) == -25.0 { 通过 = 通过 + 1 } 否 { 失败 = 失败 + 1; 示("FAIL 夹取下界") }
    若 夹取(7.5, -25.0, 25.0) == 7.5 { 通过 = 通过 + 1 } 否 { 失败 = 失败 + 1; 示("FAIL 夹取中值") }
    设 i = 0
    设 见负 = 假
    设 见正 = 假
    设 越界 = 假
    当 i < 3000 {
        设 v = 随机坐标()
        若 v < -25.0 或 v > 25.0 { 越界 = 真; 断 }
        若 v < 0.0 { 见负 = 真 }
        若 v > 0.0 { 见正 = 真 }
        i = i + 1
    }
    若 非 越界 且 见负 且 见正 { 通过 = 通过 + 1 } 否 { 失败 = 失败 + 1; 示("FAIL 随机坐标: 越界=" & 越界.转字()) }
    示("自检: 通过 " & 通过.转字() & " 项, 失败 " & 失败.转字() & " 项")
    若 失败 == 0 { 示("自检全部通过"); 终 0 } 否 { 终 1 }
}

// 命令行 --自检(argv[0] 是程序自身路径,全表扫描无害)
设 参数 = xt_get_args()
设 求自检 = 假
设 ai = 0
当 ai < 参数.长度 {
    若 参数[ai] == "--自检" { 求自检 = 真 }
    ai = ai + 1
}
若 求自检 { 自检() }

// ---------- 窗口与资源 ----------
R.初始化窗口(1280, 720, "宝石猎手 — 玄铁 3D 试作")
R.设置目标帧率(60)
设 字体 = R.加载系统字体("微软雅黑", 30)
// 合成音效(零资产,免 wav):拾取短音 + 终局长音
R.初始化音频()
设 拾取音 = R.合成音效(880, 90)
设 结束音 = R.合成音效(220, 420)

// ---------- 局面 ----------
设 目标 = []
设 gi = 0
当 gi < 目标数 {
    目标.追加(新目标())
    gi = gi + 1
}

设 玩家x = 0.0
设 玩家z = 0.0
设 得分 = 0
设 结束 = 假
设 开始时刻 = R.取运行时间()
设 上帧 = R.取运行时间()

// HUD:字体句柄为 0 时回退 raylib 默认字体(仅 ASCII)
函 绘HUD(句柄, 字号, 文本, x, y, r, g, b, a) {
    若 句柄 != 0 {
        R.绘字体文字(句柄, 文本, x, y, 字号, 1, r, g, b, a)
    } 否 {
        R.绘文字(文本, x, y, 字号, r, g, b, a)
    }
}

// ---------- 主循环 ----------
当 非 R.窗口应关闭?() {
    设 现在 = R.取运行时间()
    设 dt = 现在 - 上帧
    上帧 = 现在
    设 剩余 = 局时长 - (现在 - 开始时刻)

    若 非 结束 且 剩余 > 0.0 {
        // 移动(斜向乘 1/√2 近似归一)
        设 速 = 玩速
        若 R.键按下?(R.键空格) { 速 = 玩速 * 冲刺倍 }
        设 vx = 0.0
        设 vz = 0.0
        若 R.键按下?(R.键W) 或 R.键按下?(R.键上) { vz = vz + 1.0 }
        若 R.键按下?(R.键S) 或 R.键按下?(R.键下) { vz = vz - 1.0 }
        // 相机在玩家后方朝 +Z 看:该朝向下面向右 = -X,故 A/左 对应 +X(实测校准,勿"修回")
        若 R.键按下?(R.键A) 或 R.键按下?(R.键左) { vx = vx + 1.0 }
        若 R.键按下?(R.键D) 或 R.键按下?(R.键右) { vx = vx - 1.0 }
        若 vx != 0.0 且 vz != 0.0 { vx = vx * 0.7071; vz = vz * 0.7071 }
        玩家x = 夹取(玩家x + vx * 速 * dt, -场地半, 场地半)
        玩家z = 夹取(玩家z + vz * 速 * dt, -场地半, 场地半)

        // 收集判定(平方距离免开方)
        设 ci = 0
        当 ci < 目标.长度 {
            设 k = 目标[ci]
            若 距离平方(玩家x, 玩家z, k["x"], k["z"]) < 收集判定 * 收集判定 {
                得分 = 得分 + 1
                若 拾取音 != 0 { R.播音效(拾取音) }
                k["x"] = 随机坐标()
                k["z"] = 随机坐标()
                k["相位"] = 数学.随机(628) / 100.0
            }
            ci = ci + 1
        }
    } 否 {
        若 剩余 <= 0.0 且 非 结束 {
            结束 = 真
            若 结束音 != 0 { R.播音效(结束音) }
        }
        若 结束 且 R.键刚按下?(R.键R) {
            得分 = 0
            结束 = 假
            开始时刻 = 现在
            玩家x = 0.0
            玩家z = 0.0
            设 ri = 0
            当 ri < 目标.长度 {
                设 k2 = 目标[ri]
                k2["x"] = 随机坐标()
                k2["z"] = 随机坐标()
                k2["相位"] = 数学.随机(628) / 100.0
                ri = ri + 1
            }
        }
    }

    // ---------- 绘制 ----------
    R.开始绘图()
    R.清背景(18, 22, 34, 255)

    // 第三人称跟随:后上方看向玩家前方
    R.设置相机3D(玩家x, 8.5, 玩家z - 11.0, 玩家x, 0.0, 玩家z + 3.0, 60.0)
    R.进入3D()

    // 地面(扁立方体)+ 网格
    R.绘立方体(0.0, -0.6, 0.0, 60.0, 1.2, 60.0, 28, 34, 48, 255)
    R.绘地面网格(30, 2.0)

    // 目标:矩阵栈旋转 + 正弦浮动
    设 di = 0
    当 di < 目标.长度 {
        设 k3 = 目标[di]
        设 高度 = 0.9 + 数学.正弦(现在 * 2.0 + k3["相位"]) * 0.25
        R.推矩阵()
        R.平移3D(k3["x"], 高度, k3["z"])
        R.旋转3D(现在 * 90.0 + k3["相位"] * 57.0, 0.3, 1.0, 0.15)
        R.绘立方体(0.0, 0.0, 0.0, 0.7, 0.7, 0.7, 255, 200, 40, 255)
        R.绘立方体线(0.0, 0.0, 0.0, 0.76, 0.76, 0.76, 255, 236, 150, 200)
        R.弹矩阵()
        di = di + 1
    }

    // 玩家(橙方 + 亮线框)
    R.绘立方体(玩家x, 0.5, 玩家z, 0.9, 0.9, 0.9, 235, 130, 60, 255)
    R.绘立方体线(玩家x, 0.5, 玩家z, 0.96, 0.96, 0.96, 255, 224, 190, 255)

    R.退出3D()

    // ---------- HUD ----------
    设 剩余显 = 0
    若 剩余 > 0.0 { 剩余显 = 数学.取整(剩余) }
    绘HUD(字体, 30, "得分: " & 得分.转字() & " / " & 目标数.转字(), 24, 20, 235, 240, 250, 255)
    绘HUD(字体, 30, "剩余: " & 剩余显.转字() & " 秒", 24, 60, 255, 210, 90, 255)
    若 结束 {
        绘HUD(字体, 46, "时间到!得分 " & 得分.转字(), 440, 300, 255, 238, 170, 255)
        绘HUD(字体, 26, "按 R 重新开始,ESC 退出", 500, 366, 205, 215, 235, 255)
    }
    绘HUD(字体, 20, "WASD/方向键 移动 · 空格 冲刺 · R 重开 · ESC 退出", 24, 682, 140, 156, 182, 255)

    R.结束绘图()
}

// ---------- 收尾 ----------
若 拾取音 != 0 { R.释音效(拾取音) }
若 结束音 != 0 { R.释音效(结束音) }
R.关闭音频()
若 字体 != 0 { R.释字体(字体) }
R.关闭窗口()
示("游戏结束 — 得分 " & 得分.转字())

一、它是什么 ​

第三人称小游戏:你是一个橙色方块,在 50×50 的场地里移动,吃掉 24 个金色旋转方块;60 秒一局,时间到结算,按 R 重开。

操作键
移动W A S D 或方向键
冲刺(1.8×)空格
重开R(结算后)
退出ESC / 关闭窗口
无窗口自检命令行带 --自检

二、编译与运行 ​

bash
# 仓库根目录
./build/xtc_s4.exe 铁 temp/game/宝石猎手.xt -sc temp/game/宝石猎手.exe
./temp/game/宝石猎手.exe            # 正常游玩
./temp/game/宝石猎手.exe --自检      # 只跑纯逻辑断言,不弹窗

--自检 是这份例程最实用的部分之一:CI 或无人值守环境里不能弹窗,但又要验证逻辑没坏,就把逻辑抽成纯函数、用命令行开关跑断言。

三、主循环骨架(每帧六步) ​

玄铁渲染程序的骨架就这几行,其余全是"往里面填":

xuantie
当 非 R.窗口应关闭?() {
    设 现在 = R.取运行时间()          // 1. 时间
    设 dt = 现在 - 上帧
    上帧 = 现在
    若 非 结束 且 剩余 > 0.0 {        // 2. 模拟(只在局内推进)
        ... 读输入 → 算位移 → 收集判定 ...
    }
    R.开始绘图()                      // 3. 开帧
    R.清背景(18, 22, 34, 255)
    R.设置相机3D(...)                 // 4. 3D 相机
    R.进入3D()
    ... 绘地面 / 绘目标 / 绘玩家 ...
    R.退出3D()
    ... 绘 HUD ...                    // 5. 2D 叠加(在 3D 之外)
    R.结束绘图()                      // 6. 收帧
}

要点:进入3D() / 退出3D() 是一对括号——3D 图元必须画在两者之间;HUD 画在 退出3D() 之后,才不会被透视投影缩放。窗口尺寸不管,清背景 与 HUD 坐标按逻辑分辨率写(本例固定 1280×720)。

四、三个值得抄的手法 ​

1. 时间用"取运行时间差",不用帧数 ​

xuantie
设 现在 = R.取运行时间()
设 dt = 现在 - 上帧
上帧 = 现在
玩家x = 夹取(玩家x + vx * 速 * dt, -场地半, 场地半)

按 dt 推进,机器快慢都不影响手感;按帧数推进则帧率一变速度就变。设置目标帧率(60) 只是上限,不是保证。

2. 收集判定用平方距离,免开方 ​

xuantie
函 距离平方(ax, az, bx, bz) {
    设 dx = ax - bx
    设 dz = az - bz
    返 dx * dx + dz * dz
}
// 判定:平方距离 < 判定半径²
若 距离平方(玩家x, 玩家z, k["x"], k["z"]) < 收集判定 * 收集判定 { ... }

每帧要对 24 个目标各算一次,开方没必要——比较平方值等价,且省掉 数学.平方根。注意拿"半径的平方"去比,不要拿半径比(这是最常见的写错点)。

3. 3D 物体用"矩阵栈 + 正弦浮动" ​

想画一堆各自旋转、各自上下浮的方块,不要手算每个顶点,用矩阵栈:

xuantie
设 高度 = 0.9 + 数学.正弦(现在 * 2.0 + k3["相位"]) * 0.25   // 各自相位 → 不同步
R.推矩阵()
R.平移3D(k3["x"], 高度, k3["z"])                            // 挪到目标点
R.旋转3D(现在 * 90.0 + k3["相位"] * 57.0, 0.3, 1.0, 0.15)    // 绕轴自转
R.绘立方体(0.0, 0.0, 0.0, 0.7, 0.7, 0.7, 255, 200, 40, 255)  // 在"原点"画标准方块
R.绘立方体线(0.0, 0.0, 0.0, 0.76, 0.76, 0.76, 255, 236, 150, 200)
R.弹矩阵()

推矩阵 → 变换 → 在原点画标准图元 → 弹矩阵 是 3D 绘制的通用套路:几何体本身永远是"标准尺寸、原点居中",位置与姿态全交给矩阵。相位 是给每个目标存的随机初相(数学.随机(628) / 100.0 ≈ 0..2π),让 24 个方块不同步旋转/浮动。

五、逐段讲解 ​

可调参数集中在文件头 ​

xuantie
设 场地半 = 25.0     // 场地是 ±25 的方形
设 玩速 = 9.0        // 单位/秒
设 冲刺倍 = 1.8
设 收集判定 = 1.3    // 判定半径(比较时用其平方)
设 局时长 = 60.0
设 目标数 = 24

调手感只改这里。别把这些数字散落在循环体里——这是小游戏最容易失控的地方。

目标用字典数组表示 ​

xuantie
函 新目标() {
    返 {"x": 随机坐标(), "z": 随机坐标(), "相位": 数学.随机(628) / 100.0}
}

玄铁的字典可以直接存小数与整数混合,读时 k["x"]。移动目标就是改字段:

xuantie
k["x"] = 随机坐标()
k["z"] = 随机坐标()
k["相位"] = 数学.随机(628) / 100.0

注意:吃到一个方块不是删除它,而是把它挪到新位置。目标数 恒为 24、得分 单独计数——这样"场上永远有 24 个",逻辑最简单。真要做"吃完就没了",把 得分 == 目标数 当作提前结束条件即可。

斜向移动要归一 ​

xuantie
若 vx != 0.0 且 vz != 0.0 { vx = vx * 0.7071; vz = vz * 0.7071 }

同时按 W+D 时两轴各给 1.0,合速度是 √2 倍——不处理就会"斜着走更快"。乘 1/√2 ≈ 0.7071 即可(不需要每次都调 数学.平方根)。

相机跟随(每帧重设一次) ​

xuantie
R.设置相机3D(玩家x, 8.5, 玩家z - 11.0,     // 眼睛:玩家后上方
             玩家x, 0.0, 玩家z + 3.0,      // 看向:玩家前方一点
             60.0)                          // 视场角

七个参数每帧重写一次就实现了"跟随"。代价是可读性:这行是整份代码里最"魔法"的地方,参数调一次就记不住各是什么。已在 3D 狗粮的观察报告里记为库侧改进项(建议提供句柄式相机);该报告随 demo 源码放在工作区的 temp/game/观察报告.md(不在仓库内、不随站点发布,故此处只按路径引用,见本文末「源码位置」)。

实测校准过的坑(别"修回去"):相机在玩家后方朝 +Z 看时,屏幕上的"右"对应世界 -X,所以 A/左 要让 vx + 1、D/右 让 vx - 1。写反了操作会左右颠倒,代码里留了注释。

HUD:字体句柄为 0 时自动降级 ​

xuantie
函 绘HUD(句柄, 字号, 文本, x, y, r, g, b, a) {
    若 句柄 != 0 {
        R.绘字体文字(句柄, 文本, x, y, 字号, 1, r, g, b, a)
    } 否 {
        R.绘文字(文本, x, y, 字号, r, g, b, a)      // raylib 内置位图字体,仅 ASCII
    }
}

加载系统字体("微软雅黑", 30) 拿不到字体时返回 0。有了这层兜底,中文 HUD 在缺字体的机器上会退化成方块/空白而不是崩,同时 ASCII 部分仍可读。发布前请在目标机确认字体加载成功(见 发布前冒烟清单)。

零资产音效:合成音效 ​

xuantie
R.初始化音频()
设 拾取音 = R.合成音效(880, 90)     // 880Hz、90ms
设 结束音 = R.合成音效(220, 420)    // 220Hz、420ms
...
若 拾取音 != 0 { R.播音效(拾取音) }
...
R.释音效(拾取音); R.释音效(结束音); R.关闭音频()

不需要任何 wav 素材就能有声音反馈——做原型时非常省事。(需要真实音效文件时用 加载音效 系列。)句柄为 0 表示创建失败,所有播放/释放都先判 0。

六、自检(不进窗口) ​

xuantie
函 自检() {
    ... 距离平方 / 夹取边界 / 随机落点范围 共 5 项断言 ...
    若 失败 == 0 { 示("自检全部通过"); 终 0 } 否 { 终 1 }
}

// 命令行:argv[0] 是程序自身路径,全表扫描无害
设 参数 = xt_get_args()
设 求自检 = 假
当 ai < 参数.长度 { 若 参数[ai] == "--自检" { 求自检 = 真 } ; ai = ai + 1 }
若 求自检 { 自检() }

三个要点:

  1. xt_get_args() 不是语言关键字,用户程序要拿命令行参数必须自己声明 外 函 xt_get_args(): 数组(本文件从编译器驱动 玄铁.xt 抄的写法);
  2. 自检必须在 初始化窗口 之前返回/退出,否则还是会弹窗;
  3. 随机数断言要跑足次数(本例 3000 次)并同时验"见过负值、见过正值、从未越界",单次取值断言不了分布。

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

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