← 都来码 / Dolaima← 例程索引rust/app/snake.rs原文

rust/app/snake.rs · 27489 字节 · 下载原文

// 贪吃蛇 —— 用 **Rust** 写的手机游戏

// Snake -- a mobile game written in **Rust**

//

// 玩法:格子场上一条蛇每 150 毫秒前进一格,吃到红点就长一节、加 10 分、并整体提速一档;

// Gameplay: on a grid the snake advances one cell every 150 ms; eating a red dot grows it by one segment, adds 10 points and speeds the whole game up a notch;

// 撞墙或咬到自己这一局就结束。底下画着四个方向键,手机点它转向;键盘用方向键或 WASD。

// hitting a wall or biting itself ends the round. Four direction buttons are drawn at the bottom for touch steering; the keyboard uses the arrow keys or WASD.

//

// ◆ 为什么这一份值得单独写(打砖块照不到的那条路径)

// ◆ Why this one is worth writing on its own (the path Breakout cannot reach)

//

//   · **一条随时间增长的 Vec 就是蛇本身**:每走一步 `body.push(格号)`,

//   · **A Vec that grows with time is the snake itself**: every step does `body.push(cell_id)`,

//     尾巴靠一个"起点游标"往前挪(不删元素),长度 = `len - tail`。

//     while the tail moves forward through a "start cursor" (no element is ever removed), so the length is `len - tail`.

//   · 于是"自撞"必须**只看还在身上的那一段**,这是 `v.contains` 做不到的(没有"带窗口的 contains"),

//   · Self-collision therefore has to **look only at the window still on the body**, which `v.contains` cannot do (there is no windowed contains),

//     所以这里是手写下标扫描 —— 把"为什么不用 contains"写清楚,免得下次有人顺手改成它。

//     so this file scans subscripts by hand -- and says why, so nobody "simplifies" it into `contains` later.

//

// ◆ 顺带用到的专属库入口(本轮才第一次接通)

// ◆ The Rust-specific library entries used here (only wired up this round)

//

//   `println!`(开局 / 终局摘要,**实测中文正常**)、`rand()`(放食物)、

//   `println!` (start/end summaries, **measured: Chinese works**), `rand()` (food placement),

//   `sleep()`(让撞死的那一格停一下再弹框)、`v.push`(每走一步)、`panic`(屏幕小到放不下时的守卫)。

//   `sleep()` (let the fatal cell stay a moment before the dialog), `v.push` (every step), `panic` (guard for a screen too small to hold the board).

//

// ◆ 两条实测约束(与 `minesweeper.rs` 同源,理由写在那里)

// ◆ Two measured constraints (same source as `minesweeper.rs`, which explains them)

//

//   · 一个程序里**只养一条 Vec**(第二处分配会把先 push 的数据盖掉);

//   · **Keep exactly one Vec alive per program** (a second allocation overwrites what was pushed earlier);

//   · 数组字面量**必须写在一行**;成员调用的接收者排在最后(`v.push(x)` 即 `method_push_1(x, v)`)。

//   · array literals **must sit on one line**; a member call pushes its receiver last (`v.push(x)` is `method_push_1(x, v)`).


// ◆ UI 库的显式引用

//   The explicit reference to the UI library

//

// `use vml_ui::*;` —— 声明文件是 `Lib/rust/vml_ui.rs`(GenLib 生成,155 个 `ui_*`,**不要手改**):

// `use vml_ui::*;` -- the declaration file is `Lib/rust/vml_ui.rs` (generated by GenLib, 155 `ui_*`; **do not hand-edit**):

//

//     cd third_party/vml && dotnet run --project tools/GenLib -- -n -c vmlui -l rust -o Lib/rust/vml_ui.rs

//

// 本前端**不需要**声明就能解析 `ui_*`(它按 `ui_` 前缀自动链 `vmlui.vml`),

// This frontend resolves `ui_*` **without** any declaration (it auto-links `vmlui.vml` by the `ui_` prefix),

// 这条引用是**文档级**的:它标明"这个程序用 UI 库",并让这份例程与其余语言同形。

// so this line is **documentation-level**: it states that this program uses the UI library and keeps

// this example shaped like every other language's.

// 实测(删掉这一行,产物与画面完全一致):见仓库 docs 里那份「UI 库导入约定」的第二节 rust 行。

// Measured: deleting this line changes nothing -- the same instruction count and the same drawn picture;

// see the rust row in section two of the UI-library import convention document under docs.

#include "../../../Lib/rust/vml_ui.rs"
use vml_ui::*;

fn main() {
    let lang = ui_scr_get_language();
    let t_title = if lang == 0 { "贪吃蛇" } else { "Snake" };
    let t_score = if lang == 0 { "得分" } else { "Score" };
    let t_len = if lang == 0 { "长度" } else { "Length" };
    let t_hint = if lang == 0 { "方向键 / WASD 转向 · 空格暂停 · R 重开" } else { "Arrows / WASD to steer - Space pauses - R restarts" };
    let t_pause = if lang == 0 { "已暂停" } else { "PAUSED" };
    let t_dead = if lang == 0 { "撞上了" } else { "GAME OVER" };
    let t_restart = if lang == 0 { "按 R 重开" } else { "Press R to restart" };
    let t_win = if lang == 0 { "整张图都塞满了!\n再来一局?(选「否」退出)" } else { "The board is full!\nPlay again? (choose \"No\" to quit)" };
    let t_lose = if lang == 0 { "这一局结束。\n再来一局?(选「否」退出)" } else { "Round over.\nPlay again? (choose \"No\" to quit)" };

    // 开窗时传 0:尺寸交给宿主(用真实可用区)。**开窗之后**再问一次真实画布 —— 开窗前问到的只是估算值。

    // Pass 0 when opening: the size is the host's to decide (the real usable area). Ask the **real** canvas only after the window is open -- before that the host can only estimate.

    ui_win_open(t_title, 0, 0);
    ui_scr_set_keep_on(1);

    // 可用绘图区(手机上约 395x578)

    // The usable drawing area (about 395x578 on a phone)

    let mut w = ui_scr_get_w();
    let mut h = ui_scr_get_h();
    if w <= 0 { w = 360; }
    if h <= 0 { h = 620; }

    // ── 版面 ──

    // -- Layout --

    // 底下 132 像素留给方向键区(四个按钮),不留的话网格会盖住它们。

    // The bottom 132 px are reserved for the direction pad (four buttons); without them the grid would cover the pad.

    let margin = 6;
    let top = 44;
    // 格边长跟着屏宽走(手机上约 19、平板上大一些),钳在 16~34:

    // The cell size follows the screen width (about 19 on a phone, larger on a tablet), clamped to 16-34:

    // 太小了看不清格子,太大了同一屏就放不下几格(实测 800 宽时固定 20 会在角落缩成一小撮)。

    // too small and the cells are unreadable, too large and only a handful fit on screen (measured: a fixed 20 shrinks to a tiny clump on an 800-wide screen).

    let mut cell = w / 20;
    if cell < 16 { cell = 16; }
    if cell > 34 { cell = 34; }
    let dpadh = 132;
    let cols = (w - margin * 2) / cell;
    let rows = (h - top - dpadh) / cell;

    // 守卫:`--screen 80x160` 这类小到放不下的时候走到这里(消息只写 ASCII,理由见 minesweeper.rs 同一处)。

    // Guard: reached when the screen is too small to hold the board, e.g. `--screen 80x160` (the message is ASCII only; see the same spot in minesweeper.rs for why).

    if cols < 8 || rows < 8 {
        panic("snake: screen too small (need cols >= 8 and rows >= 8)");
    }

    let total = cols * rows;
    let boardw = cols * cell;
    let boardh = rows * cell;

    // 方向键的四个方框(布局只算一次,画与命中判定共用同一组数 —— 各算一遍就会出现"看着在键上、点下去没反应")

    // The four direction buttons (laid out once; drawing and hit testing share the same numbers -- computing them twice gives the classic "looks like it is on the button but the tap does nothing")

    let bw = 60;
    let bh = 54;
    let bx = w / 2 - bw / 2;
    let by = h - 126;

    // 方向表:0=上 1=右 2=下 3=左(下标读写在同一个数组上)

    // Direction tables: 0=up 1=right 2=down 3=left (both subscript reads and writes sit on the same arrays)

    let dxa = [0,1,0,-1];
    let dya = [0 - 1,0,1,0];

    // 蛇身:**全程序只有这一条 Vec**(见文件头)。head 在**末尾**,`tail` 之前的历史不再算数。

    // The body: **the only Vec in the whole program** (see the file header). The head is at the **end**, and history before `tail` no longer counts.

    let mut body = vec![0];
    let mut tail = 0;

    let mut dir = 1;
    let mut ndir = 1;
    let mut score = 0;
    let mut state = 0;
    let mut paused = 0;
    let mut food = 0;
    let mut stepms = 150;
    let mut steps = 0;
    // `pending`:0=进行中 1=撞死 2=通关。终局只在这里记账,收尾在下一轮画完之后做(见那边的注释)。

    // `pending`: 0=playing 1=crashed 2=won. The end of a round is only recorded here; the wrap-up runs next round, after the repaint (see the note there).

    let mut pending = 0;
    let mut quit = 0;

    // 开局的蛇:横躺在中段,头朝右。

    // The opening snake: lying across the middle, head pointing right.

    // ⚠ 头在 Vec 的**末尾**,所以尾部那两节要**先压**、头**最后压** ——

    // ⚠ The head is at the **end** of the Vec, so the two tail segments are pushed **first** and the head **last** --

    //   顺序反了的话开局第一步就"自己撞自己"(实测:三节从左往右压,第一步立刻判死,步数停在 0)。

    //   reversed, the very first step is a "self-bite" (measured: pushing the three cells left-to-right kills it on step one, with the step count stuck at 0).

    let startrow = rows / 2;
    let startcol = cols / 4;
    let mut k = 0;
    while k < 3 {
        body.push(startrow * cols + startcol - 2 + k);
        k = k + 1;
    }
    // 头在末尾 ⇒ 把尾巴游标留成"最后三格"

    // The head is at the end => the tail cursor keeps the last three cells

    tail = body.len() - 3;

    // 第一颗食物:随机撒在蛇以外的地方

    // The first food: dropped at random outside the snake

    let mut ok = 0;
    while ok == 0 {
        let cand = rand() % total;
        let n = body.len();
        let mut i = tail;
        let mut busy = 0;
        while i < n { if body[i] == cand { busy = 1; } i = i + 1; }
        if busy == 0 { food = cand; ok = 1; }
    }

    let mut tid = ui_timer_set(stepms, 0);
    let mut dirty = 1;
    // 运动墙钟欠账(毫秒)—— 按墙钟补步,用户 2026-10-04 口径(定时器只唤醒,走几步看欠了多少)

    // Motion wall-clock debt in ms — steps are caught up from the wall clock (user's 2026-10-04 rule)

    let mut acc = 0;
    // 上次补步取的墙钟时刻(进主循环时初始化)

    // wall-clock time of the last catch-up step (initialized when entering the main loop)

    let mut lastMs = ui_time_get_ms();

    // ⚠ `println!("{}", 字符串变量)` 打的是**地址**;所以摘要按语言各写一条字面量。

    // ⚠ `println!("{}", some_string_variable)` prints the **address**; so each summary is written as a per-language literal.

    if lang == 0 {
        println!("贪吃蛇开局 {}x{} 格={} 步间隔={}ms", cols, rows, total, stepms);
    } else {
        println!("snake start {}x{} cells={} step={}ms", cols, rows, total, stepms);
    }

    while ui_win_is_closed() == 0 {

      // ── 画(只在这幅画真的变了的时候)──

      // -- draw (only when the picture really changed) --

      if dirty != 0 {
        ui_2d_clear(-15724520);
        ui_2d_rect(0, 0, w, top - 4, -14932944, 1, 0, 0);
        ui_2d_text(8, 10, t_title, -1, 15, VML_ANCHOR_LEFT);

        // 分数与长度:`int_to_str` 把整数变成字符串再交给 `ui_2d_text`(这一门没有字符串拼接)。

        // Score and length: `int_to_str` turns the integers into strings for `ui_2d_text` (this language has no string concatenation).

        // ⚠ `int_to_str` 的返回值指向**同一块静态缓冲**:先把两个结果都取出来再画,

        // ⚠ The value returned by `int_to_str` points at **one shared static buffer**: taking both results first and drawing them afterwards

        //   后一次会把前一次盖掉(实测:分数与长度两处**都显示成 3**,而那局的分数是 0)。

        //   lets the later call overwrite the earlier one (measured: score and length both displayed 3 while that round's score was 0).

        //   所以每处都是"取一次、立刻用掉"。

        //   So each site takes its string and consumes it immediately.

        let sc = int_to_str(score);
        ui_2d_text(w - 8, 8, sc, -11702, 15, VML_ANCHOR_RIGHT);
        ui_2d_text(w - 8, 26, t_score, -7695200, 10, VML_ANCHOR_RIGHT);
        let ln = int_to_str(body.len() - tail);
        ui_2d_text(w - 74, 8, ln, -11884289, 15, VML_ANCHOR_RIGHT);
        ui_2d_text(w - 74, 26, t_len, -7695200, 10, VML_ANCHOR_RIGHT);

        // 场地:一块底色 + 一圈边框(**不逐格画** —— 19x20 就是 380 条绘制指令,每帧都发会拖垮手机)

        // The field: one background fill plus a border (**not cell by cell** -- 19x20 would be 380 draw commands and would drag the phone down every frame)

        ui_2d_rect(margin, top, boardw, boardh, -15262170, 1, 0, 4);
        ui_2d_rect(margin, top, boardw, boardh, -12299158, 0, 2, 4);

        // 蛇身:从尾巴游标扫到头。隔一节换个颜色,才看得出"哪一节在哪"。

        // The body: scan from the tail cursor to the head. Every other segment changes color so the segments are distinguishable.

        let n = body.len();
        let mut i = tail;
        while i < n {
            let id = body[i];
            let fx = margin + (id % cols) * cell;
            let fy = top + (id / cols) * cell;
            if i == n - 1 { ui_2d_rect(fx + 2, fy + 2, cell - 4, cell - 4, -4784277, 1, 0, 5); }
            else {
                if i % 2 == 0 { ui_2d_rect(fx + 2, fy + 2, cell - 4, cell - 4, -12855708, 1, 0, 4); }
                else { ui_2d_rect(fx + 2, fy + 2, cell - 4, cell - 4, -13981620, 1, 0, 4); }
            }
            i = i + 1;
        }

        // 食物

        // The food

        ui_2d_circle(margin + (food % cols) * cell + cell / 2, top + (food / cols) * cell + cell / 2, cell / 2 - 3, -49088, 1, 0);

        // 方向键:按下方向时那一键高亮,玩家才知道自己按键生效了

        // The direction pad: the button for the current direction is highlighted so the player can see the press registered

        if ndir == 0 { ui_2d_rect(bx, by, bw, bh, -11884289, 1, 0, 8); } else { ui_2d_rect(bx, by, bw, bh, -13747640, 1, 0, 8); }
        if ndir == 3 { ui_2d_rect(bx - bw - 6, by + bh + 6, bw, bh, -11884289, 1, 0, 8); } else { ui_2d_rect(bx - bw - 6, by + bh + 6, bw, bh, -13747640, 1, 0, 8); }
        if ndir == 2 { ui_2d_rect(bx, by + bh + 6, bw, bh, -11884289, 1, 0, 8); } else { ui_2d_rect(bx, by + bh + 6, bw, bh, -13747640, 1, 0, 8); }
        if ndir == 1 { ui_2d_rect(bx + bw + 6, by + bh + 6, bw, bh, -11884289, 1, 0, 8); } else { ui_2d_rect(bx + bw + 6, by + bh + 6, bw, bh, -13747640, 1, 0, 8); }
        ui_2d_text(bx + bw / 2, by + bh / 2 - 6, "W", -1, 16, VML_ANCHOR_CENTER);
        ui_2d_text(bx - bw - 6 + bw / 2, by + bh + 6 + bh / 2 - 6, "A", -1, 16, VML_ANCHOR_CENTER);
        ui_2d_text(bx + bw / 2, by + bh + 6 + bh / 2 - 6, "S", -1, 16, VML_ANCHOR_CENTER);
        ui_2d_text(bx + bw + 6 + bw / 2, by + bh + 6 + bh / 2 - 6, "D", -1, 16, VML_ANCHOR_CENTER);

        ui_2d_text(w / 2, top - 26, t_hint, -7695200, 10, VML_ANCHOR_CENTER);
        if paused != 0 { ui_2d_text(w / 2, top + boardh / 2 - 10, t_pause, -11702, 22, VML_ANCHOR_CENTER); }
        if state != 0 {
            ui_2d_text(w / 2, top + boardh / 2 - 10, t_dead, -49088, 22, VML_ANCHOR_CENTER);
            ui_2d_text(w / 2, top + boardh / 2 + 18, t_restart, -1, 14, VML_ANCHOR_CENTER);
        }
        ui_2d_present();
        dirty = 0;
      }

      // ── 终局收尾:**在画完之后**才响铃、停留、弹框(这样屏幕上那一帧才有"撞上了")──

      // -- End-of-round wrap-up: the beep, the pause and the dialog happen **after** the repaint (so the frame on screen really shows "GAME OVER") --

      if pending != 0 {
          if pending == 1 { ui_audio_beep(131, 320); } else { ui_audio_beep(1047, 320); }
          // 让终局那一帧停一下再弹框(单音只有几百毫秒)

          // Let the final frame stay a moment before the dialog (a single-tone beep lasts only a few hundred ms)

          sleep(300);
          if lang == 0 {
              if pending == 1 {
                  println!("贪吃蛇结束 撞了 得分={} 长度={} 步数={}", score, body.len() - tail, steps);
              } else {
                  println!("贪吃蛇结束 通关 得分={} 长度={} 步数={}", score, body.len() - tail, steps);
              }
          } else {
              if pending == 1 {
                  println!("snake end DEAD score={} length={} steps={}", score, body.len() - tail, steps);
              } else {
                  println!("snake end WIN score={} length={} steps={}", score, body.len() - tail, steps);
              }
          }
          let msg = if pending == 1 { t_lose } else { t_win };
          let again = ui_dlg_msg(t_title, msg, VML_DLG_INFO);
          if again != 0 { quit = 1; break; }
          // 重开:往同一条 Vec 上压一个新头,尾巴游标挪到它上面 —— 旧历史留在前面,不算数

          // Restart: push a new head onto the same Vec and move the tail cursor onto it -- the old history stays in front and no longer counts

          let sr = rows / 2;
          let scol = cols / 4 + 2;
          body.push(sr * cols + scol);
          tail = body.len() - 1;
          dir = 1;
          ndir = 1;
          score = 0;
          steps = 0;
          stepms = 150;
          state = 0;
          paused = 0;
          pending = 0;
          ui_timer_kill(tid);
          tid = ui_timer_set(stepms, 0);
          dirty = 1;
      }

        // ── 收一条消息 ──

        // -- take one message --

        let t = ui_msg_fetch_wait(0);
        if t == 10 { break; }

        if t == 9 {
            // 这一帧该走几步:**按墙钟欠账**(各平台定时器分辨率不同 ⇒ 不能拿"收到几拍"当时间)。

            // how many steps this frame takes: **driven by the wall-clock deficit** (timer resolution differs per platform => never treat "ticks received" as time).

            // 用户 2026-10-04 口径:所有游戏时钟取系统毫秒数。定时器只负责唤醒。

            // user's 2026-10-04 rule: all game clocks read system milliseconds; the timer only wakes the loop.

            let nowMs = ui_time_get_ms();
            acc = acc + (nowMs - lastMs);
            lastMs = nowMs;
            if acc > stepms * 8 { acc = 0; }      // 卡太久别一次补一大串(防瞬移)

                                                  // // do not catch up in one huge burst after a long stall (prevents teleporting)

            while acc >= stepms {
                acc = acc - stepms;
                if state != 0 || paused != 0 { continue; }   // 暂停/结束:只把欠账消耗掉,不走

                                                             // // paused/finished: consume the deficit without stepping

                // 应用这一拍收到的方向(不允许 180 度掉头:掉头等于立刻咬到自己)

                // Apply the direction collected for this step (a 180-degree turn is refused: it is an instant self-bite)

                if ndir != dir && (ndir + 2) % 4 != dir { dir = ndir; }
                let head = body[body.len() - 1];
                let hx = head % cols;
                let hy = head / cols;
                let nx = hx + dxa[dir];
                let ny = hy + dya[dir];
                let mut dead = 0;
                if nx < 0 || nx >= cols || ny < 0 || ny >= rows { dead = 1; }
                let nh = ny * cols + nx;
                if dead == 0 {
                    // 自撞:**只看还在身上的那一段**(`tail` 到"头的前一格")。

                    // Self-bite: **only the window still on the body** (`tail` up to the cell before the head).

                    // 尾巴那一格**不算** —— 它下一步就让开了,把它算成墙是很多实现里"明明没撞却死了"的来源。

                    // The tail cell **does not count** -- it vacates on the next step, and counting it is where many implementations kill a snake that clearly did not crash.

                    // 这里不用 `v.contains`:它只能查整条历史,而历史里全是早就让开的格子。

                    // `v.contains` is not used here: it can only scan the whole history, which is full of cells long since vacated.

                    let n = body.len();
                    let mut i = tail;
                    while i < n - 1 {
                        if body[i] == nh { dead = 1; }
                        i = i + 1;
                    }
                }
                if dead != 0 {
                    // 这一拍**只标状态**:响铃 + 停留 + 弹框都挪到下一轮"画完之后"做。

                    // This step **only marks the state**: the beep, the pause and the dialog all move to the next round, after the repaint.

                    // 原来在这里直接 `ui_2d_present()` 再弹框,抓到的那一帧是**撞之前**的画面(实测:死亡帧里没有"撞上了"那行字)。

                    // Doing `ui_2d_present()` and the dialog right here captured the frame from **before** the crash (measured: the death frame had no "GAME OVER" on it).

                    state = 1;
                    dirty = 1;
                    pending = 1;
                } else {
                    body.push(nh);
                    steps = steps + 1;
                    if nh == food {
                        // 吃到:**不挪尾巴游标** ⇒ 长度 +1

                        // Eaten: the tail cursor **does not move**, so the length grows by one

                        score = score + 10;
                        ui_audio_beep(1047, 60);
                        // 提速一档(下限 70 毫秒,再快就没法玩了)

                        // Speed up one notch (floor at 70 ms; faster than that is unplayable)

                        let want = 150 - score / 10 * 6;
                        if want < 70 { stepms = 70; } else { stepms = want; }
                        ui_timer_kill(tid);
                        tid = ui_timer_set(stepms, 0);
                        // 换一颗食物:随机撒,且不能落在蛇身上、也不能和原来那颗重合

                        // A new food: placed at random, not on the snake and not the same cell as before

                        let mut ok2 = 0;
                        while ok2 == 0 {
                            let cand = rand() % total;
                            let n2 = body.len();
                            let mut i2 = tail;
                            let mut busy2 = 0;
                            while i2 < n2 { if body[i2] == cand { busy2 = 1; } i2 = i2 + 1; }
                            if busy2 == 0 && cand != food { food = cand; ok2 = 1; }
                        }
                        // 蛇占满整张图 = 通关(与撞死同一条收尾路径,只是 `pending` 的值不同)

                        // The snake filling the whole board means a win (the same wrap-up path as a crash, only `pending` differs)

                        if body.len() - tail == total {
                            state = 2;
                            dirty = 1;
                            pending = 2;
                        }
                    } else {
                        // 没吃到:尾巴让开一格

                        // Not eaten: the tail vacates one cell

                        tail = tail + 1;
                    }
                    dirty = 1;
                }
            }
        }

        if t == 1 {
            let k = ui_msg_get_a();
            if k == 27 { quit = 1; break; }
            if k == 82 || k == 114 {
                // R 重开(与撞死那条路同一套复位)

                // R restarts (the same reset the death path performs)

                let sr = rows / 2;
                let scol = cols / 4 + 2;
                body.push(sr * cols + scol);
                tail = body.len() - 1;
                dir = 1;
                ndir = 1;
                score = 0;
                steps = 0;
                stepms = 150;
                state = 0;
                paused = 0;
                ui_timer_kill(tid);
                tid = ui_timer_set(stepms, 0);
                dirty = 1;
            }
            if k == 32 { if paused == 0 { paused = 1; } else { paused = 0; } dirty = 1; }
            // 转向:与当前方向相反的一律丢掉(`ndir` 只在下一拍生效,所以同一拍里连按两下也不会掉头)

            // Steering: anything opposite the current direction is dropped (`ndir` only takes effect on the next step, so two quick presses in one step cannot reverse either)

            if state == 0 && paused == 0 {
                if k == 38 || k == 87 { if dir != 2 { ndir = 0; } dirty = 1; }
                if k == 39 || k == 68 { if dir != 3 { ndir = 1; } dirty = 1; }
                if k == 40 || k == 83 { if dir != 0 { ndir = 2; } dirty = 1; }
                if k == 37 || k == 65 { if dir != 1 { ndir = 3; } dirty = 1; }
            }
        }

        // 触摸区的四个按钮(与"+键"同一条路:按下即转向)

        // The four touch buttons (the same path as the keyboard: a press steers immediately)

        if (t == 6 || t == 4) && state == 0 && paused == 0 {
            let mx = ui_msg_get_a();
            let my = ui_msg_get_b();
            let mut hit = 0 - 1;
            if mx >= bx && mx < bx + bw && my >= by && my < by + bh { hit = 0; }
            if mx >= bx - bw - 6 && mx < bx - 6 && my >= by + bh + 6 && my < by + bh * 2 + 6 { hit = 3; }
            if mx >= bx && mx < bx + bw && my >= by + bh + 6 && my < by + bh * 2 + 6 { hit = 2; }
            if mx >= bx + bw + 6 && mx < bx + bw * 2 + 6 && my >= by + bh + 6 && my < by + bh * 2 + 6 { hit = 1; }
            if hit >= 0 && hit != (dir + 2) % 4 { ndir = hit; dirty = 1; }
        }
    }

    // 收尾摘要(被墙钟叫停时也会走到这里)

    // Final summary (also reached when the wall clock stops the run)

    if quit == 0 {
        if lang == 0 {
            println!("贪吃蛇结束 退出 得分={} 长度={} 步数={}", score, body.len() - tail, steps);
        } else {
            println!("snake end quit score={} length={} steps={}", score, body.len() - tail, steps);
        }
    }
    ui_timer_kill(tid);
    ui_scr_set_keep_on(0);
    ui_win_close();
}