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