← 都来码 / Dolaima← 例程索引javascript/app/typing_rain.js原文

javascript/app/typing_rain.js · 44708 字节 · 下载原文

// 打字坠落 —— 用 **JavaScript** 写的手机游戏(打字游戏品类)

// Typing Rain -- a mobile game written in **JavaScript** (typing-game genre)

//

// 玩法:单词从屏幕上方缓缓落下,敲对字母就把它消掉,消不完掉到底就扣一条命。

// Gameplay: words drift down from the top of the screen; type the letters to clear them, and every word that reaches the bottom costs one life.

// 敲满 15 个词通关(胜利),3 条命掉光失败 —— 胜负各有各的音高,不看屏幕也分得出(赢 1319Hz / 输 131Hz)。

// Clear 15 words to win, lose all 3 lives to fail -- the win/lose tones are two octaves apart (win 1319 Hz / lose 131 Hz).

//

// ## 操作

// ## Controls

//

//   · **屏幕键盘**(7 列 × 4 行,A–Z):点字母键

//   · **On-screen keyboard** (7 columns x 4 rows, A-Z): tap a letter key

//   · 物理键盘 / 手柄字母键:A–Z(Win32 虚拟键 65–90,与手柄 A/B/X/Y 同一套编码)

//   · Physical keyboard / gamepad letters: A-Z (Win32 virtual keys 65-90, the same encoding as the gamepad's A/B/X/Y)

//   · 结束时按**回车**或点画面任意处重开

//   · When the round is over, press **Enter** or tap anywhere to restart

//

// ## 这一门前端的两条硬约束(都是实测出来的,不是"风格偏好")

// ## Two hard constraints of this frontend (both measured, not style preferences)

//

//   ① ~~**`function main()` 存在时,文件顶层的语句一条都不执行**(入口标签就是 `main`)~~ —— ✅ **已修好(2026-10-04 复验)**

//   ① ~~**With `function main()` present, not one top-level statement in the file runs** (the entry label *is* `main`)~~ -- ✅ **fixed (re-checked 2026-10-04)**

//      (`CodeGenerator.Generate` 先发顶层语句、再发函数,`labels["main"]` 被函数体覆盖)。

//      (`CodeGenerator.Generate` emits top-level statements first and functions second, and the function body overwrites `labels["main"]`.)

//      同理 `var G = 5;` 写在文件顶层**永远是 0**,`var A = [1,2,3];` 更是空指针。

//      For the same reason a top-level `var G = 5;` is **always 0**, and `var A = [1,2,3];` is a null pointer.

//      ⇒ **所有全局状态都在 `restart()` / `main()` 里赋值**,数组在函数体里建好再交给全局槽。

//      => **All global state is assigned in `restart()` / `main()`**, and arrays are built inside a function body before being handed to a global slot.

//      ⚠ ~~**常量要写 `#define`,不能写 `var C_BG = …`** —— 后者会静默变成 0(颜色 0 = alpha 全透明,屏幕上什么都没有)~~。

//      ~~Warning: **constants must be `#define`s, never `var C_BG = …`** -- the latter silently becomes 0 (colour 0 = fully transparent, nothing on screen)~~.

//      ~~实测:改前 trace-draw 每一笔都是 `0x00000000`(画面"编过了、跑完了、就是黑的"),改后 `0xFF101018`。~~

//      ~~Measured: before the fix every draw in trace-draw read `0x00000000` ("compiles, runs, and is black"); after it, `0xFF101018`.~~

//      ~~⚠ 而且**拼错的名字不报错**:未定义的标识符被当成变量(= 0)静默编过去,颜色就成了全透明。~~

//      ~~Warning: **a misspelt name is not an error** either: an undefined identifier is taken as a variable (= 0) and compiles silently, so the colour becomes fully transparent.~~

//      ✅ **2026-10-04 复验(上面这两条已修好,实测)**:顶层 `var G = 5; var H = 3 + 4;` + `function main(){ console.log("G =", G, "H =", H); }` 打出 `G =5 H =7`;

//      ✅ **re-checked 2026-10-04 (both fixed, measured)**: top-level `var G = 5; var H = 3 + 4;` + `function main(){ console.log("G =", G, "H =", H); }` prints `G =5 H =7`;

//      ⚠ **2026-10-13 补完**:2026-10-04 那次只修好了「顶层**没有**显式调 `main()`」这一种形状;

//         顶层写了 `main();` 的仍然红(`var K = 88;` 在 `main()` 里读回 0)—— 因为那时入口标签仍与用户

//         那个 `function main` 撞名。本次把入口换成顶层块**自己的**标签(`__js_toplevel`)⇒ 两种形状都对。

//      ⚠ **completed 2026-10-13**: the 2026-10-04 fix only covered programs whose top level does *not*

//         explicitly call `main()`; with a top-level `main();` the variable still read back 0, because the

//         entry label still collided with the user's `function main`. The entry is now the top-level block's

//         **own** label (`__js_toplevel`), so both shapes work.

//        顶层 `var A = [1,2,3];` 读出 `A[0]=1 A[2]=3`;函数里 `var CVAR = -15724520` 与顶层 `var CTOP = -15724520` 都画成 `0xFF101018`(不再 `0x00000000`);

//        a top-level `var A = [1,2,3];` reads back `A[0]=1 A[2]=3`; `var CVAR = -15724520` in a function and a top-level `var CTOP = -15724520` both draw as `0xFF101018` (no longer `0x00000000`);

//        拼错的名字现在报 `error: 未声明的变量 'CMISPELLED' [CodeGen_UndefinedVariable]`、编译失败。

//        a misspelt name now reports `error: undeclared variable 'CMISPELLED' [CodeGen_UndefinedVariable]` and fails to compile.

//      ⇒ 本文件**照旧**用「全局状态在 `restart()`/`main()` 里赋值 + 常量全 `#define`」(能跑就别动;新写的代码不必再绕)。

//      => this file **still** uses "global state assigned in `restart()`/`main()` + all constants as `#define`" (if it runs, leave it alone; newly written code need not work around it).

//

//   ② **函数里 `var` 出来的名字是「全局标签」,不是局部变量**(`GenerateVariableDecl` 写的是 `var_<名>`);

//   ② **A name introduced by `var` inside a function is a *global label*, not a local** (`GenerateVariableDecl` writes `var_<name>`);

//      **只有形参是真正的局部**(走 R14 负偏移的栈帧)。

//      **only formal parameters are genuinely local** (they live in the R14-negative stack frame).

//      实测:`function b(){var v=99;return v;} function a(){var v=1;b();return v;}` → `a()` 得 **99**,不是 1。

//      Measured: `function b(){var v=99;return v;} function a(){var v=1;b();return v;}` -> `a()` yields **99**, not 1.

//      ⇒ 本文件的每个函数都用自己的**前缀**给 `var` 命名(`tk*`、`dw*`…),不是洁癖,是避坑。

//      => Every function here prefixes its own `var` names (`tk*`, `dw*`, ...); that is not pedantry, it avoids the trap.

//

// ## 这一门前端**没有**的能力(本轮实测,绕过去了,写在这里免得下次重新发现)

// ## What this frontend **cannot** do (measured this round; worked around here so nobody rediscovers it)

//

//   · `intToStr(123)` 打出 `"1048576"`(缓冲区地址),`strToInt("456")` 得 0 ⇒ **数字拼不进字符串**。

//   · `intToStr(123)` prints `"1048576"` (a buffer address) and `strToInt("456")` gives 0 => **numbers cannot be joined into a string**.

//     ⚠ **2026-10-04 复验仍成立**:trace-draw 里 `intToStr(123)` 画成 `str="1048576"`(同 trace 里字面量 `"123"` 画成 `str="123"`);`strToInt("456")` = 0。

//     ⚠ **still holds on 2026-10-04**: in trace-draw `intToStr(123)` draws as `str="1048576"` (a literal `"123"` in the same trace draws as `str="123"`); `strToInt("456")` = 0.

//     分数因此走**查表画数字**:`digitStr()`(if 链返回字面量)+ `drawNum()`。

//     The score therefore uses **table-driven digit drawing**: `digitStr()` (an if-chain returning literals) plus `drawNum()`.

//   · `s.length` 读的是**串的前 4 个字节**(`"Hello World"` → 1819043144)⇒ 长度走 `sl()`:`charAt(i)!=0` 数到 0。

//   · `s.length` reads **the first four bytes of the string** (`"Hello World"` -> 1819043144) => length comes from `sl()`: count `charAt(i)!=0` until it hits 0.

//     ⚠ **2026-10-04 复验仍成立**:`"Hello World".length` 实测 = 1819043144(与注释完全一致)。

//     ⚠ **still holds on 2026-10-04**: `"Hello World".length` measured = 1819043144 (exactly as noted).

//   · `[1,2].concat([3,4])`(**直接对数组字面量**调方法)报 `期望 ';'` ⇒ 先存进变量再调。

//   · `[1,2].concat([3,4])` (calling a method **straight on an array literal**) reports `expected ';'` => assign to a variable first.

//     ⚠ **2026-10-04 复验仍成立**:`[1,2].concat([3,4])` 报 `期望 ';'`;先 `var t = [1,2];` 再 `t.concat([3,4])` 正常(`u[2]=3`)。

//     ⚠ **still holds on 2026-10-04**: `[1,2].concat([3,4])` reports `expected ';'`; `var t = [1,2];` then `t.concat([3,4])` works (`u[2]=3`).

//   · `DIGITS[i].charAt(0)`(**下标表达式**上调方法)编不出 `str_charat` ⇒ 也要先 `.charAt` 到一个变量。

//   · `DIGITS[i].charAt(0)` (a method call **on an index expression**) does not compile to `str_charat` => copy it into a variable first.

//     ⚠ **2026-10-04 复验仍成立**:`D[i].charAt(0)` 报「未定义的函数 'charAt'」;先 `var e = D[i];` 再 `e.charAt(0)` 得 56('8')。

//     ⚠ **still holds on 2026-10-04**: `D[i].charAt(0)` reports 「undefined function 'charAt'」; `var e = D[i];` then `e.charAt(0)` gives 56 ('8').

//

// ## 数组的「容量」很硬:`push` 不扩容

// ## Array capacity is rigid: `push` never grows

//

// 字面量 `[0,0,0,0,0,0]` 申请的是 `4 + 6*4` 字节,长度头在 `ptr+0`、元素 i 在 `ptr+4+4i`。

// The literal `[0,0,0,0,0,0]` allocates `4 + 6*4` bytes: the length header sits at `ptr+0`, element i at `ptr+4+4i`.

// `arr_push` 只把长度 +1 再写 ARR[n+1](**没有容量字段**)⇒ 推超过字面量格数就是静默越界写。

// `arr_push` just increments the length and writes ARR[n+1] (**there is no capacity field**) => pushing past the literal's slot count silently writes out of bounds.

// ⚠ **2026-10-04 复验仍成立**:`var a = [1,2,3]; var b = [9,8,7];` 之后 `a.push(4); … a.push(7);`,`b` 被静默写成 `5 6 7`(越界写)。

// ⚠ **still holds on 2026-10-04**: after `var a = [1,2,3]; var b = [9,8,7];` and `a.push(4); ... a.push(7);`, `b` becomes `5 6 7` (out-of-bounds write).

// 本文件的做法:`z6()` 建**容量 6** 的字面量,再把长度头 `arr[0-1] = 0` 清零当空数组用(`a[-1]` 就是 `ptr+0`)。

// The approach here: `z6()` builds a literal of **capacity 6** and then zeroes the length header with `arr[0-1] = 0` so it acts as an empty array (`a[-1]` *is* `ptr+0`).

//

// ## 本文件同时是一份**回归语料**

// ## This file doubles as a **regression corpus**

//

// `selfcheck()`(在开窗之前跑)把本轮修好的那批方法**逐条断言一遍**,每条打一个数

// `selfcheck()` (run before the window opens) **asserts every method fixed this round**, printing one number per check

// (编码 = `序号*10+是否通过`,序号即源码里 `ck()` 的调用顺序),并把失败数画成一条红条。

// (encoding = `index*10 + passed`, the index being the `ck()` call order in this file) and draws the failure count as a red bar.

// 它覆盖:`startsWith`/`endsWith`/`charAt`/`toUpperCase`/`toLowerCase`/`trim`/`padStart`/`padEnd`、

// It covers `startsWith`/`endsWith`/`charAt`/`toUpperCase`/`toLowerCase`/`trim`/`padStart`/`padEnd`,

// `split`/`join`、`push`/`pop`/`indexOf`/`includes`/`reverse`/`sort`/`slice`/`concat`、

// `split`/`join`, `push`/`pop`/`indexOf`/`includes`/`reverse`/`sort`/`slice`/`concat`,

// `forEach`/`map`/`filter`、`parseFloat`(正浮点位模式按整数比 = 数值同序)。

// `forEach`/`map`/`filter`, and `parseFloat` (positive float bit patterns compare as integers in numeric order).

//

// 跑法:

// How to run:

//   手机   vml run examples/javascript/typing_rain.js

//   phone  vml run examples/javascript/typing_rain.js

//   桌面   vmlcli Examples/javascript/typing_rain.js --vml-home <vml根> --screen 395x578 --trace-draw /tmp/tr.txt

//   desktop vmlcli Examples/javascript/typing_rain.js --vml-home <vml root> --screen 395x578 --trace-draw /tmp/tr.txt


// UI 库声明:**一条显式引用**(本前端实测为**文档级** —— 删掉照样编过、画面一样,见文件尾的说明)。

// UI library declarations: **one explicit reference** (measured as **documentation-level** on this frontend -- deleting it still compiles and draws the same; see the note at the end of this file).

#include "vml_ui.js"

// ── 颜色:**必须用 `#define`,不能写 `var C_BG = …`**(本前端写负数十进制,与 0xAARRGGBB 同值)──

// -- Colors: **they must be `#define`s, not `var C_BG = …`** (this frontend writes them as negative decimals, equal to 0xAARRGGBB) --

// 理由:文件级的 `var` 初始化**不执行**(见文件头约束 ①)⇒ 常量会变成 0(alpha=0 = 全透明,屏幕上什么都没有)。

// Why: a file-level `var` initializer **never runs** (constraint (1) in the header) => every constant becomes 0 (alpha = 0 = fully transparent, nothing on screen).

// 实测:改前 trace-draw 里每一笔都是 `0x00000000`,改后是 `0xFF101018`。`#define` 是**编译期文本替换**,不受影响。

// Measured: before the fix every draw in trace-draw read `0x00000000`; after it reads `0xFF101018`. `#define` is a **compile-time text substitution** and is unaffected.

#define C_BG -15724520
// 0xFF101018 background

#define C_PANEL -15066588
// 0xFF1A1A24 panel

#define C_KEY -14277062
// 0xFF26263A key face

#define C_KEYHI -13669574
// 0xFF2F6B3A highlighted key

#define C_TITLE -1513232
// 0xFFE8E8F0 title text

#define C_DIM -6643536
// 0xFF9AA0B0 dim text

#define C_GREEN -11409298
// 0xFF51E86E green

#define C_BLUE -11890471
// 0xFF4A90D9 blue

#define C_GOLD -2509750
// 0xFFD9B44A gold

#define C_RED -45747
// 0xFFFF4D4D red

#define C_MARK -8096
// 0xFFFFE060 marker yellow


// ── 全局状态(**全部在 main()/restart() 里赋值** —— 见文件头约束 ①)──

// -- Global state (**all assigned in main()/restart()** -- see constraint (1) in the header) --

// 画布宽高、键盘几何、界面语言、分数 / 命 / 已消词数 / 通关目标。

// Canvas size, keyboard geometry, UI language, score / lives / words cleared / goal.

// 本局是否结束 / 是否通关 / 退出标志、节拍计数、下一个词的倒计时。

// Round over / won / quit flags, the tick counter, and the spawn countdown.

// 危险词个数(廉价的与 filter 复算的两个 —— 互为判据)。

// The dangerous-word count (a cheap one and a filter cross-check -- mutual evidence).

// 命中 / 打错字母数、自检失败数。

// Matched / missed letters, and the self-check failure count.

var GW = 0;                 // canvas width

var GH = 0;                 // canvas height

var GKBW = 0;               // on-screen key width

var GKBH = 0;               // on-screen key height

var GKBTOP = 0;             // top of the on-screen keyboard

var GPLAYTOP = 0;           // top of the falling area

var GPLAYBOT = 0;           // bottom of the falling area (past it = lose a life)

var GLANG = 0;              // UI language (0 = Chinese)

var GSCORE = 0;             // score

var GLIVES = 0;             // lives (restart() gives 3)

var GCLEAR = 0;             // words cleared

var GGOAL = 0;              // words needed to win (main() gives 15 -- a file-level 15 read 0 back then; ✅ fixed 2026-10-04)

var GOVER = 0;              // 1 = this round is over

var GWIN = 0;               // 1 = the round was won

var GQUIT = 0;              // 1 = leave the main loop

var GTICK = 0;              // tick counter

var GSPAWN = 0;             // ticks until the next word spawns

var GDANGER = -1;           // words past the danger line (-1 = not computed yet)

var GDANGER2 = -1;          // the same count recomputed with filter (the two are mutual evidence)

var GMATCHED = 0;           // letters typed correctly

var GMISSED = 0;            // letters typed wrong

var GSCFAIL = 0;            // self-check failures


// 数组槽(在 main() 里用 z* 建好):活动词的指针与其 x / y / 已敲字母数 / 词长。

// Array slots (built with z* inside main()): the active words' pointers plus their x / y / typed count / length.

// 还有:当前该敲的字母码、打错过的字母码、最近几局的分数、结算柱状图。

// Also: the letters expected right now, the mistyped letters, the last few scores, and the result bars.

// 以及:词池与词池长度、26 个单字母串、难度倍率表。

// And: the word pool and its size, the 26 single-letter strings, and the difficulty table.

var ACT = 0;                // active word pointers

var AX = 0;                 // word x

var AY = 0;                 // word y

var AT = 0;                 // how many letters of this word are typed

var AL = 0;                 // this word's length (computed with sl(); `.length` is unusable on strings)

var NEXT = 0;               // the letter expected right now (drives the keyboard highlight)

var BADL = 0;               // letters typed wrong (deduplicated)

var SCORES = 0;             // the last few rounds' scores

var BARS = 0;               // result-chart widths

var POOL = 0;               // the word pool

var NP = 0;                 // word pool size

var ALPHA = 0;              // the 26 single-letter strings

var MULT = 0;               // difficulty multipliers (parseFloat bit patterns)


// 定容数组工厂:字面量给够格数,再把长度头清零 = 容量 N 的空数组。

// Fixed-capacity array factory: give the literal N slots, then zero the length header = an empty array with capacity N.

// ⚠ `push` 超过 N 就静默越界(没有容量字段)—— 每个数组的 N 都是按用量的上界取的。

// Note: `push` past N silently overruns (no capacity field); each N below is an upper bound on actual use.

// ⚠ **2026-10-04 复验仍成立**:容量 3 的字面量推 4 次,第 4 个元素越界写进了相邻数组(见上面那条实测)。

// ⚠ **still holds on 2026-10-04**: pushing 4 times into a 3-slot literal overruns into the neighbouring array (see the measurement above).

function z5() { var z5a = [0, 0, 0, 0, 0]; z5a[0 - 1] = 0; return z5a; }
// five-slot factory

function z6() { var z6a = [0, 0, 0, 0, 0, 0]; z6a[0 - 1] = 0; return z6a; }
// six-slot factory

function z8() { var z8a = [0, 0, 0, 0, 0, 0, 0, 0]; z8a[0 - 1] = 0; return z8a; }
// eight-slot factory


// 串长:本前端 `s.length` 读的是串的前 4 个字节,只能自己数(charAt 越界返回 0)。

// String length: `s.length` reads the first four bytes here, so count it by hand (charAt past the end returns 0).

function sl(ps) {
    var sln = 0;
    while (ps.charAt(sln) != 0) { sln = sln + 1; }
    return sln;
}

// 数字 → 单字符字符串(只能靠 if 链返回字面量,没有可用的 intToStr)。

// Digit -> one-character string (an if-chain returning literals is the only way; intToStr is unusable).

function digitStr(pd) {
    if (pd == 0) { return "0"; }
    if (pd == 1) { return "1"; }
    if (pd == 2) { return "2"; }
    if (pd == 3) { return "3"; }
    if (pd == 4) { return "4"; }
    if (pd == 5) { return "5"; }
    if (pd == 6) { return "6"; }
    if (pd == 7) { return "7"; }
    if (pd == 8) { return "8"; }
    return "9";
}

// 画一个非负整数(逐位查表 + 画)。sz = 字号,一位的推进量取 sz/2。

// Draw a non-negative integer (one table lookup per digit). sz = font size; one digit advances by sz/2.

function drawNum(dnx, dny, dnv, dncol, dnsz) {
    var dnnd = 1;
    var dnt = dnv;
    while (dnt >= 10) { dnt = dnt / 10; dnnd = dnnd + 1; }
    var dnpx = dnx;
    var dnk = dnnd;
    while (dnk > 0) {
        dnk = dnk - 1;
        var dnpw = 1;
        var dnq = 0;
        while (dnq < dnk) { dnpw = dnpw * 10; dnq = dnq + 1; }
        var dndg = (dnv / dnpw) % 10;
        ui_2d_text(dnpx, dny, digitStr(dndg), dncol, dnsz, 0);
        dnpx = dnpx + dnsz / 2;
    }
}

// 当前下落速度(像素/拍):每消 6 个词加 1。

// Current fall speed in pixels per tick: +1 for every 6 words cleared.

// 基准 2 是**按手指能跟上的速度定的**:落区 330px、一拍 90ms ⇒ 一个词从顶到底约 15 秒。

// The base of 2 is **tuned to what a finger can keep up with**: a 330 px play area at 90 ms per tick gives a word about 15 seconds to fall.

function fallSpeed() {
    return 2 + GCLEAR / 6;
}

// 重新开始一局(排行榜 SCORES 跨局保留)。

// Start a new round (the SCORES leaderboard survives across rounds).

function restart() {
    GSCORE = 0; GLIVES = 3; GCLEAR = 0; GOVER = 0; GWIN = 0;
    GTICK = 0; GSPAWN = 3; GDANGER = -1; GDANGER2 = -1;
    GMATCHED = 0; GMISSED = 0;
    ACT[0 - 1] = 0; AX[0 - 1] = 0; AY[0 - 1] = 0; AT[0 - 1] = 0; AL[0 - 1] = 0; NEXT[0 - 1] = 0;
    spawnWord();
    updNext();
}

// 生成一个新词:从池里随机取,落点在落区顶端,x 随机。

// Spawn one word: pick at random from the pool, drop it at the top of the play area at a random x.

function spawnWord() {
    if (ACT.length >= 4) { return; }
    var spwd = POOL[ui_math_rand(NP)];
    var splen = sl(spwd);
    var spwid = splen * 11;
    var spx = 8 + ui_math_rand(GW - 16 - spwid);
    ACT.push(spwd);
    AX.push(spx);
    AY.push(GPLAYTOP);
    AT.push(0);
    AL.push(splen);
}

// 移除第 k 个词:交换到末尾再 pop(本前端没有 remove/splice)。

// Remove word k: swap it to the end and pop (this frontend has no remove/splice).

function removeWord(pr) {
    var rmlast = ACT.length - 1;
    if (pr != rmlast) {
        ACT[pr] = ACT[rmlast]; AX[pr] = AX[rmlast]; AY[pr] = AY[rmlast];
        AT[pr] = AT[rmlast]; AL[pr] = AL[rmlast];
    }
    ACT.pop(); AX.pop(); AY.pop(); AT.pop(); AL.pop();
    // 少了一个词就必须重算 NEXT —— 否则键盘上会留着它的高亮(实测:3 条命掉光时亮了 5 个键,

    // A word leaving means NEXT must be rebuilt -- otherwise its highlight stays on the keyboard (measured: with all 3 lives gone, 5 keys lit

    // 而场上只剩 2 个词)。落底扣命那条路**不经过** typeLetter,所以刷新收在这里最稳。

    // while only 2 words were left). The "word hit the bottom" path never goes through typeLetter, so refreshing here is the safe spot.

    updNext();
}

// 焦点词 = 最靠下的那个(越靠近键盘越急)。

// The focus word is the lowest one (the closer to the keyboard, the more urgent).

function focusIdx() {
    if (ACT.length == 0) { return -1; }
    var fxbest = 0;
    var fxi = 1;
    while (fxi < ACT.length) {
        if (AY[fxi] > AY[fxbest]) { fxbest = fxi; }
        fxi = fxi + 1;
    }
    return fxbest;
}

// 重建「当前该敲的字母」表:只在状态变化时调用 —— 本 VM 没有 GC,每帧分配会一直涨。

// Rebuild the "next expected letter" table: only on state changes -- this VM has no GC, so per-frame allocation would grow forever.

function updNext() {
    NEXT[0 - 1] = 0;
    var uni = 0;
    while (uni < ACT.length) {
        var unwd = ACT[uni];
        NEXT.push(unwd.charAt(AT[uni]));
        uni = uni + 1;
    }
}

// 记一个打错的字母(去重,最多 8 个)—— 用 indexOf 判「在不在里面」。

// Record a mistyped letter (deduplicated, at most 8) -- indexOf decides whether it is already there.

function addBad(pbc) {
    if (BADL.indexOf(pbc) < 0) {
        if (BADL.length < 8) { BADL.push(pbc); }
    }
}

// 敲一个字母(Win32 虚拟键码,A=65)。

// Type one letter (Win32 virtual-key code, A = 65).

function typeLetter(ptc) {
    var tlfi = focusIdx();
    if (tlfi < 0) { return; }
    var tlwd = ACT[tlfi];
    var tlwant = tlwd.charAt(AT[tlfi]);
    if (tlwant == ptc) {
        AT[tlfi] = AT[tlfi] + 1;
        GMATCHED = GMATCHED + 1;
        ui_audio_beep(880, 60);
        if (AT[tlfi] >= AL[tlfi]) { wordDone(tlfi); }
        // 敲对一个字母之后**该敲的字母就换了** —— 不刷新键盘高亮会停在旧的那个上

        // A correct letter changes which letter is expected next -- without a refresh the keyboard highlight stays on the old one

        updNext();
    } else {
        GMISSED = GMISSED + 1;
        addBad(ptc);
        ui_audio_beep(220, 90);
    }
}

// 一个词敲完了。胜负音取两端的极值(赢 1319 / 输 131)。

// A word was completed. The win/lose tones take the two extremes (win 1319 / lose 131).

function wordDone(pw) {
    GSCORE = GSCORE + 10 * AL[pw] + 5;
    GCLEAR = GCLEAR + 1;
    removeWord(pw);
    if (GCLEAR >= GGOAL) {
        GWIN = 1; GOVER = 1;
        ui_audio_beep(1319, 320);
        pushScore();
    } else {
        ui_audio_beep(1047, 150);
    }
}

// 记一局分数(只留最近 5 局)。

// Record one round's score (keep the most recent 5).

function pushScore() {
    if (SCORES.length < 5) { SCORES.push(GSCORE); }
    // 结算柱:把"每个还没敲完的词敲对了几个字母"映射成柱宽。

    // Result bars: map "how many letters of each unfinished word were typed" to a bar width.

    // ⚠ 只在**一局结束那一次**算(map 会 malloc),绝不能放进每帧的绘制里 —— 本 VM 没有 GC。

    // Note: computed **once per round end** (map mallocs); it must never go on the per-frame draw path -- there is no GC here.

    BARS = AT.map(function (bx) { return bx * 14; });
}

// 危险词个数:先廉价地数一遍,**变了才**用 filter 复算一次(互为判据,也让 filter 每拍不分配)。

// Dangerous-word count: count cheaply first, and only when it changes re-derive it with filter (a cross-check, and it keeps filter off the per-tick path).

function updDanger() {
    var udn = 0;
    var udi = 0;
    while (udi < AY.length) {
        if (AY[udi] > GPLAYBOT - 90) { udn = udn + 1; }
        udi = udi + 1;
    }
    if (udn != GDANGER) {
        GDANGER = udn;
        var udlist = AY.filter(function (udy) { return udy > GPLAYBOT - 90; });
        GDANGER2 = udlist.length;
    }
}

// 一拍:下落 → 落底扣命 → 生成 → 更新危险数。

// One tick: fall -> lose a life for words past the bottom -> spawn -> refresh the danger count.

function tick() {
    if (GOVER != 0) { return; }
    GTICK = GTICK + 1;
    var tkspd = fallSpeed();
    var tki = 0;
    while (tki < AY.length) { AY[tki] = AY[tki] + tkspd; tki = tki + 1; }

    var tkj = AY.length - 1;
    while (tkj >= 0) {
        if (AY[tkj] > GPLAYBOT) {
            removeWord(tkj);
            GLIVES = GLIVES - 1;
            ui_audio_beep(196, 260);
        }
        tkj = tkj - 1;
    }
    if (GLIVES <= 0) {
        GLIVES = 0; GOVER = 1;
        ui_audio_beep(131, 320);
        pushScore();
        return;
    }

    GSPAWN = GSPAWN - 1;
    if (GSPAWN <= 0 && ACT.length < 4) {
        spawnWord();
        // 新词一进来就要进 NEXT,否则键盘高亮会少它那一个字母

        // A new word must enter NEXT right away, or its letter is missing from the keyboard highlight

        updNext();
        GSPAWN = 16;
    }
    updDanger();
}

// 按键:字母敲字,回车重开,Esc 退出。

// Key handling: letters type, Enter restarts, Esc quits.

function keyDown(pk) {
    if (GOVER != 0) {
        if (pk == 13) { restart(); }
        return;
    }
    if (pk == 27) { GQUIT = 1; return; }
    if (pk >= 65 && pk <= 90) { typeLetter(pk); }
}

// 点屏幕:键盘区内 → 敲那个字母;局末 → 重开。

// Tap: inside the keyboard area -> type that letter; round over -> restart.

function touchAt(ptx, pty) {
    if (GOVER != 0) { restart(); return; }
    if (pty < GKBTOP) { return; }
    var tkcol = (ptx - 8) / (GKBW + 2);
    var tkrow = (pty - GKBTOP) / (GKBH + 3);
    if (tkcol < 0 || tkcol > 6) { return; }
    if (tkrow < 0 || tkrow > 3) { return; }
    var tkno = tkrow * 7 + tkcol;
    if (tkno >= 0 && tkno < 26) { typeLetter(65 + tkno); }
}

// 画一个词:整词暗色 + 已敲部分的绿条 + 下一个字母的高亮框。

// Draw one word: the whole word dim, a green bar for the typed part, and a highlight box around the next letter.

function drawWord(pwk) {
    var dwx = AX[pwk];
    var dwy = AY[pwk];
    var dwc = C_DIM;
    if (pwk == focusIdx()) { dwc = C_TITLE; }
    ui_2d_text(dwx, dwy, ACT[pwk], dwc, 18, 0);
    if (AT[pwk] > 0) {
        ui_2d_rect(dwx, dwy + 21, AT[pwk] * 11, 3, C_GREEN, 1, 0, 0);
    }
    ui_2d_rect(dwx + AT[pwk] * 11 - 1, dwy - 2, 12, 24, C_MARK, 0, 2, 2);
}

// 画屏幕键盘:26 个键,本轮该敲的字母高亮。

// Draw the on-screen keyboard: 26 keys, with the letters expected right now highlighted.

function drawKeyboard() {
    var dki = 0;
    while (dki < 26) {
        var dkcol = dki % 7;
        var dkrow = dki / 7;
        var dkx = 8 + dkcol * (GKBW + 2);
        var dky = GKBTOP + dkrow * (GKBH + 3);
        var dkc = C_KEY;
        if (NEXT.includes(65 + dki)) { dkc = C_KEYHI; }
        ui_2d_rect(dkx, dky, GKBW, GKBH, dkc, 1, 0, 6);
        ui_2d_text(dkx + GKBW / 2, dky + GKBH / 2 - 9, ALPHA[dki], C_TITLE, 16, 1);
        dki = dki + 1;
    }
}

// 画一帧。

// Draw one frame.

function drawAll() {
    ui_2d_clear(C_BG);

    // HUD:分数 / 命 / 进度

    // HUD: score / lives / progress

    // HUD 三段的 x 全部由屏宽算出来(窄窗口也不越界;手机上 395 就是老位置)。

    // All three HUD x positions come from the screen width (nothing overflows on a narrow window; on a 395-wide phone this is where they always were).

    ui_2d_text(8, 6, GLANG == 0 ? "分数" : "Score", C_DIM, 13, 0);
    drawNum(46, 4, GSCORE, C_GOLD, 20);
    var dhx = GW / 2 - 30;
    ui_2d_text(dhx, 6, GLANG == 0 ? "命" : "Life", C_DIM, 13, 0);
    var dhl = 0;
    while (dhl < GLIVES) {
        ui_2d_rect(dhx + 26 + dhl * 16, 6, 12, 12, C_RED, 1, 0, 3);
        dhl = dhl + 1;
    }
    var dhp = GW - 118;
    ui_2d_text(dhp, 6, GLANG == 0 ? "进度" : "Done", C_DIM, 13, 0);
    drawNum(dhp + 40, 4, GCLEAR, C_GREEN, 20);
    ui_2d_text(dhp + 68, 8, "/", C_DIM, 14, 0);
    drawNum(dhp + 78, 4, GGOAL, C_DIM, 20);

    // 危险提示条:越接近底越红(长度 = 危险词数 * 40)

    // Danger strip: redder the closer they get (length = dangerous words * 40)

    if (GDANGER > 0) {
        ui_2d_rect(8, GPLAYBOT + 2, GDANGER * 40, 4, C_RED, 1, 0, 0);
    }
    // 打错过的字母(最多 8 个,画成小方块计数)

    // Mistyped letters (at most 8, drawn as small blocks)

    ui_2d_rect(GW - 8 - BADL.length * 12, 26, BADL.length * 10, 6, C_RED, 1, 0, 0);

    // 下落中的词

    // The falling words

    var dai = 0;
    while (dai < ACT.length) {
        drawWord(dai);
        dai = dai + 1;
    }

    drawKeyboard();

    // 自检失败的红条(宽度 = 失败条数 * 20)—— 正常时什么都不画

    // Self-check failure bar (width = failures * 20) -- nothing is drawn when everything passes

    if (GSCFAIL > 0) {
        ui_2d_rect(0, GH - 6, GSCFAIL * 20, 6, C_RED, 1, 0, 0);
    }

    // 结算 / 结束

    // Result overlay

    if (GOVER != 0) {
        ui_2d_rect(20, GH / 2 - 80, GW - 40, 156, C_PANEL, 1, 0, 10);
        if (GWIN != 0) {
            ui_2d_text(GW / 2, GH / 2 - 56, GLANG == 0 ? "通关!" : "CLEARED!", C_GREEN, 22, 1);
        }
        if (GWIN == 0) {
            ui_2d_text(GW / 2, GH / 2 - 56, GLANG == 0 ? "结束" : "GAME OVER", C_RED, 22, 1);
        }
        ui_2d_text(GW / 2 - 40, GH / 2 - 22, GLANG == 0 ? "分数" : "Score", C_DIM, 13, 0);
        drawNum(GW / 2 - 6, GH / 2 - 24, GSCORE, C_GOLD, 18);
        ui_2d_text(GW / 2 - 40, GH / 2 + 2, GLANG == 0 ? "词数" : "Words", C_DIM, 13, 0);
        drawNum(GW / 2 - 6, GH / 2, GCLEAR, C_GREEN, 18);

        // 排行榜:sort 升序 + reverse 降序(两个都在原地改,返回同一个数组)

        // Leaderboard: sort ascending then reverse to descending (both mutate in place and return the same array)

        var dbs = SCORES.sort();
        dbs.reverse();
        ui_2d_text(GW / 2 - 40, GH / 2 + 26, GLANG == 0 ? "排行" : "Best", C_DIM, 13, 0);
        var dbi = 0;
        while (dbi < dbs.length && dbi < 3) {
            drawNum(GW / 2 - 6 + dbi * 40, GH / 2 + 24, dbs[dbi], C_BLUE, 18);
            dbi = dbi + 1;
        }
        // 结算柱(在 pushScore() 里用 map 算好的):还在场上的词各敲对了几个字母

        // Result bars (built with map inside pushScore()): how many letters each surviving word had typed

        var dbx = 0;
        while (dbx < BARS.length && dbx < 4) {
            ui_2d_rect(28, GH / 2 + 46 + dbx * 9, BARS[dbx], 7, C_BLUE, 1, 0, 0);
            dbx = dbx + 1;
        }
        ui_2d_text(GW / 2, GH / 2 + 64, GLANG == 0 ? "按回车 / 点屏幕 再来一局" : "Enter / tap to play again", C_MARK, 13, 1);
    }
    ui_2d_present();
}

// 自检:把本轮修好的那批方法逐条断言一遍,打「序号*10+通过」。开窗之前跑。

// Self-check: assert every method fixed this round, printing `index*10 + passed`. Runs before the window opens.

function selfcheck() {
    var scf = 0;
    var sci = 0;
    var scs = "Hello World";
    // 1-2 startsWith 的正反两面必须给出不同答案

    // 1-2 startsWith must answer differently for the positive and the negative case

    if (scs.startsWith("Hello") == 1) { sci = sci + 1; } else { scf = scf + 1; }
    if (scs.startsWith("World") == 0) { sci = sci + 1; } else { scf = scf + 1; }
    // 3-4 endsWith

    if (scs.endsWith("World") == 1) { sci = sci + 1; } else { scf = scf + 1; }
    if (scs.endsWith("Hello") == 0) { sci = sci + 1; } else { scf = scf + 1; }
    // 5-7 charAt:'H'=72 / 'd'=100 / 越界返回 0

    // 5-7 charAt: 'H' = 72, 'd' = 100, and past the end returns 0

    if (scs.charAt(0) == 72) { sci = sci + 1; } else { scf = scf + 1; }
    if (scs.charAt(10) == 100) { sci = sci + 1; } else { scf = scf + 1; }
    if (scs.charAt(99) == 0) { sci = sci + 1; } else { scf = scf + 1; }
    // 8-9 sl 自己数长度(.length 不可用)

    // 8-9 sl counts the length by hand (.length is unusable)

    if (sl("Hello World") == 11) { sci = sci + 1; } else { scf = scf + 1; }
    if (sl("") == 0) { sci = sci + 1; } else { scf = scf + 1; }
    // 10-11 toUpperCase / toLowerCase(**返回新串、原串不变** —— 清单 BUG-11 修好之后)

    // 10-11 toUpperCase / toLowerCase (**returns a new string, the original is unchanged** -- after list item BUG-11 was fixed)

    var scu = "abc";
    var scu2 = scu.toUpperCase();
    // 新串第 2 位是 'C'(67)、原串第 0 位仍是 'a'(97):断言正确的 ECMAScript 语义

    if (scu2.charAt(2) == 67 && scu.charAt(0) == 97) { sci = sci + 1; } else { scf = scf + 1; }
    var scl = "XYZ";
    var scl2 = scl.toLowerCase();
    if (scl2.charAt(2) == 122) { sci = sci + 1; } else { scf = scf + 1; }
    // 12-13 trim

    var sct = "  go  ";
    var sct2 = sct.trim();
    if (sct2.charAt(0) == 103 && sct2.charAt(2) == 0) { sci = sci + 1; } else { scf = scf + 1; }
    // 14-15 padStart / padEnd(padChar 必须是字符码,不是指针)

    // 14-15 padStart / padEnd (padChar must be a character code, not a pointer)

    var scp = "7";
    var scp2 = scp.padStart(3, "0");
    if (scp2.charAt(0) == 48 && scp2.charAt(2) == 55) { sci = sci + 1; } else { scf = scf + 1; }
    var scq = "7";
    var scq2 = scq.padEnd(3, "0");
    if (scq2.charAt(0) == 55 && scq2.charAt(2) == 48) { sci = sci + 1; } else { scf = scf + 1; }
    // 16-17 split / join

    var scv = "a,b,c";
    var scv2 = scv.split(",");
    if (scv2.length == 3) { sci = sci + 1; } else { scf = scf + 1; }
    var scj = z5();
    scj.push(1); scj.push(2); scj.push(3);
    var scjs = scj.join("-");
    if (scjs.charAt(0) == 49 && scjs.charAt(1) == 45 && scjs.charAt(4) == 51) { sci = sci + 1; } else { scf = scf + 1; }
    // 18-21 定容数组 + push/pop/indexOf/includes

    // 18-21 fixed-capacity array + push/pop/indexOf/includes

    var sca = z5();
    sca.push(11); sca.push(22); sca.push(33);
    if (sca.length == 3 && sca[0] == 11 && sca[2] == 33) { sci = sci + 1; } else { scf = scf + 1; }
    if (sca.indexOf(22) == 1 && sca.indexOf(99) == -1) { sci = sci + 1; } else { scf = scf + 1; }
    if (sca.includes(33) == 1 && sca.includes(99) == 0) { sci = sci + 1; } else { scf = scf + 1; }
    if (sca.pop() == 33 && sca.length == 2) { sci = sci + 1; } else { scf = scf + 1; }
    // 22-24 sort / reverse / slice / concat

    var scb = z5();
    scb.push(40); scb.push(30); scb.push(20); scb.push(10);
    var scb2 = scb.sort();
    if (scb2[0] == 10 && scb2[3] == 40) { sci = sci + 1; } else { scf = scf + 1; }
    var scb3 = scb.reverse();
    if (scb3[0] == 40 && scb3[3] == 10) { sci = sci + 1; } else { scf = scf + 1; }
    var scc = scb.slice(1, 3);
    if (scc.length == 2 && scc[0] == 30) { sci = sci + 1; } else { scf = scf + 1; }
    var scd1 = z5(); scd1.push(1); scd1.push(2);
    var scd2 = z5(); scd2.push(3); scd2.push(4);
    var scd3 = scd1.concat(scd2);
    if (scd3.length == 4 && scd3[3] == 4) { sci = sci + 1; } else { scf = scf + 1; }
    // 25-27 forEach / map / filter(曾经拿数组指针当循环上界)

    // 25-27 forEach / map / filter (they used to treat the array pointer as the loop bound)

    var sce = z5(); sce.push(1); sce.push(2); sce.push(3); sce.push(4);
    var scn = 0;
    sce.forEach(function (scex) { scn = scn + scex; });
    if (scn == 10) { sci = sci + 1; } else { scf = scf + 1; }
    var scg = z5(); scg.push(1); scg.push(2); scg.push(3); scg.push(4);
    var scm = scg.map(function (scgx) { return scgx * 2; });
    if (scm.length == 4 && scm[3] == 8) { sci = sci + 1; } else { scf = scf + 1; }
    var sch = z5(); sch.push(1); sch.push(2); sch.push(3); sch.push(4); sch.push(5); sch.push(6);
    var scfl = sch.filter(function (schx) { return schx % 2 == 0; });
    if (scfl.length == 3 && scfl[2] == 6) { sci = sci + 1; } else { scf = scf + 1; }
    // 28-30 parseFloat:正浮点的 IEEE-754 位模式按整数比大小 = 数值同序

    // 28-30 parseFloat: for positive floats the IEEE-754 bit pattern compares as an integer in numeric order

    if (parseFloat("1.25") < parseFloat("1.5")) { sci = sci + 1; } else { scf = scf + 1; }
    if (parseFloat("2.5") > parseFloat("1.5")) { sci = sci + 1; } else { scf = scf + 1; }
    if (parseFloat("abc") == 0) { sci = sci + 1; } else { scf = scf + 1; }

    // 两个数分开打:`sci*10+标志` 那种编码在标志为 0 时和"上一档 +1"是同一个数(踩过)。

    // Print the two numbers separately: the `sci*10+flag` encoding collides with "one tier up" when the flag is 0 (this was hit for real).

    console.log("selfcheck checks=", sci, "fails=", scf);
    GSCFAIL = scf;
}

// 入口。

// Entry point.

function main() {
    selfcheck();

    GLANG = ui_scr_get_language();

    // 26 个单字母串:split 把分隔符换成 NUL,每段就是一个 1 字母的串。

    // The 26 single-letter strings: split turns each delimiter into a NUL, so every part is a one-letter string.

    var mnl = "A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z";
    ALPHA = mnl.split(",");

    // 词池:两半各切一刀再 concat —— 顺带证明 slice/concat 的返回值能当数组用。

    // The word pool: cut the list in half and concat -- which also proves slice/concat return a usable array.

    var mnw = "cat,dog,go,fish,bird,tree,moon,star,rain,code,bug,run";
    var mnup = mnw.toUpperCase();
    var mnall = mnup.split(",");
    var mneasy = mnall.slice(0, 6);
    var mnhard = mnall.slice(6, 12);
    POOL = mneasy.concat(mnhard);
    NP = POOL.length;

    // 难度倍率表:split → map(parseFloat)。**只能这样拿浮点**(本前端没有 float 运算),

    // Difficulty multiplier table: split -> map(parseFloat). This is the only way to get floats here (no float arithmetic on this frontend),

    // 而正浮点的位模式按整数比大小与数值同序,够用来判「下一档比这一档快」。

    // and for positive floats the bit pattern compares as an integer in numeric order, which is enough to tell "the next tier is faster".

    var mnsp = "1.0,1.25,1.5,2.0,2.5";
    var mnparts = mnsp.split(",");
    MULT = mnparts.map(function (mns) { return parseFloat(mns); });
    var mni = 1;
    while (mni < MULT.length) {
        if (MULT[mni] <= MULT[mni - 1]) { GSCFAIL = GSCFAIL + 1; }
        mni = mni + 1;
    }

    ACT = z6(); AX = z6(); AY = z6(); AT = z6(); AL = z6(); NEXT = z6();
    BADL = z8(); SCORES = z5(); BARS = z6();

    // 开窗时传 0:尺寸交给宿主(用真实可用区)。**开窗之后**再问一次真实画布 —— 开窗前问到的只是估算值。

    // Open with 0: the host decides the size (the real usable area). Ask the **real** canvas only after the window is open -- before that the host can only estimate.

    ui_win_open(GLANG == 0 ? "打字坠落" : "Typing Rain", 0, 0);
    ui_scr_set_keep_on(1);

    GW = ui_scr_get_w();
    if (GW <= 0) { GW = 360; }
    GH = ui_scr_get_h();
    if (GH <= 0) { GH = 620; }
    GGOAL = 15;
    GKBH = 40;
    GKBW = (GW - 28) / 7;
    GKBTOP = GH - GKBH * 4 - 18;
    GPLAYTOP = 62;
    GPLAYBOT = GKBTOP - 12;

    restart();

    var mntid = ui_timer_set(90, 1);
    var mnAcc = 0;                    // 运动墙钟欠账(毫秒)—— 按墙钟补步,用户 2026-10-04 口径

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

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

                                      // wall-clock time of the last catch-up step (the wall-clock origin, sampled when entering the main loop)

    drawAll();

    while (ui_win_is_closed() == 0) {
        var mnt = ui_msg_fetch_wait(0);
        if (mnt == 10) { break; }

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

        // how many steps this frame takes: **driven by the wall-clock deficit** (timer resolution differs per platform => never treat "ticks received" as time).

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

        // user's 2026-10-04 rule: all game clocks read system milliseconds.

        var mnNow = ui_time_get_ms();
        mnAcc = mnAcc + (mnNow - mnLastMs);
        mnLastMs = mnNow;
        if (mnAcc > 90 * 8) { mnAcc = 0; }   // 卡太久别一次补一大串(防瞬移)

                                             // do not catch up in one huge burst after a long stall (prevents teleporting)

        while (mnAcc >= 90) {
            mnAcc = mnAcc - 90;
            tick();
        }

        if (mnt == 9) { drawAll(); }   // 定时器**只负责唤醒**(推进在上面按墙钟欠账走)

                                       // the timer **only wakes us up** (advancement happens above, driven by the wall-clock deficit)

        if (mnt == 1) { keyDown(ui_msg_get_a()); if (GQUIT != 0) { break; } drawAll(); }
        if (mnt == 6) { touchAt(ui_msg_get_a(), ui_msg_get_b()); drawAll(); }
    }

    ui_timer_kill(mntid);
    ui_scr_set_keep_on(0);
    ui_win_close();

    // 收尾:给无头验证留几个确定性判据(stdout 在本环境不可靠,真正的判据看 --trace-draw)。

    // Wrap-up: leave a few deterministic values for headless checks (stdout is unreliable here; the real evidence is --trace-draw).

    console.log("typing_rain scfail=", GSCFAIL, " score=", GSCORE, " cleared=", GCLEAR, " lives=", GLIVES, " win=", GWIN, " ticks=", GTICK);
    console.log("typing_rain matched=", GMATCHED, " missed=", GMISSED, " danger=", GDANGER, " danger2=", GDANGER2);
    print("typing_rain scores=");
    print(SCORES.join(","));
    println("");
}