← 都来码 / Dolaima← 例程索引lua/app/g2048.lua原文

lua/app/g2048.lua · 29342 字节 · 下载原文

-- 2048 —— 用 **Lua** 写的手机游戏

-- 2048 — a phone game written in **Lua**

--

-- 规则:四方向滑动,同值方块相撞就合并成双倍并加分。

-- Rules: slide in one of four directions; equal tiles that collide merge into their double and score.

-- 每滑一次补一块新方块(90% 是 2、10% 是 4)。

-- Every slide spawns one new tile (90% a 2, 10% a 4).

-- 合出 2048 算赢;棋盘满且四个方向都动不了算输。

-- Reaching 2048 wins; a full board with no legal move in any direction loses.

-- 操作:方向键 / WASD 滑动 · 触摸**滑动** · 回车或 R 重开 · ESC 退出。

-- Controls: arrows / WASD to slide · swipe by touch · Enter or R restarts · ESC exits.

--

-- ◆ 这份例程在练什么

-- ◆ What this example exercises

--

-- 这是本目录里第一个**回合制**的游戏(`snake.lua` 是定时器驱动的实时游戏、`life.lua` 是纯状态机)。

-- This is the first **turn-based** game in this directory (`snake.lua` is timer-driven and real time,

-- `life.lua` is a pure state machine).

-- 一次输入触发一次整体重排:「取一条线 → 压紧 → 合并 → 写回」四步对 4 行 / 4 列各做一遍。

-- One input triggers one full rearrangement: "extract a line → compact → merge → write back", run once per row

-- and once per column.

-- 逻辑全在**多参函数**里:`merge4(v1,v2,v3,v4)` 收 4 个格子,`line_cell(dir,i,k)` 算格子下标。

-- The logic lives in **multi-argument functions**: `merge4(v1,v2,v3,v4)` takes four cells and

-- `line_cell(dir,i,k)` computes a cell index.

--

-- ◆ 本语言的五条**实测约束**(与 `snake.lua` 同一套,都在这份里躲开了)

-- ◆ Five **measured constraints** of this language (the same set as `snake.lua` — all avoided here)

--

--   · **顶层 `local` 在函数里读不到**(野地址 ⇒ 内存错误),函数也看不见外层函数的局部。

--   · **A top-level `local` is invisible inside a function** (wild address ⇒ memory fault), and a function

--     cannot see the enclosing function's locals either.

--     所以状态与逻辑**一律顶层全局**。

--     So all state and logic are **top-level globals**.

--   · **空表 `{}` 只有 8 个键位**:`capacity = 字段数 + max(8, 字段数/2 + 4)`,**写满后新键被静默丢弃**。

--   · **An empty `{}` holds only 8 keys**: `capacity = fields + max(8, fields/2 + 4)`, and **new keys are

--     silently dropped once it is full**.

--     实测:`A = {}` 连写 90 个下标只留下 8 个;`C = {16 个 0}` 留下 28 个。

--     Measured: `A = {}` — 8 of 90 indices survive; `C = {16 zeros}` — 28.

--     ✅ 2026-10-04 复验仍成立(重测:`{}` 连写 90 个 ⇒ `#A` = 8、`{16 个 0}` ⇒ 28)。

--     ✅ Still true as of 2026-10-04 (re-measured: `{}` + 90 writes => `#A` = 8; `{16 zeros}` => 28).

--     ⇒ 棋盘是 `{16 个 0}`(容量 28),临时线是 `{0,0,0,0}`(容量 12),**都按容量建表**。

--     ⇒ The board is `{16 zeros}` (capacity 28) and the scratch lines are `{0,0,0,0}` (capacity 12).

--   · 别写 `t[#t + 1] = v`(内联 `#` 当下标):实测写 3 次只剩 1 个元素,要先 `local n = #t`。

--   · Do not write `t[#t + 1] = v` (inline `#` as a subscript): measured, three writes leave one element —

--     take `local n = #t` first.

--   · `and` / `or` 求的是布尔 0/1(见 `life.lua`)⇒ 一律 `if/else`。

--   · `and` / `or` evaluate to boolean 0/1 (see `life.lua`) ⇒ always use `if/else`.

--     `tostring` / `pairs` 的路没通,数字要画出来就自己画点阵(`draw_number`,见下)。

--     `tostring` / `pairs` are not wired up, so numbers are drawn with our own dot-matrix renderer.

--   · **表字面量没有 GC**:每帧 `{}` 会一直攒着 ⇒ 临时数组**建一次、反复覆盖**(`LINE` / `LINE2`)。

--   · **Table literals are never collected**: a `{}` per frame keeps piling up ⇒ scratch arrays are created

--     once and overwritten in place (`LINE` / `LINE2`).


-- ── 显式引用 UI 库的声明文件 ────────────────────────────────────────────────

-- Explicit reference to the UI library's declaration file.

-- `Lib/lua/vml_ui.lua` 由 `GenLib -n -c vmlui -l lua` 从 `Lib/shared/src/vmlui.c` 生成(155 个 `ui_*`)。

-- `Lib/lua/vml_ui.lua` is produced by `GenLib -n -c vmlui -l lua` from `Lib/shared/src/vmlui.c` (155 `ui_*`).

-- **不要手写、也不要手改生成物**(要改就改生成器 `tools/GenLib`)。

-- Never hand-write it and never edit the generated file (change the generator `tools/GenLib` instead).

-- ⚠ 这条 `require` 对 Lua 是**文档级**引用:本前端按名字解析库函数,删掉它照样编得过、跑得对。

-- ⚠ For Lua this `require` is a **documentation-level** reference: the front end resolves library

-- functions by name, so removing it still compiles and runs.

-- 实测结论与命令见 `docs/UI库导入约定.md` 的「lua 实测结论」一节。

-- Measured results and the exact commands are in the UI-library import convention doc (Lua section).

require("vml_ui")

-- ── 顶层全局:常量、点阵字体、棋盘与一局的状态 ──────────────────────────────

-- ── Top-level globals: constants, the dot-matrix font, the board and the state of one round ──

-- 棋盘边长(4×4)

-- board side (4x4)

N = 4
-- 合出它就通关

-- reaching this value wins the round

TARGET = 2048
FONT = {31599, 29850, 29671, 31207, 18925, 31183, 31695, 18727, 31727, 31215}
-- 数字 0..9 的 15 位点阵掩码(位序 = 行*3 + 列)

-- 15-bit dot-matrix masks for 0..9 (bit index = row*3 + col)

BIT = {1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384}
-- 棋盘:**按 16 个字段建表**拿到 28 个键位(空表 `{}` 只有 8 个,写到第 9 格起会被静默丢掉;2026-10-04 复验仍成立)

-- The board: **built with 16 fields** so it gets 28 key slots (a bare `{}` has 8, and writes past the eighth are

-- silently dropped; still true as of 2026-10-04)

BOARD = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
-- 两条复用的临时线(容量 12):**建一次、反复覆盖**,因为本平台没有 GC,逐次 `{}` 会一直攒着

-- Two reused scratch lines (capacity 12): created once and overwritten, because there is no GC here and a fresh

-- `{}` per move would pile up

LINE = {0, 0, 0, 0}
LINE2 = {0, 0, 0, 0}
SCORE = 0
-- 0 进行 / 1 结束 / 2 通关

-- round state: 0 playing / 1 over / 2 won

STATE = 0
-- 盘面上最大的方块(HUD 上画出来,不用 tostring)

-- the largest tile on the board (drawn in the HUD; no tostring here)

MAXTILE = 0
-- merge4 的四个输出格(本前端不做多返回值,结果落在全局)

-- the four outputs of merge4 (no multiple return values here, so they land in globals)

M1 = 0
M2 = 0
M3 = 0
M4 = 0
-- 这一步有没有真的动过(动了才补新块、才重画)

-- did this slide really move anything (only then spawn a tile and redraw)

MOVED = 0
-- 这一步合并拿到的分(一次滑动里累加,滑完一次性记进 SCORE)

-- score gained by merges this slide (accumulated per line, added to SCORE once at the end)

GAIN = 0
W = 360
CELL = 80
OX = 8
OY = 96

-- 界面文字:开局按 `ui_scr_get_language()` 选一次(见 main)

-- UI strings: chosen once from `ui_scr_get_language()` at startup (see main)

T_TITLE = "2048"
T_SCORE = "SCORE"
T_MAX = "MAX"
T_OVER = "NO MOVES LEFT"
T_WIN = "YOU WIN!"
T_AGAIN = "Enter = restart"
T_HELP = "Swipe or use the arrow keys"

-- 画一个 3×5 点阵数字:`d` 是数字、`px` 是一格边长、`color` 是真彩色

-- Draw one 3×5 dot-matrix digit: `d` the digit, `px` the cell side, `color` a truecolor value

function draw_digit(x, y, d, px, color)
    if d < 0 then
        d = 0
    end
    if d > 9 then
        d = 9
    end
    local mask = FONT[d + 1]
    local r = 0
    while r < 5 do
        local c = 0
        while c < 3 do
            -- 位序与生成掩码时一致:idx = 行*3 + 列;掩码表 1 基 ⇒ +1

            -- Same bit order as the masks were generated with: idx = row*3 + col; the table is 1-based ⇒ +1

            if mask / BIT[r * 3 + c + 1] % 2 == 1 then
                ui_2d_rect(x + c * px, y + r * px, px, px, color, 1, 0, 0)
            end
            c = c + 1
        end
        r = r + 1
    end
end

-- 数字的宽度(格):每位 4 格(3 格字形 + 1 格间距),最后一位不算间距

-- Width of a number in cells: four cells per digit (three glyph + one gap), the last digit has no gap

function num_w(v)
    local digits = 1
    local n = v
    while n >= 10 do
        n = n / 10
        digits = digits + 1
    end
    return digits * 4 - 1
end

-- 画一个非负整数并**居中**在 (cx, cy):先用 num_w 量宽度,再算左上角

-- Draw a non-negative integer **centred** at (cx, cy): measure with num_w first, then compute the top-left corner

function draw_number_centered(cx, cy, v, px, color)
    if v < 0 then
        v = 0
    end
    if v > 999999 then
        v = 999999
    end
    local wcells = num_w(v)
    local x = cx - wcells * px / 2
    local y = cy - 5 * px / 2
    local digits = 1
    local n = v
    while n >= 10 do
        n = n / 10
        digits = digits + 1
    end
    local i = digits
    local rest = v
    while i >= 1 do
        local d = rest % 10
        rest = rest / 10
        draw_digit(x + (i - 1) * px * 4, y, d, px, color)
        i = i - 1
    end
end

-- 左对齐画一个非负整数(HUD 用)

-- Draw a non-negative integer left-aligned (used by the HUD)

function draw_number(x, y, v, px, color)
    if v < 0 then
        v = 0
    end
    if v > 999999 then
        v = 999999
    end
    local digits = 1
    local n = v
    while n >= 10 do
        n = n / 10
        digits = digits + 1
    end
    local i = digits
    local rest = v
    while i >= 1 do
        local d = rest % 10
        rest = rest / 10
        draw_digit(x + (i - 1) * px * 4, y, d, px, color)
        i = i - 1
    end
end

-- 字符串宽度(方法调用形态:`s:len()` ⇒ `string.len(s)`,实测可用;它数的是**字节**)

-- String width (the method-call form: `s:len()` ⇒ `string.len(s)`, measured working; it counts **bytes**)

function text_w(s)
    return s:len()
end

-- 方块配色:按数值回一个真彩色(0 表示空格,用底色)

-- Tile color: one truecolor per value (0 = empty, drawn with the board color)

function tile_color(v)
    if v == 0 then
        -- 0xFF1F2422 空格

        -- 0xFF1F2422 empty cell

        return -14736350
    end
    if v == 2 then
        return -13684674         -- 0xFF2F303E

    end
    if v == 4 then
        return -12631730         -- 0xFF3F414E

    end
    if v == 8 then
        return -2069424          -- 0xFFE06C50

    end
    if v == 16 then
        return -2056112          -- 0xFFE0A050

    end
    if v == 32 then
        return -2509750          -- 0xFFD9B44A

    end
    if v == 64 then
        return -11483016         -- 0xFF50C878

    end
    if v == 128 then
        return -11890471         -- 0xFF4A90D9

    end
    if v == 256 then
        return -8823591          -- 0xFF795CD9

    end
    if v == 512 then
        return -2532194          -- 0xFFD95C9E

    end
    if v == 1024 then
        return -11409298         -- 0xFF51E86E

    end
    -- 0xFFFFE060 2048 及以上

    -- 0xFFFFE060 2048 and above

    return -8096
end

-- 数字在方块上的颜色:亮底用深字、暗底用亮字(对比度是能看清的前提)

-- Digit color on a tile: dark text on light tiles, light text on dark tiles (contrast is what makes it readable)

function tile_ink(v)
    if v >= 8 then
        -- 0xFF101018 深字(亮底)

        -- 0xFF101018 dark ink on bright tiles

        return -15724520
    end
    -- 0xFFE8E8F0 亮字(暗底)

    -- 0xFFE8E8F0 light ink on dark tiles

    return -1513232
end

-- 格子下标:`dir` 方向、第 `i` 条线、线上第 `k` 格(`k` 从「靠前」那一侧数)

-- Cell index: direction `dir`, line `i`, position `k` along that line (k counted from the leading side)

-- 下标 = 行*4 + 列 + 1(本前端的表是 1 基的)

-- Index = row*4 + col + 1 (tables here are 1-based)

-- 0 上 / 1 左 / 2 下 / 3 右(与滑动方向同名,见 do_move)

-- 0 up / 1 left / 2 down / 3 right (named after the slide direction, see do_move)

function line_cell(dir, i, k)
    if dir == 1 then
        -- 左:第 i 行,从左数第 k 个

        -- left: row i, the k-th cell counting from the left

        return i * N + k + 1
    end
    if dir == 3 then
        -- 右:同一行,从右数第 k 个

        -- right: the same row, the k-th cell counting from the right

        return i * N + (N - 1 - k) + 1
    end
    if dir == 0 then
        -- 上:第 i 列,从上往下第 k 个

        -- up: column i, the k-th cell counting from the top

        return k * N + i + 1
    end
    -- 下:同一列,从下往上第 k 个

    -- down: the same column, the k-th cell counting from the bottom

    return (N - 1 - k) * N + i + 1
end

-- 一条线(4 个格子的值)压紧 + 合并,结果写进全局 M1..M4、得分增量写进 GAIN、动过没有写进 MOVED。

-- Compact and merge one line (four cell values); the result goes into M1..M4, the score gain into GAIN,

-- and whether anything moved into MOVED.

-- 多参函数形态:4 个格子直接当形参传进来,不建临时表。

-- The multi-argument form: the four cells come in as parameters, so no temporary table is built.

function merge4(v1, v2, v3, v4)
    -- ① 压紧:把非 0 的往前提(`LINE[n] = v` 覆盖的是**已有下标**,不碰容量)

    -- ① Compact: move non-zero values forward (`LINE[n] = v` overwrites an existing index, capacity untouched)

    local n = 0
    if v1 ~= 0 then
        n = n + 1
        LINE[n] = v1
    end
    if v2 ~= 0 then
        n = n + 1
        LINE[n] = v2
    end
    if v3 ~= 0 then
        n = n + 1
        LINE[n] = v3
    end
    if v4 ~= 0 then
        n = n + 1
        LINE[n] = v4
    end

    -- ② 合并:相邻同值合成双倍,加一次分(一次滑动里同一块只能合一次 —— 靠跳过被吃掉的那格实现)

    -- ② Merge: adjacent equal values double and score once (a tile merges only once per slide — done by skipping

    -- the swallowed cell)

    local m = 0
    local i = 1
    while i <= n do
        local v = LINE[i]
        if i < n and LINE[i + 1] == v then
            v = v * 2
            GAIN = GAIN + v
            i = i + 1
        end
        m = m + 1
        LINE2[m] = v
        i = i + 1
    end

    -- ③ 补 0 到 4 格

    -- ③ Pad back to four cells

    while m < 4 do
        m = m + 1
        LINE2[m] = 0
    end

    M1 = LINE2[1]
    M2 = LINE2[2]
    M3 = LINE2[3]
    M4 = LINE2[4]

    -- ④ 有没有动过(写回前比一次;`MOVED` 决定要不要补新块)

    -- ④ Did anything move? (compared before writing back; `MOVED` decides whether a new tile spawns)

    if M1 ~= v1 or M2 ~= v2 or M3 ~= v3 or M4 ~= v4 then
        MOVED = 1
    end
end

-- 找个空格放新方块:随机试 200 次,试不出来(棋盘快满了)就线性扫

-- Pick an empty cell for the new tile: 200 random tries, then a linear scan when the board is nearly full

function spawn_tile()
    local total = N * N
    local slot = -1
    local tries = 0
    while tries < 200 do
        local idx = ui_math_rand(total) + 1
        if BOARD[idx] == 0 then
            slot = idx
            -- `break` 在本前端是好的(见 demo_ui.lua 的说明)

            -- `break` works on this front end (see the note in demo_ui.lua)

            break
        end
        tries = tries + 1
    end
    if slot < 0 then
        local j = 1
        while j <= total do
            if BOARD[j] == 0 then
                slot = j
                break
            end
            j = j + 1
        end
    end
    if slot < 0 then
        return 0
    end
    -- 90% 出 2、10% 出 4

    -- 90% of the time a 2, 10% of the time a 4

    local r = ui_math_rand(10)
    if r == 0 then
        BOARD[slot] = 4
    else
        BOARD[slot] = 2
    end
    return 1
end

-- 重开一局:清盘、放两块、分数与状态复位

-- Restart: clear the board, place two tiles, reset score and state

function reset_game()
    local i = 1
    while i <= N * N do
        BOARD[i] = 0
        i = i + 1
    end
    SCORE = 0
    STATE = 0
    MAXTILE = 0
    GAIN = 0
    spawn_tile()
    spawn_tile()
    -- ⚠ MAXTILE 要在这里就填一次:它原本只在 `do_move` 末尾算,于是开局 HUD 上会一直挂着 0。

    -- ⚠ MAXTILE is also filled here: it used to be computed only at the end of `do_move`, so the HUD showed 0

    -- at the start even though two 2s were already on the board.

    MAXTILE = max_tile()
end

-- 还能不能动:有空格就能动;没有空格就看有没有相邻同值

-- Is any move still possible: an empty cell means yes; otherwise look for an adjacent equal pair

function any_move_possible()
    local i = 1
    while i <= N * N do
        if BOARD[i] == 0 then
            return 1
        end
        i = i + 1
    end
    local r = 0
    while r < N do
        local c = 0
        while c < N do
            local v = BOARD[r * N + c + 1]
            if c + 1 < N and BOARD[r * N + c + 2] == v then
                return 1
            end
            if r + 1 < N and BOARD[(r + 1) * N + c + 1] == v then
                return 1
            end
            c = c + 1
        end
        r = r + 1
    end
    return 0
end

-- 盘面上最大的方块(HUD 画它 —— 离 2048 还有多远一眼看得出)

-- The largest tile on the board (drawn in the HUD — how far 2048 is, at a glance)

function max_tile()
    local best = 0
    local i = 1
    while i <= N * N do
        if BOARD[i] > best then
            best = BOARD[i]
        end
        i = i + 1
    end
    return best
end

-- 往 `dir` 滑一次:4 条线各跑一遍 merge4,然后写回、补块、判胜负

-- Slide once toward `dir`: run merge4 over the four lines, write back, spawn, and test win/lose

-- 返回 1 表示这一步改了盘面(喂给主循环的「要不要重画」)

-- Returns 1 when the move changed the board (fed to the main loop's "should we redraw")

function do_move(dir)
    MOVED = 0
    GAIN = 0
    local i = 0
    while i < N do
        local a = BOARD[line_cell(dir, i, 0)]
        local b = BOARD[line_cell(dir, i, 1)]
        local c = BOARD[line_cell(dir, i, 2)]
        local d = BOARD[line_cell(dir, i, 3)]
        merge4(a, b, c, d)
        -- 写回时**必须按同一个方向顺序**放(左/上从 `k=0` 起,右/下也按「靠前」那一侧起)

        -- The write-back **must use the same directional order** (left/up start at `k=0`; right/down also start

        -- from their leading side)

        BOARD[line_cell(dir, i, 0)] = M1
        BOARD[line_cell(dir, i, 1)] = M2
        BOARD[line_cell(dir, i, 2)] = M3
        BOARD[line_cell(dir, i, 3)] = M4
        i = i + 1
    end

    if MOVED == 0 then
        -- ⚠ 滑不动也要判一次胜负:正常情况下「无路可走」是在**上一次成功移动补完新块之后**

        -- ⚠ A blocked slide still has to test win/lose: normally "no moves left" is caught right below by

        --   立刻被下面的 `any_move_possible()` 抓到的,但那种"先卡住、再按键"的顺序也要兜住 ——

        --   `any_move_possible()` after the spawn that follows the previous successful move, but the

        --   否则玩家会看到"按键只响一声、游戏永远不结束"(实测:塞一个满盘四向皆堵的盘面,

        --   "already stuck, then press a key" order must be covered too — otherwise the player sees a beep and a

        --   按左键只会一直响 196,横幅永远不出来)

        --   game that never ends (measured: with a full board stuck in all four directions, pressing left only

        --   beeps 196 forever and the banner never appears)

        if any_move_possible() == 0 then
            STATE = 1
            ui_audio_beep(131, 320)
            return 1
        end
        -- 滑不动:一声很低的"闷"提示(不是错误,不打断节奏)

        -- blocked slide: one low thud (not an error, it does not break the rhythm)

        ui_audio_beep(196, 40)
        return 0
    end

    SCORE = SCORE + GAIN
    if GAIN > 0 then
        -- 合并成功:清亮的一声(得分多少由画面说话)

        -- a merge happened: one bright tone (the picture tells you how much it scored)

        ui_audio_beep(523, 60)
    end

    spawn_tile()
    MAXTILE = max_tile()
    if MAXTILE >= TARGET then
        STATE = 2
        -- 通关:最高音 + 最长

        -- won: highest and longest tone

        ui_audio_beep(1047, 320)
        return 1
    end
    if any_move_possible() == 0 then
        STATE = 1
        -- 走投无路:最低音 + 最长(§4.5「输取最低」)

        -- no moves left: lowest and longest tone (4.5: take the lowest for a loss)

        ui_audio_beep(131, 320)
    end
    return 1
end

-- 画一整帧

-- Draw one whole frame

function draw()
    local cell = CELL
    local ox = OX
    local oy = OY
    local gap = 6

    ui_2d_clear(-15724520)            -- 0xFF101018


    -- 标题 + 与标题等宽的下划线(宽度由 `s:len()` 数出来 —— 方法调用那条路)

    -- Title plus an underline as wide as the title (width from `s:len()` — the method-call path)

    ui_2d_text(ox, 8, T_TITLE, -1513232, 20, VML_ANCHOR_LEFT)
    ui_2d_rect(ox, 34, text_w(T_TITLE) * 4, 3, -2069424, 1, 0, 0)

    -- HUD:得分 / 最大方块(都走点阵渲染器)

    -- HUD: score / largest tile (both through the dot-matrix renderer)

    ui_2d_text(W - ox - 150, 8, T_SCORE, -6643536, 11, VML_ANCHOR_LEFT)
    draw_number(W - ox - 150, 22, SCORE, 3, -2509750)
    ui_2d_text(W - ox - 60, 8, T_MAX, -6643536, 11, VML_ANCHOR_LEFT)
    draw_number(W - ox - 60, 22, MAXTILE, 3, -11409298)

    -- 棋盘底

    -- Board floor

    ui_2d_rect(ox - gap, oy - gap, cell * N + gap * 2, cell * N + gap * 2, -14079702, 1, 0, 8)

    -- 16 个格子

    -- The sixteen cells

    local r = 0
    while r < N do
        local c = 0
        while c < N do
            local v = BOARD[r * N + c + 1]
            local x = ox + c * cell
            local y = oy + r * cell
            ui_2d_rect(x + gap / 2, y + gap / 2, cell - gap, cell - gap, tile_color(v), 1, 0, 6)
            if v ~= 0 then
                -- 位数多(≥ 4 位)时把字缩小一档,免得顶出格子

                -- Values with four or more digits use a smaller step so they do not overflow the tile

                local px = 5
                if v >= 1024 then
                    px = 4
                end
                if v >= 16384 then
                    px = 3
                end
                draw_number_centered(x + cell / 2 + 2, y + cell / 2, v, px, tile_ink(v))
            end
            c = c + 1
        end
        r = r + 1
    end

    -- 结束 / 通关的横幅(都伴随音效,见 do_move)

    -- The over / won banner (each comes with a sound — see do_move)

    if STATE ~= 0 then
        local cy = oy + cell * N / 2
        ui_2d_rect(ox - gap, cy - 58, cell * N + gap * 2, 116, -14736350, 1, 0, 8)
        if STATE == 2 then
            ui_2d_text(W / 2, cy - 40, T_WIN, -11409298, 22, VML_ANCHOR_CENTER)
        else
            ui_2d_text(W / 2, cy - 40, T_OVER, -2069424, 20, VML_ANCHOR_CENTER)
        end
        ui_2d_text(W / 2, cy - 6, T_AGAIN, -1513232, 13, VML_ANCHOR_CENTER)
        draw_number_centered(W / 2, cy + 34, SCORE, 4, -2509750)
    end

    ui_2d_text(W / 2, oy + cell * N + 20, T_HELP, -6643536, 12, VML_ANCHOR_CENTER)

    ui_2d_present()
end

function main()
    -- 界面语言:开局问一次宿主要中文还是英文(0=中文 1=英文),之后整局按它分支。

    -- UI language: ask the host once (0=Chinese 1=English), then branch on it for the whole session.

    -- ⚠ 别在每帧里调(一次 syscall),也**不能**用 `and/or` 选串(见文件头)。

    -- ⚠ Not per frame (it is a syscall), and do not pick strings with `and/or` (see the header).

    local lang = ui_scr_get_language()
    if lang == 0 then T_SCORE = "得分" end
    if lang == 0 then T_MAX = "最大" end
    if lang == 0 then T_OVER = "走不动了" end
    if lang == 0 then T_WIN = "合出 2048!" end
    if lang == 0 then T_AGAIN = "回车 = 重开" end
    if lang == 0 then T_HELP = "滑动屏幕 / 方向键移动" end

    -- 开窗时传 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("2048", 0, 0)
    ui_scr_set_keep_on(1)

    local w = ui_scr_get_w()
    local h = ui_scr_get_h()
    if w <= 0 then
        w = 360
    end
    if h <= 0 then
        h = 620
    end
    W = w

    -- 棋盘边长按「按宽」「按高」两个上界的小者算(手机可用绘图区实测约 395×578)

    -- The board side is the smaller of the width and height bounds (the measured phone drawing area is ~395×578)

    local cell = (w - 24) / N
    local avail = h - 200
    if avail / N < cell then
        cell = avail / N
    end
    if cell < 20 then
        cell = 20
    end
    CELL = cell
    OX = (w - cell * N) / 2
    OY = 96

    reset_game()

    local quit = 0
    local need_draw = 1
    -- 按下的位置(触摸滑动要用「按下 → 抬起」的位移判方向)

    -- where the press started (a swipe needs the press-to-release displacement to pick a direction)

    local td_x = 0
    local td_y = 0
    while ui_win_is_closed() == 0 and quit == 0 do
        -- 先画后等:只在盘面真会变时才画

        -- Draw before waiting, and only when the board really changes

        if need_draw ~= 0 then
            draw()
            need_draw = 0
        end

        local t = ui_msg_fetch_wait(0)
        if t == 10 then
            quit = 1
        end

        if t == 1 then
            local k = ui_msg_get_a()
            if k == 27 then
                quit = 1
            end
            -- 回车 / R

            -- Enter or R

            if k == 13 or k == 82 then
                reset_game()
                ui_audio_beep(660, 70)
                need_draw = 1
            end
            if STATE == 0 then
                if k == 38 or k == 87 then need_draw = do_move(0) end
                if k == 37 or k == 65 then need_draw = do_move(1) end
                if k == 40 or k == 83 then need_draw = do_move(2) end
                if k == 39 or k == 68 then need_draw = do_move(3) end
            end
        end

        -- 触摸:按下时记位置、抬起时按位移的主轴判方向(真正的"滑动",不是点击)

        -- Touch: record the press, then on release use the dominant axis of the displacement (a real swipe, not a tap)

        if t == 6 then
            td_x = ui_msg_get_a()
            td_y = ui_msg_get_b()
        end
        if t == 8 then
            local ux = ui_msg_get_a()
            local uy = ui_msg_get_b()
            local dx = ux - td_x
            local dy = uy - td_y
            local adx = dx
            local ady = dy
            if adx < 0 then adx = 0 - adx end
            if ady < 0 then ady = 0 - ady end
            -- 位移太小当点击处理(免得误触就把盘面搅了)

            -- Too small a displacement is treated as a tap (so an accidental touch does not scramble the board)

            if adx > 24 or ady > 24 then
                if STATE == 0 then
                    if adx > ady then
                        if dx > 0 then need_draw = do_move(3) else need_draw = do_move(1) end
                    else
                        if dy > 0 then need_draw = do_move(2) else need_draw = do_move(0) end
                    end
                end
            end
        end
    end

    ui_scr_set_keep_on(0)
    ui_sfx_panic()
    ui_win_close()
end

main()