← 都来码 / Dolaima← 例程索引kotlin/app/snake.kt原文

kotlin/app/snake.kt · 34152 字节 · 下载原文

// 贪吃蛇 —— 用 **Kotlin** 写的手机游戏(网格竞技场、实时、吃到食物变长提速)

// Snake — a phone game written in **Kotlin** (grid arena, real time, grows and speeds up as you eat)

//

// 玩法:方向键 / 屏幕十字键控制蛇头,吃到红点就长一格并加一分,撞墙或咬到自己就结束。

// Play: steer the head with the arrow keys / the on-screen D-pad, eat a red dot to grow one cell and

// score a point; hitting a wall or your own body ends the run.

//

// ## 两个品类里它负责"实时动作"那一档

// ## Of the two new games this one covers the **real-time action** slot

//

// Kotlin 目录原先只有 `gomoku.kt`(回合制棋类)与 `puzzle15.kt`(滑块谜题)——两份都是 "你一步、它一步"的静态游戏。这一份是**定时器驱动**的:蛇自己走,玩家只改变方向。

// The Kotlin folder used to hold only `gomoku.kt` (turn-based board game) and `puzzle15.kt` (sliding

// puzzle) — both static "you move, it moves". This one is **timer-driven**: the snake moves on its own

// and the player only changes its direction.

//

// ## 三条**只有真跑起来才发现**的设计点

// ## Three design points that **only showed up by actually running it**

//

//   · **网格坐标 + 整数计时**:位置一律用"格"而不是像素,一个方向一次走**一整格**。 用像素的话蛇会停在半格上 —— 判定用取整后的格号,于是"看着在格子里、其实算作下一格"。

//   · **grid coordinates + integer timing**: positions are in **cells**, not pixels, and one tick moves

//     exactly one cell. With pixel positions the snake parks half a cell off and the collision test

//     (which floors to a cell index) disagrees with what you see.

//   · **不允许 180° 掉头**:一次只走一格,若允许反向,头会立刻撞进自己的脖子。 这里把"待生效方向"与"当前方向"分开存,掉头的输入直接丢掉。

//   · **no 180° reversal**: one tick is one cell, so reversing instantly drives the head into its own

//     neck. The pending direction is stored separately from the live one and a reversal is dropped.

//   · **吃掉的那一格,尾巴不走**:自撞判定要**跳过尾巴那一格**(不进食时尾巴会腾出来), 否则"追着自己的尾巴跑"这个最基本的操作会被判死。进食时尾巴不腾,就得把它算进去。

//   · **when eating, the tail does not move**: the self-collision test must **skip the tail cell**

//     (it vacates when not eating), otherwise the most basic "chase your own tail" move is fatal.

//     When eating, the tail stays, so it must be counted.

//

// ## 这一份例程用到的 Kotlin 特性

// ## Kotlin features used here

//

//   · **字符串模板**(`"得分 $gScore"`)—— 修之前会静默打出一串数字;

//   · **string templates** (`"score $gScore"`) — these used to silently print digits;

//   · **多参数函数**(`fun setDir(d: Int, fromKey: Int)`)—— 修之前形参**整体倒序**;

//   · **multi-parameter functions** (`fun setDir(d: Int, fromKey: Int)`) — parameters used to be reversed;

//   · **`when` 作表达式**(`segColor`)—— 修之前它把数据段标签的地址当结果;

//   · **`when` as an expression** (`segColor`) — it used to yield the address of a data label;

//   · **`for (i in 0..n)`** 与 **`x in a..b`** 双重区间判定;

//   · **`for (i in 0..n)`** and double `x in a..b` range tests;

//   · **顶层 `var` 数组**(棋盘状态全在文件级);

//   · **file-level `var` arrays** (all board state lives at file scope);

//   · **无 C 风格三目** —— 条件赋值一律写全 `if/else`(`cond ? a : b` 2026-10-04 复验仍会静默错编;~~本前端没有 `?:`~~ —— Kotlin 的 Elvis `?:` 已经能用)。

//   · **no C-style ternary** — every conditional value is written as a full `if/else` (`cond ? a : b` still silently miscompiles as of 2026-10-04; ~~this frontend has no `?:`~~ -- Kotlin's Elvis `?:` works now).

//

// ## 音效(`ui_audio_beep` 单通道:一个事件一个音,连发只听得见最后一个)

// ## Sound (`ui_audio_beep` is single-channel: one event one tone, a burst only lets the last be heard)

//   吃到 880/60 · 撞死 131/320(**最低**)· 重开 660/90

//   eat 880/60 · crash 131/320 (**lowest**) · restart 660/90

// ⚠ 最低音只到 131Hz(C3):手机外放在 200Hz 以下衰减很快,更低只会被听成"没响"

// ⚠ nothing goes below 131 Hz (C3): a phone speaker rolls off fast under 200 Hz, so anything lower just reads as silence

//

// 跑法 —— 手机:`vml run examples/kotlin/snake.kt`

// How to run — on the phone: `vml run examples/kotlin/snake.kt`

// 跑法 —— 桌面:`vmlcli Examples/kotlin/snake.kt --screen 480x640 --frames /tmp/fr`

// How to run — on the desktop: `vmlcli Examples/kotlin/snake.kt --screen 480x640 --frames /tmp/fr`

//   确定性自动对局(固定种子 + 贪心 AI 自己跑 90 拍后退出并打印分数/长度):

//   Deterministic self-play (fixed seed + a greedy AI for 90 ticks, then prints score/length and exits):

//                  `vmlcli Examples/kotlin/snake.kt --screen 480x640 --arg demo`


// ── 显式引用:下面用到的 `ui_*` 来自共享库 vmlui.vml ──────────────────

// ── Explicit reference: the `ui_*` used below come from the shared library vmlui.vml ──

// ⚠ `.*` 必须有:`import vmlui`(无 `.*`)会被 `ExtractImports` 的正则**静默忽略**。

// ⚠ The `.*` is required: `import vmlui` (without `.*`) is silently ignored by `ExtractImports`.

// ⚠ 但整行删掉也照样编、照样跑 —— 前端靠 `ui_` 前缀**自动**链 `vmlui.vml`。

// ⚠ But deleting the whole line still compiles and runs — the frontend auto-links `vmlui.vml` from the `ui_` prefix.

// ⇒ 所以这条是**文档级**的显式声明,不是强制开关(判据见 `FRONTEND_DEFECTS.md` 的 Kotlin 一节)。

// ⇒ So this is a **documentation-level** explicit reference, not a hard switch (see the Kotlin section of `FRONTEND_DEFECTS.md`).

// ✅ 2026-10-04 复验仍成立:三种写法(`import vmlui` / `import vmlui.*` / 整行删掉)产出的指令数完全相同(59377 条)。

// ✅ Re-verified 2026-10-04, still holds: all three forms (`import vmlui` / `import vmlui.*` / line deleted) produce the identical instruction count (59377).

#include "../../../Lib/kotlin/vml_ui.kt"
import vmlui.*

// 竞技场:16×13 格是竖屏手机上最合适的比例(宽度扣掉边距,高度留给十字键)

// Arena: 16x13 cells fits a portrait phone best (width after margins, height above the D-pad)

const val COLS = 16
// 纵向格子数

// Rows

const val ROWS = 13
// 蛇最长占满整个场地

// The snake can at most fill the arena

const val MAXLEN = COLS * ROWS

// ── 方向编码(一整套都用这四个数)──

// ── Direction codes (these four numbers are used everywhere) ──

const val DUP = 0
const val DRIGHT = 1
const val DDOWN = 2
const val DLEFT = 3

// ── 配色:十六进制 `0xAARRGGBB` ──

// ── Palette: hex `0xAARRGGBB` ──

// 历史:v0.96.592 及之前本前端不认 `0x` 字面量(词法器把它切成 `0` + 一个标识符)。

// History: up to v0.96.592 this frontend did not accept `0x` literals (the lexer split them into `0` plus an identifier).

// 实参位置还会被静默吃掉 ⇒ 颜色变 0、一条错都不报;当时只能用负十进制绕过去。

// In argument position the stray identifier was swallowed silently ⇒ the color became 0 with no error; negative decimals were the workaround.

// 现已修(词法器认 `0x`/`0b`,解析器按 **32 位字**收窄)。

// Fixed now (the lexer accepts `0x`/`0b` and the parser narrows to a **32-bit word**).

// ✅ 2026-10-04 复验仍成立:`0xFF` 得 255、`0xFF101820` 得 -15722464、`0b1011` 得 11,实参位置也不再被吃掉。

// ✅ Re-verified 2026-10-04, still holds: `0xFF` gives 255, `0xFF101820` gives -15722464, `0b1011` gives 11, and argument positions are no longer swallowed.

// 下面那张是当年的对照表,留作记录。

// The table below is the old cross-reference, kept for the record.

//   BG     0xFF101018 = -15724520     PANEL  0xFF1A2230 = -15064528

//   GRID   0xFF2A3448 = -14011320     TEXT   0xFFE8E8F0 =  -1513232

//   SNAKE  0xFF51E86E = -11409298     HEAD   0xFFA8F0B0 =  -5705552

//   BODY   0xFF2FA84A = -13653942     FOOD   0xFFE06050 =  -2072496

//   MUTED  0xFF9AA0B0 =  -6643536     BUTTON 0xFF2A3A56 = -14009770

//   GOLD   0xFFFFC040 =    -16320     DIM    0xFF3A4A66 = -12957082

var LANG = 0
var SW = 0
var SH = 0
// 场地:左上角 (BX,BY) + 一格多少像素 CELL

// Arena: the top-left corner (BX,BY) plus CELL, the pixels per grid cell

var BX = 0
var BY = 0
var CELL = 0
// 十字键:按钮边长 DB、中心 x(DCX)、顶边 y(DBY)

// D-pad: button size DB, center x (DCX) and top y (DBY)

var DB = 0
var DCX = 0
var DBY = 0

// 蛇身:下标 0 是蛇头,往后是身体,`gLen` 之前的才有效

// Snake body: index 0 is the head, the tail follows; only the first `gLen` entries are live

var gSnakeX = arrayOf(
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)
var gSnakeY = arrayOf(
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)
// 自动对局的候选方向表(文件级,避开"局部数组"那条已知风险)

// Preference table for the auto-play AI (file level, dodging the known local-array risk)

var gTryD = arrayOf(0, 0, 0, 0)
var gLen = 3
// 当前方向(这一拍真正走的)与待生效方向(玩家按的)—— 分开存才能丢掉"掉头"

// The live direction (what this tick uses) and the pending one are kept apart — that is what lets a

// reversal be dropped instead of driven into the snake's own neck

var gDir = DRIGHT
var gNextDir = DRIGHT
var gFoodX = 0
var gFoodY = 0
var gScore = 0
var gBest = 0
var gDead = 0
var gPaused = 0
var gSeed = 1
var gTick = 0
var gEaten = 0
var gMaxPrims = 0
var gAuto = 0
var gTickLimit = 0
// 当前节拍毫秒(每吃到一个食物就缩短一档)

// Current tick period in ms (it shrinks one notch per food eaten)

var gPeriod = 240
// 运动墙钟欠账(毫秒)—— 按墙钟补步,用户 2026-10-04 口径(定时器只唤醒,走几步看欠了多少)

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

var gAcc = 0
// 上次补步取的墙钟时刻(进主循环时初始化)

// wall-clock moment of the last catch-up (initialized on entering the main loop)

var gLastMs = 0

/// 确定性伪随机(**不用 `ui_math_rand`**:验收要能复现同一局)

/// Deterministic PRNG (**not `ui_math_rand`**: acceptance needs to reproduce the same run)

fun rnd(n: Int): Int {
    gSeed = gSeed * 1103515245 + 12345
    if (gSeed < 0) { gSeed = 0 - gSeed }
    return gSeed % n
}

fun absI(v: Int): Int {
    if (v < 0) { return 0 - v }
    return v
}

/// 方向的反向(0↔2、1↔3)—— 掉头判定用它

//the opposite direction (0↔2, 1↔3), used to reject reversals

fun oppDir(d: Int): Int {
    return when {
        d == DUP -> DDOWN
        d == DDOWN -> DUP
        d == DLEFT -> DRIGHT
        else -> DLEFT
    }
}

/// 某一格上是不是蛇身

//is that cell occupied by the snake?

fun onSnake(x: Int, y: Int): Int {
    var i = 0
    while (i < gLen) {
        if (gSnakeX[i] == x && gSnakeY[i] == y) { return 1 }
        i = i + 1
    }
    return 0
}

/// 把食物挪到一个空着的格子上。随机撞上蛇身就重摇,重摇 200 次还不行就**线性扫**一遍 / (几乎不会走到;但"重摇到天荒地老"会让整个游戏卡死在一个不可见的循环里)

/// Move the food to a free cell. Re-roll when it lands on the snake, and after 200 tries **scan

/// linearly** (almost never reached, but re-rolling forever would freeze the game in an invisible loop).

fun placeFood() {
    var k = 0
    var tries = 0
    var ok = 0
    while (tries < 200 && ok == 0) {
        k = rnd(COLS * ROWS)
        gFoodX = k % COLS
        gFoodY = k / COLS
        if (onSnake(gFoodX, gFoodY) == 0) { ok = 1 }
        tries = tries + 1
    }
    if (ok == 0) {
        var y = 0
        while (y < ROWS && ok == 0) {
            var x = 0
            while (x < COLS && ok == 0) {
                if (onSnake(x, y) == 0) { gFoodX = x; gFoodY = y; ok = 1 }
                x = x + 1
            }
            y = y + 1
        }
    }
}

/// 开一局:蛇摆在中间横着三格,朝右

//start a run: three horizontal cells in the middle, heading right

fun restart() {
    gLen = 3
    var cy = ROWS / 2
    var cx = COLS / 2
    gSnakeX[0] = cx
    gSnakeY[0] = cy
    gSnakeX[1] = cx - 1
    gSnakeY[1] = cy
    gSnakeX[2] = cx - 2
    gSnakeY[2] = cy
    gDir = DRIGHT
    gNextDir = DRIGHT
    gScore = 0
    gDead = 0
    gPaused = 0
    gTick = 0
    gEaten = 0
    placeFood()
}

/// 玩家/自动机想改方向时唯一的入口 —— **掉头直接丢掉**(见文件头第三条)

/// The single entry point for changing direction — **a reversal is dropped** (see the third header note)

fun setDir(d: Int) {
    if (d == oppDir(gDir)) { return }
    gNextDir = d
}

/// 走一步。返回 0=正常 1=吃到 2=死了

//advance one tick: 0=ok 1=ate 2=died

fun step(): Int {
    if (gDead == 1) { return 2 }
    gDir = gNextDir
    var nx = gSnakeX[0]
    var ny = gSnakeY[0]
    if (gDir == DUP) { ny = ny - 1 }
    if (gDir == DRIGHT) { nx = nx + 1 }
    if (gDir == DDOWN) { ny = ny + 1 }
    if (gDir == DLEFT) { nx = nx - 1 }

    // 撞墙

    // wall

    if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS) {
        gDead = 1
        return 2
    }

    var eating = 0
    if (nx == gFoodX && ny == gFoodY) { eating = 1 }

    // 自撞:不进食时**跳过尾巴那一格**(那一格马上会空出来)

    // self-collision: **skip the tail cell** when not eating (it vacates this very tick)

    var last = gLen - 1
    var i = 0
    while (i < gLen) {
        if (i != last || eating == 1) {
            if (gSnakeX[i] == nx && gSnakeY[i] == ny) {
                gDead = 1
                return 2
            }
        }
        i = i + 1
    }

    if (eating == 1) { gLen = gLen + 1 }
    // 从尾往头挪一格(进食时先把长度 +1,尾巴那格因此被"顶"出来)

    // shift one cell from tail to head (when eating, the length was bumped first, so the tail is kept)

    i = gLen - 1
    while (i > 0) {
        gSnakeX[i] = gSnakeX[i - 1]
        gSnakeY[i] = gSnakeY[i - 1]
        i = i - 1
    }
    gSnakeX[0] = nx
    gSnakeY[0] = ny

    if (eating == 1) {
        gScore = gScore + 1
        gEaten = gEaten + 1
        if (gScore > gBest) { gBest = gScore }
        placeFood()
        ui_audio_beep(880, 60)
        return 1
    }
    return 0
}

/// 每吃一个食物提速一档:240ms 起步,最快 90ms(**有下限**,不然最后一秒几十格没法玩)

/// Speed up one notch per food: 240 ms at first, 90 ms at the fastest (**there is a floor**, otherwise

/// the last seconds would move dozens of cells per second and be unplayable)

fun periodFor(score: Int): Int {
    var p = 240 - score * 10
    if (p < 90) { p = 90 }
    return p
}

/// 某个方向走一步会不会立刻死(自动对局用)

/// Would stepping that way be fatal right away? (used by auto-play)

fun dirSafe(d: Int): Int {
    var nx = gSnakeX[0]
    var ny = gSnakeY[0]
    if (d == DUP) { ny = ny - 1 }
    if (d == DRIGHT) { nx = nx + 1 }
    if (d == DDOWN) { ny = ny + 1 }
    if (d == DLEFT) { nx = nx - 1 }
    if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS) { return 0 }
    var i = 0
    while (i < gLen - 1) {
        if (gSnakeX[i] == nx && gSnakeY[i] == ny) { return 0 }
        i = i + 1
    }
    return 1
}

/// 贪心自动对局:先朝"差得多的那个轴"走,走不通再试另一个轴,都不行就随便挑一条活路。 / 纯确定性、无随机 ⇒ 同一个种子必然跑出同一局,可以当验收判据。

/// Greedy auto-play: head for the axis with the larger delta first, then the other one, and if neither

/// works take any safe direction. Fully deterministic, so one seed always yields the same run — usable

/// as an acceptance criterion.

fun autoDir() {
    var dx = gFoodX - gSnakeX[0]
    var dy = gFoodY - gSnakeY[0]
    var adx = absI(dx)
    var ady = absI(dy)
    var ht = DRIGHT
    if (dx < 0) { ht = DLEFT }
    var vt = DDOWN
    if (dy < 0) { vt = DUP }
    if (adx >= ady) {
        gTryD[0] = ht
        gTryD[1] = vt
        gTryD[2] = DDOWN
        gTryD[3] = DUP
    } else {
        gTryD[0] = vt
        gTryD[1] = ht
        gTryD[2] = DRIGHT
        gTryD[3] = DLEFT
    }
    var i = 0
    var picked = gDir
    var found = 0
    while (i < 4) {
        var d = gTryD[i]
        if (found == 0 && d != oppDir(gDir) && dirSafe(d) == 1) { picked = d; found = 1 }
        i = i + 1
    }
    gNextDir = picked
}

/// 蛇身配色 —— **`when` 作表达式**(修之前它把数据标签的地址当结果返回)

/// Segment color — a **`when` as an expression** (it used to return a data-label address)

fun segColor(i: Int): Int {
    return when {
        i == 0 -> 0xFFA8F0B0
        i < 4 -> 0xFF51E86E
        else -> 0xFF2FA84A
    }
}

fun relayout() {
    SW = ui_scr_get_w()
    SH = ui_scr_get_h()
    if (SW <= 0) { SW = 411 }
    if (SH <= 0) { SH = 726 }
    BY = 92
    // 场地要同时受宽和高约束 —— 只按宽算,矮屏上底部会被十字键压掉

    // the arena must respect width *and* height — width alone lets the D-pad eat the bottom on short screens

    var cw = (SW - 24) / COLS
    var chh = (SH - BY - 200) / ROWS
    CELL = cw
    if (chh < CELL) { CELL = chh }
    if (CELL < 8) { CELL = 8 }
    BX = (SW - COLS * CELL) / 2
    // 十字键:按剩下的空间定边长,再夹到 34..64(太小的按不准,太大的放不下)

    // D-pad: size from the leftover room, clamped to 34..64 (smaller is hard to hit, bigger will not fit)

    var dpadTop = BY + ROWS * CELL + 18
    DB = (SH - dpadTop - 46) / 3
    if (DB > 64) { DB = 64 }
    if (DB < 34) { DB = 34 }
    DCX = SW / 2
    DBY = dpadTop
}

fun statusLine() {
    if (gDead == 1) {
        if (gLen >= MAXLEN) {
            if (LANG == 0) { ui_2d_text(SW / 2, 60, "满屏通关!", 0xFF51E86E, 16, VML_ANCHOR_CENTER) }
            else { ui_2d_text(SW / 2, 60, "Perfect run!", 0xFF51E86E, 16, VML_ANCHOR_CENTER) }
        } else if (gScore > 0 && gScore >= gBest) {
            if (LANG == 0) { ui_2d_text(SW / 2, 60, "新纪录 $gScore 分 · 点屏幕重开", 0xFFFFC040, 16, VML_ANCHOR_CENTER) }
            else { ui_2d_text(SW / 2, 60, "New best $gScore · tap to restart", 0xFFFFC040, 16, VML_ANCHOR_CENTER) }
        } else {
            if (LANG == 0) { ui_2d_text(SW / 2, 60, "撞了 · 得分 $gScore · 点屏幕重开", 0xFFE06050, 16, VML_ANCHOR_CENTER) }
            else { ui_2d_text(SW / 2, 60, "Crashed · score $gScore · tap to restart", 0xFFE06050, 16, VML_ANCHOR_CENTER) }
        }
    } else if (gPaused == 1) {
        if (LANG == 0) { ui_2d_text(SW / 2, 60, "暂停中 · 按 P 继续", 0xFF9AA0B0, 16, VML_ANCHOR_CENTER) }
        else { ui_2d_text(SW / 2, 60, "Paused · press P", 0xFF9AA0B0, 16, VML_ANCHOR_CENTER) }
    } else {
        // **字符串模板**:修之前这一句会静默打出一串数字,而不是画在状态行上

        // **String template**: before the fix this silently printed digits instead of drawing the status line

        // ✅ 2026-10-04 复验:字符串模板已可用(`"n=$n"` ⇒ `n=7`),这一段保持原样。

        // ✅ Re-verified 2026-10-04: string templates work (`"n=$n"` => `n=7`); this section is unchanged.

        if (LANG == 0) { ui_2d_text(SW / 2, 60, "得分 $gScore · 最长 $gLen 格", 0xFFE8E8F0, 16, VML_ANCHOR_CENTER) }
        else { ui_2d_text(SW / 2, 60, "Score $gScore · length $gLen", 0xFFE8E8F0, 16, VML_ANCHOR_CENTER) }
    }
}

/// 一个十字键按钮(带方向箭头)

/// One D-pad button (with a direction arrow)

fun padButton(x: Int, y: Int, label: String, col: Int) {
    ui_2d_rect(x, y, DB, DB, col, 1, 0, 10)
    ui_2d_text_v(x + DB / 2, y + DB / 2, label, 0xFFE8E8F0, 22, VML_ANCHOR_CENTER, VML_VANCHOR_MIDDLE, VML_FONT_BOLD)
}

fun draw() {
    ui_2d_clear(0xFF101018)

    if (LANG == 0) { ui_2d_text_styled(SW / 2, 18, "贪吃蛇", 0xFFE8E8F0, 20, VML_ANCHOR_CENTER, VML_FONT_BOLD) }
    else { ui_2d_text_styled(SW / 2, 18, "Snake", 0xFFE8E8F0, 20, VML_ANCHOR_CENTER, VML_FONT_BOLD) }
    statusLine()

    // 场地底 + 网格(每格一条线,颜色压暗 —— 网格是**参考**不是主体)

    // arena floor + grid (one line per cell, kept dim — the grid is a reference, not the subject)

    ui_2d_rect(BX - 6, BY - 6, COLS * CELL + 12, ROWS * CELL + 12, 0xFF1A2230, 1, 0, 8)
    var i = 0
    while (i <= COLS) {
        ui_2d_line(BX + i * CELL, BY, BX + i * CELL, BY + ROWS * CELL, 0xFF2A3448, 1)
        i = i + 1
    }
    i = 0
    while (i <= ROWS) {
        ui_2d_line(BX, BY + i * CELL, BX + COLS * CELL, BY + i * CELL, 0xFF2A3448, 1)
        i = i + 1
    }
    // 场地边框(撞的是它,画清楚一点)

    // The arena border (this is what you crash into, so draw it clearly)

    ui_2d_rect(BX - 6, BY - 6, COLS * CELL + 12, ROWS * CELL + 12, 0xFF3A4A66, 0, 3, 8)

    // 食物:红点 + 一圈光晕

    // food: a red dot with a soft ring

    var fx = BX + gFoodX * CELL + CELL / 2
    var fy = BY + gFoodY * CELL + CELL / 2
    if (gDead == 0) {
        ui_2d_circle(fx, fy, CELL / 2 - 3, 0xFFE06050, 1, 0)
        ui_2d_circle(fx, fy, CELL / 2 - 1, 0xFF9AA0B0, 0, 1)
    }

    // 蛇身:**上界就是 MAXLEN 格**(见文件末尾的图元数说明)

    // Body; the bound is MAXLEN segments (see the primitive-count note at the end of the file)

    i = gLen - 1
    while (i >= 0) {
        var px = BX + gSnakeX[i] * CELL
        var py = BY + gSnakeY[i] * CELL
        ui_2d_rect(px + 1, py + 1, CELL - 2, CELL - 2, segColor(i), 1, 0, 5)
        i = i - 1
    }
    // 蛇头加两点眼睛(朝向由 gDir 决定)—— 让"哪头是头"一眼看得出来

    // two eyes on the head (facing gDir) — makes it obvious which end is the head

    var hx = BX + gSnakeX[0] * CELL
    var hy = BY + gSnakeY[0] * CELL
    var e1x = hx + CELL / 3
    var e1y = hy + CELL / 3
    var e2x = hx + CELL * 2 / 3
    var e2y = hy + CELL / 3
    if (gDir == DUP || gDir == DDOWN) {
        e1x = hx + CELL / 3
        e1y = hy + CELL / 3
        e2x = hx + CELL / 3
        e2y = hy + CELL * 2 / 3
    }
    ui_2d_circle(e1x, e1y, CELL / 8 + 1, 0xFF101018, 1, 0)
    ui_2d_circle(e2x, e2y, CELL / 8 + 1, 0xFF101018, 1, 0)

    // 结束后把"死因"盖在场地正中

    // on death, stamp the cause in the middle of the arena

    if (gDead == 1) {
        var msg = SW / 2
        ui_2d_rect(BX, BY + ROWS * CELL / 2 - 22, COLS * CELL, 44, 0xFF101018, 1, 0, 8)
        if (LANG == 0) { ui_2d_text(msg, BY + ROWS * CELL / 2, "游戏结束", 0xFFE06050, 18, VML_ANCHOR_CENTER) }
        else { ui_2d_text(msg, BY + ROWS * CELL / 2, "Game over", 0xFFE06050, 18, VML_ANCHOR_CENTER) }
    }

    // 十字键:上 / 左 / 中(重开) / 右 / 下

    // D-pad: up / left / centre (restart) / right / down

    padButton(DCX - DB / 2, DBY, "^", 0xFF2A3A56)
    padButton(DCX - DB * 3 / 2, DBY + DB, "<", 0xFF2A3A56)
    padButton(DCX - DB / 2, DBY + DB, "R", 0xFF3A4A66)
    padButton(DCX + DB / 2, DBY + DB, ">", 0xFF2A3A56)
    padButton(DCX - DB / 2, DBY + DB * 2, "v", 0xFF2A3A56)

    // ⚠ 英文比中文长:这一行按"一个字约 6 像素"估宽,宁可短一点,不然两边被裁

    // ⚠ English is longer than Chinese: budget ~6 px per character here, or both ends get clipped

    if (LANG == 0) { ui_2d_text(SW / 2, DBY + DB * 3 + 22, "方向键/ WASD 转向 · P 暂停 · R 重开 · Esc 退出", 0xFF9AA0B0, 12, VML_ANCHOR_CENTER) }
    else { ui_2d_text(SW / 2, DBY + DB * 3 + 22, "Arrows / WASD · P pause · R restart · Esc quit", 0xFF9AA0B0, 12, VML_ANCHOR_CENTER) }

    // 图元数自检:**量的是"这一帧往场景里放了多少条"**(`ui_2d_present` 之前的那个数)

    // Primitive self-check: the count of scene primitives **before `ui_2d_present` flushes them**

    var n = ui_res_get_count(0)
    if (n > gMaxPrims) { gMaxPrims = n }

    ui_2d_present()
}

/// 一拍:先让自动机决定方向,再走一步

/// One tick: let the auto-player pick a direction, then advance

fun tickGame() {
    gTick = gTick + 1
    if (gAuto == 1) { autoDir() }
    var r = step()
    if (r == 2) { ui_audio_beep(131, 320) }
}

fun dumpSnake() {
    printf("snake: score=%d\n", gScore)
    printf("snake: len=%d dead=%d ticks=%d\n", gLen, gDead, gTick)
    printf("snake: food=%d,%d\n", gFoodX, gFoodY)
}

fun main() {
    var w = 0
    var h = 0
    var tid = 0

    LANG = ui_scr_get_language()
    w = ui_scr_get_w()
    h = ui_scr_get_h()
    if (w <= 0) { w = 411 }
    if (h <= 0) { h = 726 }
    // 竖屏 + 不要手柄区(画布吃满整屏)

    // Portrait + no gamepad area (the canvas fills the whole screen)

    // ⚠ 上面那两句是**开窗前的估算值**,只作兜底;开窗后 `relayout()` 会重新问一次拿到**实测画布**。

    // ⚠ The two reads above are the **pre-open estimate**, a fallback only; `relayout()` re-reads the **measured canvas** after the open.

    // 开窗:尺寸传 0 ⇒ 交给宿主给真实可用区(开窗**之前**问到的只是估算值)。

    // Opening: pass size 0 ⇒ the host supplies the real available area (a **pre-open** read is only an estimate).

    if (LANG == 0) { ui_win_open_ex("贪吃蛇", 0, 0, VML_WIN_PORTRAIT, VML_WIN_NO_GAMEPAD) } else { ui_win_open_ex("Snake", 0, 0, VML_WIN_PORTRAIT, VML_WIN_NO_GAMEPAD) }
    ui_scr_lock_orientation(VML_LOCK_PORTRAIT)      // VML_LOCK_PORTRAIT

    ui_scr_set_keep_on(1)

    // 两档无头验收(不传参数时是完全正常的对局): `--arg demo` → 固定种子 + 贪心 AI 自己跑 90 拍(节拍固定 25ms)后退出。 ⚠ **实测**:桌面脚手架上一拍要重绘一帧、一帧约 0.15 秒 ⇒ 90 拍≈14 秒,不是 2 秒 —— 「25ms 定时器」在这里是**逻辑刻度**,墙钟取决于渲染,别拿节拍毫秒估运行时长。 判据只用 `ui_arg_get_count()`(读参数值要传缓冲区,而 `ui_arg_get` 收数组,Kotlin 侧那条约已知会错位)

    // Two headless modes (with no argument the game is completely normal):

    //   `--arg demo` → fixed seed + the greedy AI plays 90 ticks (fixed 25 ms period), then exits.

    //   ⚠ **Measured**: on the desktop scaffold one tick redraws one frame and a frame costs ~0.15 s,

    //   so 90 ticks is ~14 s, not 2 s — the "25 ms timer" is a *logical* rate here and the wall clock

    //   follows rendering, so never estimate runtime from the tick period.

    // The test only uses `ui_arg_get_count()` (reading argument *values* needs a buffer, and `ui_arg_get` takes an

    // array, which is the known-shifted path on the Kotlin side)

    if (ui_arg_get_count() > 1) {
        gAuto = 1
        gSeed = 12345
        gTickLimit = 90
    } else {
        gSeed = ui_time_get_ms()
        if (gSeed < 1000) { gSeed = gSeed + 1000 }
    }
    relayout()
    restart()
    draw()

    if (gAuto == 1) {
        gPeriod = 25
    } else {
        gPeriod = periodFor(gScore)
    }
    tid = ui_timer_set(gPeriod, 1)
    gAcc = 0
    gLastMs = ui_time_get_ms()   // 墙钟起点:进主循环时取(不在模块级调 syscall)

    // Wall-clock origin: taken when entering the main loop (no syscall at module level)


    while (ui_win_is_closed() == 0) {
        var t = ui_msg_fetch_wait(120)
        if (t == 10) { break }                  // MSG_WINDOWCLOSE


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

        // How many steps this frame: **by wall-clock debt** (timer resolution differs per platform, so

        // 用户 2026-10-04 口径:所有游戏时钟取系统毫秒数。

        // "how many ticks arrived" is not a measure of time).

        // User rule of 2026-10-04: every game clock reads system milliseconds.

        var nowMs = ui_time_get_ms()
        gAcc = gAcc + (nowMs - gLastMs)
        gLastMs = nowMs
        if (gAcc > gPeriod * 8) { gAcc = 0 }    // 卡太久别一次补一大串(防瞬移)

        // Do not catch up in one huge burst after a long stall (anti-teleport)

        if (gAcc >= gPeriod) {
            while (gAcc >= gPeriod) {
                gAcc = gAcc - gPeriod
                if (gPaused == 0 && gDead == 0) {
                    var before = gScore
                    tickGame()
                    // 吃到才重设节拍(每拍都 kill/set 会把定时器抖成一锅粥)

                    // only re-arm on a catch (killing/re-arming every tick turns the timer into mush)

                    if (gScore != before && gAuto == 0) {
                        gPeriod = periodFor(gScore)
                        ui_timer_kill(tid)
                        tid = ui_timer_set(gPeriod, 1)
                    }
                    draw()
                }
            }
            if (gTickLimit > 0 && gTick >= gTickLimit) { break }
        }

        if (t == 9) {                           // MSG_TIMER

            // 定时器**只负责唤醒**(推进在上面按墙钟欠账走)

            // The timer **only wakes us up** — the advance happens above, by wall-clock debt

            continue
        }

        if (t == 1) {                           // MSG_KEYDOWN

            var k = ui_msg_get_a()
            // ESC 退出

            // ESC quits

            if (k == 27) { break }
            if (gDead == 1) {
                if (k == 13 || k == 32 || k == 82) { restart(); ui_audio_beep(660, 90) }
            } else {
                // 一个键一个方向;**键盘也走 `setDir`**(掉头判定只有一处,见文件头)

                // one key one direction; **the keyboard also goes through `setDir`** (one reversal rule)

                if (k == 38 || k == 87) { setDir(DUP) }
                if (k == 39 || k == 68) { setDir(DRIGHT) }
                if (k == 40 || k == 83) { setDir(DDOWN) }
                if (k == 37 || k == 65) { setDir(DLEFT) }
                if (k == 80) { gPaused = 1 - gPaused }
                if (k == 82) { restart(); ui_audio_beep(660, 90) }
            }
            draw()
            continue
        }

        if (t == 6 || t == 4) {                 // MSG_TOUCHDOWN / MSG_MOUSEDOWN

            var x = ui_msg_get_a()
            var y = ui_msg_get_b()
            if (gDead == 1) {
                restart()
                ui_audio_beep(660, 90)
            } else if (y >= DBY && y <= DBY + DB * 3) {
                // 命中判定用**和绘制同一套坐标**(`relayout` 里算一次,两处共用)

                // hit testing uses **the same numbers the drawing used** (computed once in `relayout`)

                if (x >= DCX - DB / 2 && x <= DCX + DB / 2 && y <= DBY + DB) {
                    setDir(DUP)
                } else if (y >= DBY + DB && y <= DBY + DB * 2) {
                    if (x >= DCX - DB * 3 / 2 && x <= DCX - DB / 2) { setDir(DLEFT) }
                    if (x >= DCX + DB / 2 && x <= DCX + DB * 3 / 2) { setDir(DRIGHT) }
                    if (x >= DCX - DB / 2 && x <= DCX + DB / 2) { restart(); ui_audio_beep(660, 90) }
                } else if (x >= DCX - DB / 2 && x <= DCX + DB / 2) {
                    setDir(DDOWN)
                }
            }
            draw()
            continue
        }

        if (t == 11) { relayout(); draw(); continue }   // MSG_WINDOWRESIZE

        if (t == 12) { continue }                       // MSG_WINDOWORIENT

    }

    ui_timer_kill(tid)
    ui_scr_set_keep_on(0)
    ui_win_close()

    // 无头验收用的确定性判据(画面只能肉眼看,这几个数能自动比)

    // Deterministic checkpoints for headless verification (graphics need eyes; these numbers compare)

    dumpSnake()
    printf("snake: eaten=%d period=%d\n", gEaten, gPeriod)
    printf("snake: maxprims=%d\n", gMaxPrims)
    printf("snake: done\n")
}