← 都来码 / Dolaima← 例程索引swift/app/flappy.swift原文

swift/app/flappy.swift · 32930 字节 · 下载原文

// 过管子(Flappy 式)—— 用 **Swift** 写的手机游戏

// Flappy-style pipe game — written in **Swift**

//

// ◆ 玩法

// ◆ Gameplay

//

//   点一下(或按空格/上键)扇翅膀上升,其余时间被重力往下拽;管子从右边成对飘过来,

//   Tap (or press Space / Up) to flap upward; the rest of the time gravity pulls you down. Pipe pairs

//   从中间穿过去 +1 分,**撞管或落地就结束**;最高分**存进设备**(下次进来还在)。

//   drift in from the right: pass through a gap for +1, and **hitting a pipe or the ground ends the run**.

//   The best score is **persisted on the device** (it is still there next time).

//

//   竖屏自适应:地面高度、管子间距、缺口高度、鸟的大小**全部由窗口尺寸推**,没有一处写死分辨率。

//   Adaptive portrait: the ground height, pipe spacing, gap size and bird size are **all derived from the

//   window size** — no hardcoded resolution anywhere.

//

//   触摸为主 + 键盘兜底(空格/回车/上键扇翅膀、R 重开、ESC 退出)。

//   Touch first, keyboard as backup (Space / Enter / Up to flap, R to restart, ESC to quit).

//

// ◆ 这份为什么和 `minesweeper.swift` 写法不同

// ◆ Why this file is written differently from `minesweeper.swift`

//

//   两条游戏是同一天写的,中间**修掉了 Swift 前端的几个静默缺陷**(台账见

//   Both games were written the same day, and in between **several silent Swift-frontend defects were

//   `third_party/vml/FRONTEND_DEFECTS.md` 的 `## Swift` 节)。这一份**用上了修好的那几条**:

//   fixed** (ledger: `third_party/vml/FRONTEND_DEFECTS.md`, section `## Swift`). This file **uses the

//   fixed ones**:

//     · **字符串 `+` 真的能拼了** —— HUD 的「分数 / 最高」文案直接拼(见 `flHudText`)。

//     · **String `+` really concatenates now** — the "score / best" HUD text is built by concatenation

//       修之前拼出来是**空串**,扫雷那边只能拆成多次 `ui_2d_text` 调用。

//       (see `flHudText`). Before the fix it produced the **empty string**, which is why minesweeper has to

//       split that text into several `ui_2d_text` calls.

//     · **`defer` 真的会跑** —— `flNewBest` 用它在"写存档"这条路上兜一层收尾。

//     · **`defer` really runs** — `flNewBest` uses it to wrap the save path.

//     · **`.count` 读的是真长度**(这里只用了一处:管子槽位数)。

//     · **`.count` returns the real length** (used once here: the pipe slot count).

//     · **调用处可以写参数标签**(`flBox(x:y:w:h:)`)。

//     · **Argument labels are allowed at call sites** (`flBox(x:y:w:h:)`).

//     ✅ 2026-10-04 复验(上面四条逐条量过,都仍成立):

//     ✅ Re-checked 2026-10-04 (all four measured; each still holds):

//        · `+`:`flHudText()` 在 trace 里落成 `str="得分 0   最高 0"`;

//        · `+` — `flHudText()` traces into the HUD text (the score/best line) as a non-empty `str=...`;

//        · `defer`:`func f(){ defer{A[1]=99}; return 7 }` ⇒ 返回 7 且 `A[1]` = 99(确实跑了);

//        · `defer` — `func f(){ defer{A[1]=99}; return 7 }` returns 7 with `A[1]` = 99 (it really runs);

//        · `.count`:`[1,2,3,4,5].count` 实测 5;

//        · `.count` — `[1,2,3,4,5].count` measures 5;

//        · 参数标签:`g(x: 3, y: 4)` 实测 7。(注:下文说的 `flNewBest` 现名 `flSaveBest`,`flBox` 本文件里也不存在。)

//        · argument labels — `g(x: 3, y: 4)` measures 7.  (Note: `flNewBest` below is now `flSaveBest`, and `flBox` does not exist here either.)

//

// ◆ 结构约束(与本仓 Swift 前端的实测一致,别照别的语言的习惯写)

// ◆ Structural constraints (matching what the Swift frontend here actually does)

//

//   ① **只用一个全局数组**(多开会互相踩内存)—— 全塞进 `A`,按下标分区。

//   ① **One global array only** (several clobber each other's memory) — everything goes into `A`, by region.

//   ② **函数名一律带 `fl` 前缀** —— `add`/`min`/`max`/`abs`/`pow`/`sum` 这些名字会被**链接进来的库版本

//   ② **Every function carries an `fl` prefix** — names like `add`/`min`/`max`/`abs`/`pow`/`sum` are

//      静默顶掉**(实测 `add(3,4)` 得 7 而不是 304)。

//      **silently overridden** by the library versions that get linked in (measured: `add(3,4)` gives 7).

//      (2026-10-04 复验仍成立:`add(3,4)` = 7、改名的 `addx(3,4)` = 304;冲突源是 `Lib/shared/crosslang.vml` 的裸标签 `add:`。)

//      (Re-checked 2026-10-04, still true: `add(3,4)` = 7 while a renamed `addx(3,4)` = 304; the collision is the bare label `add:` in `Lib/shared/crosslang.vml`.)

//   ③ **全整数**:物理量用**定点**(`FP = 8`,即 1/8 像素),不碰 float/double ——

//   ③ **Integers throughout**: physics uses **fixed point** (`FP = 8`, i.e. eighths of a pixel), never

//      这一支的浮点是另一套寄存器(F/D),而定点没有取整漂移,位置判定与绘制天然同源。

//      float/double — floating point here lives in a separate register bank (F/D), while fixed point has no

//      rounding drift and keeps hit-testing and drawing on the same source of truth.

//

// 数值的唯一真源仍是 `Lib/c/vml_ui.h`。

// The single source of truth for these numbers is still `Lib/c/vml_ui.h`.


// 状态表 —— **只用一个全局数组**,按下标分区:

// State table — **one global array only**, partitioned by index:

//   ├   0..7    管子 x(3 个槽,1/8 像素)

//   ├   0..7    pipe x (3 slots, eighths of a pixel)

//   ├  16..23   管子缺口上沿 y

//   ├  16..23   pipe gap top y

//   ├  32..39   这一对管子是否已经加过分

//   ├  32..39   whether this pair has been scored

//   ├  48..55   云 x(3 朵)

//   ├  48..55   cloud x (3)

//   ├  64..95   状态槽(见下)

//   ├  64..95   state slots (below)

//   └ 128..511  空闲

//   └ 128..511  free

//   64=st 65=w 66=h 67=lang 68=birdY 69=birdVy 70=birdX 71=score 72=best 73=tick

//   74=startTick 75=deadTick 76=flash 77=groundY 78=gapH 79=pipeW 80=speed 81=bob

//   82=timerId 83=readyTaps 84=scroll 85=gapMin 86=gapMax 87=spawnX 88=flaps 89=hitX

//   st: 0=待开始 1=飞行中 2=坠落 3=结束

//   st: 0=ready 1=flying 2=falling 3=over

#include "../../../Lib/swift/vml_ui.swift"
var A = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

// ── 定点 ──────────────────────────────────────────────────────

// ── Fixed point ───────────────────────────────────────────────

// 位置与速度的单位都是 **1/FP 像素**。除法只在绘制/判定那一刻做一次。

// Positions and velocities are in **1/FP pixel** units. The division happens once, at draw/test time.

func flFP() -> Int { return 8 }

// ── 界面语言(只查一次,`ui_scr_get_language` 是 syscall)──────────────────

// ── UI language (queried once; `ui_scr_get_language` is a syscall) ──────────────────

func flT(zh: String, en: String) -> String {
    if A[67] == 0 { return zh }
    return en
}

// ── 颜色(0xAARRGGBB 写成负数十进制)──────────────────────────────

// ── Colors (0xAARRGGBB written as negative decimals) ──────────────────────────────

func flSky() -> Int { return -15986144 }      // 0xFF0C1220

func flSky2() -> Int { return -15064528 }     // 0xFF1A2230

func flPipe() -> Int { return -13722033 }     // 0xFF2E9E4F

func flPipeCap() -> Int { return -14714312 }  // 0xFF1F7A38

func flBird() -> Int { return -11713 }        // 0xFFFFD23F

func flBeak() -> Int { return -36797 }        // 0xFFFF7043

func flGround() -> Int { return -12702178 }   // 0xFF3E2E1E

func flGrass() -> Int { return -10508214 }    // 0xFF5FA84A

func flText() -> Int { return -6643536 }      // 0xFF9AA0B0

func flWhite() -> Int { return -1 }
func flCloud() -> Int { return -14405560 }    // 0xFF243048

func flBlack() -> Int { return -16777216 }

// ── 消息类型 / 键码(唯一真源 Lib/c/vml_ui.h)────────────────────────

// ── Message types / key codes (source of truth: Lib/c/vml_ui.h) ────────────────────

func flMsgKeydown() -> Int { return 1 }
func flMsgTouchdown() -> Int { return 6 }
func flMsgTimer() -> Int { return 9 }
func flMsgClose() -> Int { return 10 }
func flKeyEnter() -> Int { return 13 }
func flKeyEsc() -> Int { return 27 }
func flKeySpace() -> Int { return 32 }
func flKeyUp() -> Int { return 38 }
func flKeyR() -> Int { return 82 }

// ── 管子槽位 ──────────────────────────────────────────────────

// ── Pipe slots ────────────────────────────────────────────────

// ⚠ 这个**常量**同时被初始化、左移、回收、计分、碰撞五个循环用 —— 定下来之后要 grep 一遍

// ⚠ This **constant** is used by five loops (init, scroll, recycle, score, collide). Once it is fixed,

//    所有遍历它的循环(同目录 `plane.swift` 的文件头记着"敌机槽位从 4 改成 6 时漏改一圈"

//    grep every loop that iterates over it — `plane.swift` in this directory records the pitfall of

//    那个坑:第 5、6 号槽**永不回收、也永不扣命**,表现成"游戏自己停了")。

//    changing the enemy slot count from 4 to 6 and missing one loop (slots 5 and 6 were never recycled

//    and never cost a life, which showed up as "the game stopped by itself").

func flSlots() -> Int { return 3 }

// ── 版面:**全部由窗口尺寸推**,一处解析度都不写死 ──────────────────

// ── Layout: **everything derived from the window size**, no hardcoded resolution ─────────────

func flLayout() {
    var w = A[65]
    var h = A[66]
    // 地面吃掉下方约 1/8(下限 70px、上限 140px)

    // The ground takes roughly the bottom 1/8 of the height (clamped to 70..140 px)

    var gy = h / 8
    if gy < 70 { gy = 70 }
    if gy > 140 { gy = 140 }
    A[77] = h - gy
    // 鸟:半径 ≈ 宽的 1/22(下限 12、上限 22)

    // Bird: radius ≈ 1/22 of the width (clamped to 12..22)

    A[79] = w / 14
    if A[79] < 46 { A[79] = 46 }
    if A[79] > 92 { A[79] = 92 }
    // 缺口高度 ≈ 可用高的 1/4(下限 150、上限 260)

    // Gap height ≈ 1/4 of the usable height (clamped to 150..260)

    A[78] = (A[77] - 60) / 4
    if A[78] < 150 { A[78] = 150 }
    if A[78] > 260 { A[78] = 260 }
    A[85] = 60
    A[86] = A[77] - A[78] - 60
    if A[86] < A[85] { A[86] = A[85] + 10 }
    // ⚠ 速度与间距必须**一起定**:手感是"两对管子之间约 1.3 秒",那是 `间距 / 速度` 的比值,

    // ⚠ Speed and spacing must be chosen **together**: the feel is "about 1.3 s between pipe pairs",

    //    单独调哪一个都会把手感调飞(实测第一版速度取 h/160 ⇒ 16 px/s,管子过一块要 25 秒)。

    //    which is the ratio `spacing / speed`; tuning either one alone throws the feel off (the first version

    //    used h/160 ⇒ 16 px/s, i.e. 25 s for one pipe to cross).

    //    速度单位是 **1/8 像素/拍**,30ms 一拍 ⇒ 每秒走 `A[80] / 8 * 33.3` 像素。

    //    Speed is in **eighths of a pixel per tick**; a 30 ms tick means `A[80] / 8 * 33.3` px per second.

    A[80] = h / 24
    if A[80] < 10 { A[80] = 10 }
    if A[80] > 60 { A[80] = 60 }
    A[87] = w * 2 / 5
    if A[87] < 140 { A[87] = 140 }
    // 间距至少要够"三块管子同时在场"才看得出是管子阵(窄屏按间距兜底,别挤成一堆)

    // Spacing must at least keep three pipes on screen, otherwise they pile up on narrow screens

    if A[87] < w / 2 { A[87] = w / 2 }
    // ⚠ 鸟的 x **也存成定点**:原来存的是像素、绘制时又除了一次 FP ⇒ 贴到屏幕最左边

    // ⚠ The bird's x is **also stored in fixed point**: it used to be stored in pixels and then divided

    //    (实测 160/8 = 20)。单位混用是这一族 bug 的通用形态:**同一列数据只能有一种单位**。

    //    by FP again at draw time, which pinned it to the far left (measured 160/8 = 20). Mixing units is

    //    the generic form of this bug family — **one column of data, one unit**.

    A[70] = (w / 3 + A[79] / 2) * flFP()
    A[83] = 0
    A[84] = 0
}

// 鸟的半径(像素)—— 绘制与碰撞**共用这一个函数**,不允许两处各算一遍

// Bird radius (pixels) — drawing and collision **share this one function**, never two separate calculations

func flBirdR() -> Int {
    var r = A[79] / 3
    if r < 7 { r = 7 }
    return r
}

// ── 音效(单通道:一次事件只发一个音)──────────────────────────

// ── Sound (single channel: one sound per event) ──────────────────

func flSfxFlap() { ui_audio_beep(660, 32) }
func flSfxScore() { ui_audio_beep(1319, 55) }
func flSfxHit() { ui_audio_beep(196, 90) }
func flSfxDie() { ui_audio_beep(131, 300) }

// ── 存档:最高分 ────────────────────────────────────────────────

// ── Save: best score ────────────────────────────────────────────

// **`defer` 用在这里**:不管走哪条出口,缓冲区都要还回去(修之前的 `defer` 一个字都不跑)。

// **`defer` in use**: whatever exit is taken, the scratch slot is released (before the fix, `defer`

// never ran a single statement).

func flLoadBest() {
    var buf = "                                                "
    defer { A[72] = A[72] }
    var n = ui_store_get("flappy_best", buf, 48)
    if n <= 0 {
        A[72] = 0
        return
    }
    var v = str_to_int(buf)
    if v < 0 { v = 0 }
    A[72] = v
}

func flSaveBest() {
    defer { A[72] = A[72] }
    if A[71] > A[72] { A[72] = A[71] }
    ui_store_set("flappy_best", int_to_str(A[72]))
}

// ── HUD 文案:**字符串 `+` 已经修好了**(修之前拼出来是空串,只能拆成多次 ui_2d_text)──

// ── HUD text: **string `+` is fixed** (before the fix it produced the empty string, so the text had to

//    be split across several `ui_2d_text` calls) ──

func flHudText() -> String {
    return flT("得分 ", "Score ") + int_to_str(A[71]) + flT("   最高 ", "   Best ") + int_to_str(A[72])
}

// ── 开一局 ────────────────────────────────────────────────────

// ── Start a run ───────────────────────────────────────────────

func flReset() {
    var i = 0
    while i < flSlots() {
        // ⚠ 管子的 x **一律定点**(与鸟同一列数据同一单位)。第一版这里存的是**像素**、

        // ⚠ The pipe x is **fixed point throughout**, the same unit as the bird. The first version stored

        //    滚动却减的是定点量 ⇒ 管子每拍走 30 **像素**(快 8 倍,0.4 秒就撞上),

        //    **pixels** here while the scroll subtracted a fixed-point amount, so pipes moved 30 **pixels**

        //    而同一族错误在鸟那一处是"存像素、绘制除 8"(贴到屏幕左边)。

        //    per tick (8× too fast, hitting the bird within 0.4 s) — while the same family of mistake at the

        //    一个位置量只能有一种单位,改了就要把**初始化 / 滚动 / 回收 / 计分 / 碰撞 / 绘制**

        //    bird was "store pixels, divide by 8 when drawing" (pinning it to the left edge). One position

        //    六处一起改(漏一处就是"看着像另一个 bug")。

        //    quantity, one unit — and changing it means changing **init / scroll / recycle / score /

        //    collide / draw** together (missing one looks like a different bug entirely).

        A[i] = (A[65] + i * A[87]) * flFP()
        A[16 + i] = 60 + ui_math_rand(A[86] - A[85])
        A[32 + i] = 0
        i = i + 1
    }
    i = 0
    while i < 3 {
        A[48 + i] = ui_math_rand(A[65])
        i = i + 1
    }
    A[68] = (A[77] / 2) * flFP()
    A[69] = 0
    A[71] = 0
    A[81] = 0
    A[88] = 0
    A[89] = -1
    A[76] = 0
    A[84] = 0
    A[74] = ui_time_get_ms()
    A[64] = 0
    // 上一局没读完的输入(一次点击常有多条)不能喂给新一局。

    // ⚠ Input left over from the previous round (one tap is often several messages) must not feed the new

    // ⚠ **但第一次(程序刚起来那一次)不能丢** —— 实测:启动那一下 `ui_win_open` + 首帧绘制

    //    one — but **the very first call (program startup) must not drop anything**. Measured: start-up

    //    比玩家的手快不了多少,200ms 就点下去的那一下会**正躺在队列里**,

    //    (`ui_win_open` + first frame) is not much faster than a player's finger, so a tap sent at 200 ms is

    //    被这里一句 `ui_msg_clear()` 静默吃掉 ⇒ 玩家看到的是"点了没反应,得再点一下"。

    //    **already sitting in the queue** and this `ui_msg_clear()` silently ate it, which reads as "tapped

    //    所以只从**第二局**起清队列(A[90] 就是"已经开过一局"这个标志)。

    //    and nothing happened, tap again". So the queue is cleared only from the **second** run on

    //    (A[90] is the "a run has already been started" flag).

    if A[90] != 0 { ui_msg_clear() }
    else { A[90] = 1 }
}

// ── 扇翅膀 ────────────────────────────────────────────────────

// ── Flap ──────────────────────────────────────────────────────

func flFlap() {
    // ⚠ **坠落中(st=2)与结束后(st=3)都不许再扇翅膀**。

    // ⚠ **No flapping while falling (st=2) or after the run is over (st=3)**. The first version only

    //    第一版只挡了 st=3 ⇒ 撞了之后继续点屏幕,鸟会**一直被顶着往上升**、

    //    blocked st=3, so tapping after a hit kept **pushing the bird upward forever**, it never reached

    //    永远落不了地 ⇒ `flSettle` 永远不执行 ⇒ 结算画面与最高分存档**整条路都不跑**

    //    the ground, `flSettle` never ran, and the whole settle path (final screen + best-score save)

    //    (现象是"撞了以后游戏不结束,鸟飞出屏幕")。这一类叫「结束态忘了收输入」。

    //    never executed. The symptom reads as "after a crash the game never ends and the bird flies off

    //    screen". This family is called "the end state forgot to cut off input".

    if A[64] == 2 || A[64] == 3 { return }
    if A[64] == 0 { A[64] = 1 }
    A[69] = 0 - 60
    A[88] = A[88] + 1
    flSfxFlap()
}

// ── 撞了:先坠落,落了地才结算 ────────────────────────────────

// ── Hit: fall first, settle only once the ground is reached ────────────────────

func flDie(why: Int) {
    if A[64] != 1 { return }
    A[64] = 2
    A[75] = ui_time_get_ms()
    A[76] = 12
    A[89] = why
    flSfxHit()
    ui_vibrate_simple(90, 1)
}

func flSettle() {
    A[64] = 3
    flSaveBest()
    flSfxDie()
    ui_vibrate_simple(50, 1)
}

// ── 每拍(30ms ⇒ 约 33 拍/秒;⚠ 拍是逻辑,帧率由宿主按显示预算走)──

// ── Per tick (30ms ⇒ about 33 ticks/s; ⚠ a tick is logic, the host paces frames) ──

func flTick() {
    var i = 0
    if A[64] == 2 {
        // 坠落:还是同一套重力,只是不再响应输入

        // Falling: the same gravity, just no longer responding to input

        A[69] = A[69] + 6
        if A[69] > 90 { A[69] = 90 }
        A[68] = A[68] + A[69]
        if A[68] >= A[77] * flFP() {
            A[68] = A[77] * flFP()
            flSettle()
        }
        return
    }
    if A[64] == 0 {
        // 待开始:鸟原地上下浮,管子已经排好但不动

        // Ready: the bird bobs in place; the pipes are already laid out but do not move yet

        A[81] = (A[81] + 1) % 60
        var t = A[81]
        if t < 30 { A[68] = (A[77] / 2 + t / 3) * flFP() }
        else { A[68] = (A[77] / 2 + (60 - t) / 3) * flFP() }
        return
    }
    if A[64] != 1 { return }

    // 重力 + 位移

    // Gravity + motion

    A[69] = A[69] + 6
    if A[69] > 90 { A[69] = 90 }
    A[68] = A[68] + A[69]

    // 管子左移 + 回收(x + 3×间距 = 保持等距)

    // Pipes move left + recycle (x + 3×spacing keeps them evenly spaced)

    i = 0
    while i < flSlots() {
        A[i] = A[i] - A[80]
        if A[i] + A[79] * flFP() < 0 {
            A[i] = A[i] + flSlots() * A[87] * flFP()
            A[16 + i] = 60 + ui_math_rand(A[86] - A[85])
            A[32 + i] = 0
        }
        i = i + 1
    }
    // 云(视差:比管子慢一半)

    // Clouds (parallax: half the pipe speed)

    i = 0
    while i < 3 {
        A[48 + i] = A[48 + i] - A[80] / 2
        if A[48 + i] < 0 - 60 { A[48 + i] = A[65] + 20 }
        i = i + 1
    }
    // 地面纹理滚动

    // Ground texture scrolls

    A[84] = A[84] + A[80]
    if A[84] > 40 { A[84] = A[84] - 40 }

    // 计分:管子的右沿走过了鸟的中心

    // Score: the pipe's right edge has passed the bird's centre

    i = 0
    while i < flSlots() {
        if A[32 + i] == 0 && A[i] + A[79] * flFP() < A[70] {
            A[32 + i] = 1
            A[71] = A[71] + 1
            flSfxScore()
        }
        i = i + 1
    }

    // 碰撞:鸟的包围盒 × 管子缺口

    // Collision: the bird's box against the pipe gap

    var bx = A[70] / flFP()
    var by = A[68] / flFP()
    var r = flBirdR()
    // 落地

    // Ground

    if by + r >= A[77] { flDie(2); return }
    // 天花板

    // Ceiling

    if by - r < 0 { A[68] = r * flFP(); A[69] = 0 }
    i = 0
    while i < flSlots() {
        var px = A[i] / flFP()
        if bx + r > px && bx - r < px + A[79] {
            var gy = A[16 + i]
            if by - r < gy { flDie(1); return }
            if by + r > gy + A[78] { flDie(1); return }
        }
        i = i + 1
    }
}

// ── 画一帧 ────────────────────────────────────────────────────

// ── Draw one frame ────────────────────────────────────────────

func flDraw() {
    var w = A[65]
    var h = A[66]
    ui_2d_clear(flSky())

    // 上半层稍微亮一点(天空的两段色带,省掉渐变刷子)

    // The upper half is a touch lighter (a two-band sky, avoiding a gradient brush)

    ui_2d_rect(0, 0, w, A[77] / 3, flSky2(), 1, 0, 0)

    // 云

    // Clouds

    var i = 0
    while i < 3 {
        var cx = A[48 + i]
        var cy = 70 + i * 90
        ui_2d_rect(cx, cy, 54, 16, flCloud(), 1, 0, 8)
        ui_2d_rect(cx + 14, cy - 10, 34, 14, flCloud(), 1, 0, 7)
        i = i + 1
    }

    // 管子(成对:上管 + 下管 + 两个管口)

    // Pipes (in pairs: upper + lower + two caps)

    i = 0
    while i < flSlots() {
        var px = A[i] / flFP()
        var gy = A[16 + i]
        ui_2d_rect(px, 0, A[79], gy, flPipe(), 1, 0, 0)
        ui_2d_rect(px - 5, gy - 18, A[79] + 10, 18, flPipeCap(), 1, 0, 3)
        ui_2d_rect(px, gy + A[78], A[79], A[77] - gy - A[78], flPipe(), 1, 0, 0)
        ui_2d_rect(px - 5, gy + A[78], A[79] + 10, 18, flPipeCap(), 1, 0, 3)
        i = i + 1
    }

    // 地面

    // Ground

    ui_2d_rect(0, A[77], w, h - A[77], flGround(), 1, 0, 0)
    ui_2d_rect(0, A[77], w, 6, flGrass(), 1, 0, 0)
    i = 0
    while i < 8 {
        ui_2d_rect(i * 40 - A[84], A[77] + 14, 22, 5, flGrass(), 1, 0, 2)
        i = i + 1
    }

    // 鸟:身体 + 翅膀(扇动时抬起)+ 嘴 + 眼

    // Bird: body + wing (raised while flapping) + beak + eye

    var bx = A[70] / flFP()
    var by = A[68] / flFP()
    var br = flBirdR()
    ui_2d_circle(bx, by, br, flBird(), 1, 0)
    if A[69] < 0 { ui_2d_rect(bx - br, by - br / 2, br, br, flBeak(), 1, 0, 5) }
    else { ui_2d_rect(bx - br, by + br / 4, br, br, flBeak(), 1, 0, 5) }
    ui_2d_rect(bx + br - 5, by - 6, br, br / 2, flBeak(), 1, 0, 2)
    ui_2d_circle(bx + br / 3, by - br / 3, br / 5, flBlack(), 1, 0)

    // 撞了那一下闪白(12 拍内)

    // White flash on impact (within 12 ticks)

    // ⚠ 用 **`ui_2d_set_alpha` + 实心矩形**,不要用"粗线框"(`fill=0, lw=6`)——

    // ⚠ Use **`ui_2d_set_alpha` plus a filled rect**, not a thick outline (`fill=0, lw=6`), which draws a **white

    //    那画出来是一圈**白边框**,不是"整屏闪一下"。

    //    border** rather than a full-screen flash.

    if A[76] > 0 {
        A[76] = A[76] - 1
        ui_2d_set_alpha(70)
        ui_2d_rect(0, 0, w, h, flWhite(), 1, 0, 0)
        ui_2d_set_alpha(255)
    }

    // HUD:**一句拼出来的文案**(字符串 `+` 修好之后才写得出这一行)

    // HUD: **one concatenated line** (only writable now that string `+` is fixed)

    ui_2d_text(w / 2, 26, flHudText(), flWhite(), 18, VML_ANCHOR_CENTER)

    if A[64] == 0 {
        ui_2d_text(w / 2, A[77] / 2 + 90, flT("轻点起飞", "Tap to fly"), flText(), 15, VML_ANCHOR_CENTER)
        ui_2d_text(w / 2, A[77] / 2 + 116, flT("点一下扇一次翅膀", "each tap is one flap"), flText(), 12, VML_ANCHOR_CENTER)
    }
    if A[64] == 3 {
        ui_2d_text(w / 2, A[77] / 2 - 40, flT("这局结束", "Game over"), flWhite(), 24, VML_ANCHOR_CENTER)
        // 新纪录:把 best / score 两次比较分开写(`?:` 里再嵌 `?:` 容易看错)

        // New record: the two comparisons are kept separate (a `?:` nested in a `?:` is easy to misread)

        var line = flHudText()
        if A[71] >= A[72] && A[71] > 0 { line = flT("新纪录!", "New record! ") + int_to_str(A[71]) }
        ui_2d_text(w / 2, A[77] / 2, line, flPipe(), 18, VML_ANCHOR_CENTER)
        ui_2d_text(w / 2, A[77] / 2 + 40, flT("轻点重来(或按 R)", "tap to retry (or press R)"), flText(), 14, VML_ANCHOR_CENTER)
    }
    ui_2d_present()
}

func main() {
    // 界面语言:开局问一次宿主(0=中文 1=英文),存进 A[67](见 flT)

    // UI language: ask the host once at the start (0=Chinese 1=English) and store it in A[67] (see flT)

    A[67] = ui_scr_get_language()
    var lang = A[67]

    // 竖屏 + 不要屏幕手柄区 —— 两条都在**开窗之前**声明

    // Portrait + no on-screen gamepad — both declared **before** the window opens

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

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

    ui_win_open_ex(lang == 0 ? "过管子" : "Flappy", 0, 0, VML_WIN_PORTRAIT, VML_WIN_NO_GAMEPAD)

    // 尺寸:开窗**之后**再问一次 —— 这时 `ui_scr_get_w/h` 给的是实测画布(真值)

    // Size: re-read **after** the open — `ui_scr_get_w/h` now report the measured canvas (the real value)

    A[65] = ui_scr_get_w()
    A[66] = ui_scr_get_h()
    if A[65] <= 0 { A[65] = 411 }
    if A[66] <= 0 { A[66] = 726 }
    flLayout()

    ui_scr_set_keep_on(1)
    flLoadBest()
    flReset()
    flDraw()

    A[82] = ui_timer_set(30, 0)
    var acc = 0                     // 运动墙钟欠账(毫秒)—— 按墙钟补步,用户 2026-10-04 口径

                                    // Motion wall-clock debt in ms — step by wall clock (user rule, 2026-10-04)

    var lastMs = ui_time_get_ms()   // 上次补步取的墙钟时刻(墙钟起点,进主循环时取)

                                    // Wall-clock time of the last step (taken when entering the main loop)


    while ui_win_is_closed() == 0 {
        var t = ui_msg_fetch_wait(30)
        if t == flMsgClose() { break }

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

        // 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()
        acc = acc + (nowMs - lastMs)
        lastMs = nowMs
        if acc > 30 * 8 { acc = 0 }     // 卡太久别一次补一大串(防瞬移)

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

        while acc >= 30 {
            acc = acc - 30
            flTick()
        }
        if t == flMsgTimer() {
            // 定时器**只负责唤醒**(推进在上面按墙钟欠账走)

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

            flDraw()
        }
        else if t == flMsgKeydown() {
            var k = ui_msg_get_a()
            if k == flKeyEsc() { break }
            else if k == flKeySpace() || k == flKeyUp() || k == flKeyEnter() {
                if A[64] == 3 { flReset(); flFlap() } else { flFlap() }
            }
            else if k == flKeyR() { flReset() }
            flDraw()
        }
        else if t == flMsgTouchdown() {
            if A[64] == 3 { flReset(); flFlap() } else { flFlap() }
            flDraw()
        }
    }

    ui_timer_kill(A[82])
    ui_scr_set_keep_on(0)
    ui_win_close()
}