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

kotlin/app/gomoku.kt · 26803 字节 · 下载原文

// 五子棋 —— 用 **Kotlin** 写的手机游戏(15×15、人机对战、悔棋、重开)

// Gomoku — a phone game written in **Kotlin** (15x15, human vs. a simple AI, undo, restart)

//

// 玩法:手指点棋盘落子(黑子,先手),AI 白子应一手。先连成五子者胜。

// Play: tap the board to place your stone (black, first), the AI answers with white. Five in a row wins.

//

// ## 这一份例程的定位:**压 Kotlin 前端的"类外"特性**

// ## What this example is for: **stressing the Kotlin frontend's non-class features**

//

// 写它的时候(2026-09-30)前端刚修掉一批静默错编,这一份刻意把修好的那些用上, 于是它同时是"Kotlin 能写多像 Kotlin"的演示与那份修复的验收:

// Written right after a batch of silent-miscompile fixes (2026-09-30); it deliberately uses what got

// fixed, so it doubles as a demo and as acceptance for that batch:

//

//   · **字符串模板**(`"轮到你了(第 $gMoves 手)"`)—— 修之前会静默打出一串数字, 而且中间结果**直接画进别的绘制层**(模板那段发的是 `print_int`)。

//   · **string templates** (`"Your turn (move $gMoves)"`) — before the fix these silently printed a run of digits

//     and the intermediate results were **drawn into other layers** (the template path emitted `print_int`).

//   · **多参数函数**(`fun place(idx: Int, pl: Int)`)—— 修之前形参**整体倒序**。

//   · **multi-parameter functions** (`fun place(idx: Int, pl: Int)`) — before the fix the parameters were reversed.

//   · **`when` 作表达式** + **无主语 `when { }`**(`patScore`)—— 修之前前者把标签地址当结果。

//   · **`when` as an expression** + **subject-less `when { }`** (`patScore`) — the former used to yield a label address.

//   · **`x in a..b` 与 `when (x) { in a..b -> }`** —— 修之前后者每个分支都被跳过。

//   · **`x in a..b` and `when (x) { in a..b -> }`** — before the fix every `in` branch was skipped.

//   · **扩展函数**(`fun Int.row()`)与 **`.size`** —— 都曾被前端拦住或算错。

//   · **extension functions** (`fun Int.row()`) and **`.size`** — both used to be blocked or miscalculated.

//   · **`if/else` 必须写全**(**C 风格三目 `cond ? a : b` 仍不能用**:2026-10-04 复验仍静默错编成条件值本身;Kotlin 的 Elvis `?:` 已经能用)。

//   · **`if/else` must be written out** (the **C-style ternary `cond ? a : b` still cannot be used**: as of 2026-10-04 it still silently compiles to the condition value; Kotlin's Elvis `?:` works now).

//

// ## 两条写法约束(照 `catch.kt` / `demo_ui.kt` 的实测)

// ## Two style constraints (measured, per `catch.kt` / `demo_ui.kt`)

//

// ① **消息只用标量版读**(`ui_msg_fetch_wait` / `ui_msg_get_a` / `ui_msg_get_b`)—— 数组传给库的 `int*` 形参时指针会落偏,Kotlin 侧读到的是错位一格的四个数。

// ① **Read messages with the scalar API only** (`ui_msg_fetch_wait` / `ui_msg_get_a` / `ui_msg_get_b`) — arrays passed to a library's

//    `int*` parameter land off by a slot, so the Kotlin side reads a shifted quad.

// ② **状态数组写在文件级 `var`**(本前端的顶层属性已经能用了;`catch.kt` 里那条 "文件级数组读回 0"是老黄历,见 `FRONTEND_DEFECTS.md`)。

// ② **State arrays live in a file-level `var`** (top-level properties work now; the old "file-level array reads back 0"

//    note in `catch.kt` is stale — see `FRONTEND_DEFECTS.md`).

//

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

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

// 跑法 —— 桌面:`vmlcli Examples/kotlin/gomoku.kt --screen 411x726 --frames /tmp/fr`

// How to run — on the desktop: `vmlcli Examples/kotlin/gomoku.kt --screen 411x726 --frames /tmp/fr`

//   自动对局(双方都走同一套启发式、跑满 24 手后自己退出并打印棋盘):

//   Self-play (both sides run the same heuristic, exits after 24 moves and dumps the board):

//                   `vmlcli Examples/kotlin/gomoku.kt --screen 411x726 --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.*

// 棋盘边长(15×15)

// The board side (15x15)

const val N = 15
const val EMPTY = 0
const val HUMAN = 1
const val AI = 2

// ── 状态:颜色写十六进制 `0xAARRGGBB` ──

// ── State; colors are 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  0xFF3A4356 = -12958890

//   LINE   0xFF8894AC =  -7826260     TITLE  0xFFE8E8F0 =  -1513232

//   HUMAN  0xFF16161C = -15329764     AI     0xFFF6F6FA =  -592134

//   GOLD   0xFFFFC040 =   -16320      GREEN  0xFF51E86E = -11409298

//   MUTED  0xFF9AA0B0 =  -6643536     RED    0xFFE06050 =  -2072496

//

// ⚠ 棋盘面板特意用**浅色**(石板灰):第一版面板与"黑子"都贴近深色背景,出图一看 黑子几乎看不见 —— 深色棋盘 + 深色棋子 = 没有对比度。这一条**只有真出图才看得出来**。

// ⚠ The board panel is deliberately **light** (slate): in the first render both the panel and the "black"

//   stones sat close to the dark background and the black stones were nearly invisible.

//   This is the kind of thing **only an actual rendered frame shows**.

var LANG = 0
var SW = 0
var SH = 0
var BX = 0
var BY = 0
var CELL = 0
var gBoard = 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,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)
var gHist = 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,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)
var gHistLen = 0
var gTurn = HUMAN
var gOver = 0
var gWinner = 0
var gMoves = 0
var gLast = -1
// 键盘光标((7,7) 正中那一格)

// The keyboard cursor (the centre cell, (7,7))

var gCursor = 112
var gMaxPrims = 0
var gFrame = 0
var gFrameLimit = 0
var gAuto = 0

// `.size` 与 `#` 下标:数组是"定长"的,长度直接从块头读(前端已修)

// `.size` and `#` indexing: the array is fixed length, read from the block header (fixed in the frontend)

fun cells(): Int { return gBoard.size }

// 扩展函数:`fun Int.row()`。修之前前端**解析不了**接收者是 `Int` 的声明

// Extension functions. Before the fix the frontend **could not parse** a declaration whose receiver is `Int`.

fun Int.row(): Int { return this / N }
fun Int.col(): Int { return this % N }

/// 四连判定:从 `idx` 出发沿 (dx,dy) 一个方向,数出连续的 `pl` 有几颗(含自己)

/// Run length: starting at `idx`, count consecutive `pl` stones along (dx,dy), including the cell itself.

fun dirRun(idx: Int, dx: Int, dy: Int, pl: Int): Int {
    var r = idx.row()
    var c = idx.col()
    var cnt = 1
    var steps = 0
    var rr = r + dx
    var cc = c + dy
    while (steps < 4 && rr >= 0 && rr < N && cc >= 0 && cc < N && gBoard[rr * N + cc] == pl) {
        cnt = cnt + 1
        rr = rr + dx
        cc = cc + dy
        steps = steps + 1
    }
    steps = 0
    rr = r - dx
    cc = c - dy
    while (steps < 4 && rr >= 0 && rr < N && cc >= 0 && cc < N && gBoard[rr * N + cc] == pl) {
        cnt = cnt + 1
        rr = rr - dx
        cc = cc - dy
        steps = steps + 1
    }
    return cnt
}

fun winsAt(idx: Int, pl: Int): Int {
    if (dirRun(idx, 1, 0, pl) >= 5) { return 1 }
    if (dirRun(idx, 0, 1, pl) >= 5) { return 1 }
    if (dirRun(idx, 1, 1, pl) >= 5) { return 1 }
    if (dirRun(idx, 1, -1, pl) >= 5) { return 1 }
    return 0
}

/// 形状打分 —— **无主语 `when` 作表达式**(修之前它把数据段标签的地址当结果返回)

/// Shape scoring — a **subject-less `when` as an expression** (it used to return the address of a data label).

fun patScore(cnt: Int, open: Int): Int {
    return when {
        cnt >= 5 -> 1000000
        cnt == 4 && open == 2 -> 120000
        cnt == 4 && open == 1 -> 12000
        cnt == 3 && open == 2 -> 9000
        cnt == 3 && open == 1 -> 600
        cnt == 2 && open == 2 -> 400
        cnt == 2 && open == 1 -> 40
        cnt == 1 && open == 2 -> 20
        else -> 1
    }
}

/// 假想 `idx` 放一颗 `pl`,这个形状值多少分(把"假设的那颗"也算进连子)

/// Score of pretending `idx` holds a `pl` stone (the hypothetical stone counts toward the run).

fun dirScore(idx: Int, dx: Int, dy: Int, pl: Int): Int {
    var r = idx.row()
    var c = idx.col()
    var cnt = 1
    var open = 0
    var steps = 0
    var rr = r + dx
    var cc = c + dy
    while (steps < 4 && rr >= 0 && rr < N && cc >= 0 && cc < N && gBoard[rr * N + cc] == pl) {
        cnt = cnt + 1
        rr = rr + dx
        cc = cc + dy
        steps = steps + 1
    }
    if (rr >= 0 && rr < N && cc >= 0 && cc < N && gBoard[rr * N + cc] == EMPTY) { open = open + 1 }
    steps = 0
    rr = r - dx
    cc = c - dy
    while (steps < 4 && rr >= 0 && rr < N && cc >= 0 && cc < N && gBoard[rr * N + cc] == pl) {
        cnt = cnt + 1
        rr = rr - dx
        cc = cc - dy
        steps = steps + 1
    }
    if (rr >= 0 && rr < N && cc >= 0 && cc < N && gBoard[rr * N + cc] == EMPTY) { open = open + 1 }
    return patScore(cnt, open)
}

fun cellScore(idx: Int, pl: Int): Int {
    return dirScore(idx, 1, 0, pl) + dirScore(idx, 0, 1, pl)
         + dirScore(idx, 1, 1, pl) + dirScore(idx, 1, -1, pl)
}

/// 启发式:给每个空点算"我方价值 ×11 + 对方价值 ×10",取最高。 / 系数只差一点 ⇒ 同样分量的进攻比防守优先一点点,但对方成四时防守分压倒一切(能挡)。

/// Heuristic: for every empty point take `my value * 11 + their value * 10`, keep the max.

/// The slight edge to attack only breaks ties; a four for the opponent still dominates (so it blocks).

fun bestMove(pl: Int, opp: Int): Int {
    var best = -1
    var bestScore = -1
    var i = 0
    while (i < N * N) {
        if (gBoard[i] == EMPTY) {
            var s = cellScore(i, pl) * 11 + cellScore(i, opp) * 10
            if (s > bestScore) { bestScore = s; best = i }
        }
        i = i + 1
    }
    return best
}

/// 落子。返回 0 = 正常 / 1 = 连成五子 / 2 = 棋盘满了(平局)

/// Place a stone. 0 = ok, 1 = five in a row, 2 = the board is full (draw).

fun place(idx: Int, pl: Int): Int {
    if (idx < 0 || idx >= N * N) { return 3 }
    if (gBoard[idx] != EMPTY) { return 3 }
    gBoard[idx] = pl
    gHist[gHistLen] = idx
    gHistLen = gHistLen + 1
    gLast = idx
    gMoves = gMoves + 1
    if (winsAt(idx, pl) == 1) { gOver = 1; gWinner = pl; return 1 }
    if (gMoves >= N * N) { gOver = 1; gWinner = 0; return 2 }
    return 0
}

fun undo() {
    var k = 0
    while (k < 2 && gHistLen > 0) {
        gHistLen = gHistLen - 1
        gBoard[gHist[gHistLen]] = EMPTY
        gMoves = gMoves - 1
        k = k + 1
    }
    gOver = 0
    gWinner = 0
    gTurn = HUMAN
    if (gHistLen > 0) { gLast = gHist[gHistLen - 1] } else { gLast = -1 }
}

fun restart() {
    var i = 0
    while (i < N * N) { gBoard[i] = EMPTY; i = i + 1 }
    gHistLen = 0
    gTurn = HUMAN
    gOver = 0
    gWinner = 0
    gMoves = 0
    gLast = -1
    gCursor = 112
}

fun relayout() {
    SW = ui_scr_get_w()
    SH = ui_scr_get_h()
    if (SW <= 0) { SW = 411 }
    if (SH <= 0) { SH = 726 }
    CELL = (SW - 28) / N
    BX = (SW - (N - 1) * CELL) / 2
    BY = 86
}

fun statusText() {
    if (gOver == 1 && gWinner == HUMAN) {
        if (LANG == 0) { ui_2d_text(SW / 2, 58, "你赢了!", 0xFF51E86E, 15, VML_ANCHOR_CENTER) }
        else { ui_2d_text(SW / 2, 58, "You win!", 0xFF51E86E, 15, VML_ANCHOR_CENTER) }
    } else if (gOver == 1 && gWinner == AI) {
        if (LANG == 0) { ui_2d_text(SW / 2, 58, "AI 赢了", 0xFFE07050, 15, VML_ANCHOR_CENTER) }
        else { ui_2d_text(SW / 2, 58, "AI wins", 0xFFE07050, 15, VML_ANCHOR_CENTER) }
    } else if (gOver == 1) {
        if (LANG == 0) { ui_2d_text(SW / 2, 58, "平局(棋盘满了)", 0xFF9AA0B0, 15, VML_ANCHOR_CENTER) }
        else { ui_2d_text(SW / 2, 58, "Draw (board full)", 0xFF9AA0B0, 15, VML_ANCHOR_CENTER) }
    } else if (gTurn == HUMAN) {
        // **字符串模板**:修之前这一句会静默打出一串数字,而不是画在状态行上

        // **String template**: before the fix this silently printed digits instead of drawing on 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, 58, "轮到你了(第 $gMoves 手)", 0xFFE8E8F0, 15, VML_ANCHOR_CENTER) }
        else { ui_2d_text(SW / 2, 58, "Your turn (move $gMoves)", 0xFFE8E8F0, 15, VML_ANCHOR_CENTER) }
    } else {
        if (LANG == 0) { ui_2d_text(SW / 2, 58, "AI 在想…", 0xFF9AA0B0, 15, VML_ANCHOR_CENTER) }
        else { ui_2d_text(SW / 2, 58, "AI thinking…", 0xFF9AA0B0, 15, VML_ANCHOR_CENTER) }
    }
}

fun draw() {
    ui_2d_clear(0xFF101018)

    // 标题

    // title

    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, "Gomoku", 0xFFE8E8F0, 20, VML_ANCHOR_CENTER, VML_FONT_BOLD) }
    statusText()

    // 棋盘底 + 网格(15 横 + 15 竖 = 30 条线)

    // The board panel plus the grid (15 rows + 15 columns = 30 lines)

    ui_2d_rect(BX - 8, BY - 8, (N - 1) * CELL + 16, (N - 1) * CELL + 16, 0xFF3A4356, 1, 0, 8)
    var i = 0
    while (i < N) {
        ui_2d_line(BX, BY + i * CELL, BX + (N - 1) * CELL, BY + i * CELL, 0xFF8894AC, 1)
        ui_2d_line(BX + i * CELL, BY, BX + i * CELL, BY + (N - 1) * CELL, 0xFF8894AC, 1)
        i = i + 1
    }
    // 五个星位

    // five star points

    ui_2d_circle(BX + 3 * CELL, BY + 3 * CELL, 3, 0xFF8894AC, 1, 0)
    ui_2d_circle(BX + 11 * CELL, BY + 3 * CELL, 3, 0xFF8894AC, 1, 0)
    ui_2d_circle(BX + 3 * CELL, BY + 11 * CELL, 3, 0xFF8894AC, 1, 0)
    ui_2d_circle(BX + 11 * CELL, BY + 11 * CELL, 3, 0xFF8894AC, 1, 0)
    ui_2d_circle(BX + 7 * CELL, BY + 7 * CELL, 3, 0xFF8894AC, 1, 0)

    // 棋子(**上界就是 225 颗**,见文件末尾的图元数说明)

    // Stones; the bound is 225 (see the primitive-count note at the end of the file)

    i = 0
    while (i < N * N) {
        var v = gBoard[i]
        if (v != EMPTY) {
            var px = BX + i.col() * CELL
            var py = BY + i.row() * CELL
            if (v == HUMAN) { ui_2d_circle(px, py, CELL / 2 - 2, 0xFF16161C, 1, 0) }
            else { ui_2d_circle(px, py, CELL / 2 - 2, 0xFFF6F6FA, 1, 0) }
        }
        i = i + 1
    }

    // 最后一手 + 键盘光标

    // last move + keyboard cursor

    if (gLast >= 0) {
        ui_2d_rect(BX + gLast.col() * CELL - 4, BY + gLast.row() * CELL - 4, 8, 8, 0xFFFFC040, 0, 2, 0)
    }
    if (gCursor >= 0 && gBoard[gCursor] == EMPTY && gOver == 0) {
        ui_2d_rect(BX + gCursor.col() * CELL - CELL / 2 + 1, BY + gCursor.row() * CELL - CELL / 2 + 1,
                CELL - 2, CELL - 2, 0xFF51E86E, 0, 1, 3)
    }

    // 两个按钮

    // two buttons

    var bw = (SW - 36) / 2
    var byy = BY + (N - 1) * CELL + 28
    ui_2d_rect(12, byy, bw, 42, 0xFF3A4356, 1, 0, 8)
    ui_2d_rect(24 + bw, byy, bw, 42, 0xFF3A4356, 1, 0, 8)
    if (LANG == 0) { ui_2d_text(12 + bw / 2, byy + 21, "悔棋", 0xFFE8E8F0, 16, VML_ANCHOR_CENTER) }
    else { ui_2d_text(12 + bw / 2, byy + 21, "Undo", 0xFFE8E8F0, 16, VML_ANCHOR_CENTER) }
    if (LANG == 0) { ui_2d_text(24 + bw + bw / 2, byy + 21, "重开", 0xFFE8E8F0, 16, VML_ANCHOR_CENTER) }
    else { ui_2d_text(24 + bw + bw / 2, byy + 21, "Restart", 0xFFE8E8F0, 16, VML_ANCHOR_CENTER) }

    // 底部提示

    // bottom hint

    // ⚠ 英文比中文长:第一版那句 66 个字符、12 号字排出来比 411 还宽,**两边都被裁掉** (出图才看出来)。这行按"一个字大约 6 像素"估长度,宁可短一点。

    // ⚠ English runs longer than Chinese: the first version's 66-character line was wider than 411 at

    //   size 12 and got **clipped on both sides** (only the rendered frame showed it). Budget ~6 px per

    //   character here and keep it short.

    if (LANG == 0) { ui_2d_text(SW / 2, byy + 62, "点棋盘落子 · 方向键+回车也行 · U 悔棋 · R 重开 · Esc 退出", 0xFF9AA0B0, 12, VML_ANCHOR_CENTER) }
    else { ui_2d_text(SW / 2, byy + 62, "Tap or arrows+Enter · U undo · 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()
}

/// 落子 + AI 应手(一拍走完,AI 是纯启发式、无随机 ⇒ 可复现)

/// Place + the AI's reply (one tick; the AI is a pure heuristic with no randomness, so it is reproducible)

fun humanThenAi(idx: Int) {
    if (gOver == 1) { return }
    if (gBoard[idx] != EMPTY) { ui_audio_beep(300, 60); return }
    var rc = place(idx, HUMAN)
    if (rc == 1) { ui_audio_beep(1047, 300); return }
    ui_audio_beep(880, 60)
    gTurn = AI
    var ai = bestMove(AI, HUMAN)
    var rc2 = place(ai, AI)
    if (rc2 == 1) { ui_audio_beep(220, 420); return }
    if (rc2 == 2) { ui_audio_beep(330, 300); return }
    gTurn = HUMAN
    ui_audio_beep(520, 60)
}

fun dumpBoard() {
    var i = 0
    while (i < N * N) {
        printf("%d", gBoard[i])
        if (i.col() == N - 1) { printf("\n") }
        i = i + 1
    }
}

fun main() {
    var w = 0
    var h = 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 screen) 标题也双语

    // the title is bilingual too

    // ⚠ 上面那两句是**开窗前的估算值**,只作兜底;开窗后 `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("Gomoku", 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`          → 双方都走同一套启发式,跑满 24 手自己退出并打印棋盘 `--arg demo --arg N`  → 同上但跑满 220 手 —— **用来量"满盘时每帧图元数"的上界** 判据只用 `ui_arg_get_count()`(读参数值要传缓冲区,而 `ui_arg_get` 收数组,Kotlin 侧那条约已知会错位)

    // Two headless self-play modes (with no argument the game is completely normal and runs freely):

    //   `--arg demo`          → both sides run the same heuristic, exits after 24 moves and dumps the board

    //   `--arg demo --arg N`  → same but 220 moves — **used to measure the per-frame primitive bound on a full board**

    // 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
        gFrameLimit = 24
        if (ui_arg_get_count() > 2) { gFrameLimit = 220 }
    }
    relayout()
    restart()
    draw()

    var tid = ui_timer_set(60, 1)
    while (ui_win_is_closed() == 0) {
        var t = ui_msg_fetch_wait(120)
        if (t == 10) { break }                  // MSG_WINDOWCLOSE


        if (t == 9) {                           // MSG_TIMER

            gFrame = gFrame + 1
            if (gFrameLimit > 0 && gFrame <= gFrameLimit && gOver == 0 && gAuto == 1) {
                if (gTurn == HUMAN) { humanThenAi(bestMove(HUMAN, AI)) }
            }
            draw()
            if (gFrameLimit > 0 && gFrame >= gFrameLimit) { break }
            continue
        }

        if (t == 1) {                           // MSG_KEYDOWN

            var k = ui_msg_get_a()
            // ESC 退出

            // ESC quits

            if (k == 27) { break }
            if (k == 37) { gCursor = gCursor - 1 }
            if (k == 39) { gCursor = gCursor + 1 }
            if (k == 38) { gCursor = gCursor - N }
            if (k == 40) { gCursor = gCursor + N }
            if (gCursor < 0) { gCursor = gCursor + N * N }
            if (gCursor >= N * N) { gCursor = gCursor - N * N }
            if (k == 13 || k == 32 || k == 65) { humanThenAi(gCursor) }
            if (k == 85) { undo(); ui_audio_beep(440, 70) }        // 'U'

            if (k == 82) { restart(); ui_audio_beep(660, 90) }     // 'R'

            draw()
            continue
        }

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

            var x = ui_msg_get_a()
            var y = ui_msg_get_b()
            var byy = BY + (N - 1) * CELL + 28
            var bw = (SW - 36) / 2
            if (y >= byy && y <= byy + 42 && x >= 12 && x <= 12 + bw) {
                undo()
                ui_audio_beep(440, 70)
            } else if (y >= byy && y <= byy + 42 && x >= 24 + bw && x <= 24 + bw * 2) {
                restart()
                ui_audio_beep(660, 90)
            } else {
                var c = (x - BX + CELL / 2) / CELL
                var r = (y - BY + CELL / 2) / CELL
                // `in` 判断:区间两端都要查(前端已修 `x in arrayOf(…)` 恒为真那条)

                // `in` range check on both ends (the old "`x in arrayOf(...)` is always true" bug is fixed)

                if (r in 0..(N - 1) && c in 0..(N - 1)) { humanThenAi(r * N + c) }
            }
            draw()
            continue
        }

        if (t == 11) {                          // MSG_WINDOWRESIZE

            relayout()
            draw()
            continue
        }
        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)

    dumpBoard()
    printf("gomoku: cells=%d\n", cells())
    printf("gomoku: moves=%d over=%d winner=%d last=%d\n", gMoves, gOver, gWinner, gLast)
    printf("gomoku: maxprims=%d\n", gMaxPrims)
    if (LANG == 0) { printf("gomoku: done\n") } else { printf("gomoku: done\n") }
}