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")
}