kotlin/app/puzzle15.kt · 22061 字节 · 下载原文
// 数字华容道(15 拼图)—— 用 **Kotlin** 写的手机游戏
// 15-puzzle — a phone game written in **Kotlin**
//
// 玩法:4×4 里放了 1..15 与一个空格,点空格**紧邻**的那一格就把它滑进空格;排回 1..15 即复原。
// Play: a 4x4 board holds 1..15 and one blank. Tap a tile **adjacent** to the blank to slide it in;
// restoring 1..15 in order solves the puzzle.
//
// ## 打乱是**用合法移动做出来的**,不是"随机排列"
// ## The shuffle is made of **legal moves**, not a random permutation
//
// 15 拼图有一半的排列**无解**(逆序数 + 空格行号的奇偶性决定)。"直接乱排"的话: 一半的局永远拼不回来,而玩家看不出来 —— 那不是难度,是坏局。
// Half of all permutations are **unsolvable** (the parity of inversions plus the blank's row decides).
// A "random permutation" shuffle leaves half of the games impossible, and the player cannot tell —
// that is not difficulty, that is a broken game.
//
// 这里从**已复原**的状态出发做 N 次**合法滑动**(每一次都真的能把空格滑过去) ⇒ 得到的局面必然有解,而且**难度是可控的**(N 越大越乱)。
// This starts from the **solved** state and performs N **legal slides** (each one really moves the blank)
// => the result is always solvable, and the difficulty is controllable (bigger N = more scrambled).
//
// 验收时会**独立算一遍**奇偶性(不是复用打乱那段代码)当作判据,见 `solvable()`。
// Acceptance recomputes the parity **independently** (not by reusing the shuffle code) — see `solvable()`.
//
// ## 这一份例程用到的 Kotlin 特性
// ## Kotlin features used here
//
// · **扩展函数** `fun Int.row4()` / `col4()`(修之前 `fun Int.f()` 连**解析**都过不去);
// · **extension functions** (before the fix `fun Int.f()` could not even be *parsed*);
// · **字符串模板**(`"步数 $gMoves"`)—— 修之前会静默打出一串数字;
// · **string templates** — these used to silently print digits;
// · **`when` 作表达式**(`tileColor`)—— 修之前它把数据段标签的地址当结果;
// · **`when` as an expression** — it used to yield the address of a data label;
// · **`for (i in 0..15)`** 与 **`x in a..b && y in a..b`**(后者修之前会把 `&&` 当成范围上界);
// · **`for (i in 0..15)`** and **`x in a..b && y in a..b`** (the latter used to swallow `&&` into the range bound);
// · **`const val`** 与**顶层 `var` 数组**;
// · **`const val`** and a **file-level `var` array**.
//
// 跑法 —— 手机:`vml run examples/kotlin/puzzle15.kt`
// How to run — on the phone: `vml run examples/kotlin/puzzle15.kt`
// 跑法 —— 桌面:`vmlcli Examples/kotlin/puzzle15.kt --screen 411x726 --frames /tmp/fr`
// How to run — on the desktop: `vmlcli Examples/kotlin/puzzle15.kt --screen 411x726 --frames /tmp/fr`
// 确定性一局(固定种子 + 打印棋盘与空格位置后自己退出,供脚本化验收):
// A deterministic round (fixed seed + prints the board and the blank, then exits — for scripted acceptance):
// `vmlcli Examples/kotlin/puzzle15.kt --screen 411x726 --arg demo`
// ── 显式引用:下面用到的 `ui_*` 来自共享库 vmlui.vml ──────────────────
// ── Explicit reference: the `ui_*` used below come from the shared library vmlui.vml ──
// ⚠ `.*` 必须有:`import vmlui`(无 `.*`)会被 `ExtractImports` 的正则**静默忽略**。
// ⚠ The `.*` is required: `import vmlui` (without `.*`) is silently ignored by `ExtractImports`.
// ⚠ 但整行删掉也照样编、照样跑 —— 前端靠 `ui_` 前缀**自动**链 `vmlui.vml`。
// ⚠ But deleting the whole line still compiles and runs — the frontend auto-links `vmlui.vml` from the `ui_` prefix.
// ⇒ 所以这条是**文档级**的显式声明,不是强制开关(判据见 `FRONTEND_DEFECTS.md` 的 Kotlin 一节)。
// ⇒ So this is a **documentation-level** explicit reference, not a hard switch (see the Kotlin section of `FRONTEND_DEFECTS.md`).
// ✅ 2026-10-04 复验仍成立:三种写法(`import vmlui` / `import vmlui.*` / 整行删掉)产出的指令数完全相同(59377 条)。
// ✅ Re-verified 2026-10-04, still holds: all three forms (`import vmlui` / `import vmlui.*` / line deleted) produce the identical instruction count (59377).
#include "../../../Lib/kotlin/vml_ui.kt"
import vmlui.*
const val SIDE = 4
const val CELLS = 16
const val BLANK = 0
const val SHUFFLE_MOVES = 200
// ── 配色:十六进制 `0xAARRGGBB` ──
// ── Palette: hex `0xAARRGGBB` ──
// 历史:v0.96.592 及之前本前端不认 `0x` 字面量(词法器把它切成 `0` + 一个标识符)。
// History: up to v0.96.592 this frontend did not accept `0x` literals (the lexer split them into `0` plus an identifier).
// 实参位置还会被静默吃掉 ⇒ 颜色变 0、一条错都不报;当时只能用负十进制绕过去。
// In argument position the stray identifier was swallowed silently ⇒ the color became 0 with no error; negative decimals were the workaround.
// 现已修(词法器认 `0x`/`0b`,解析器按 **32 位字**收窄)。
// Fixed now (the lexer accepts `0x`/`0b` and the parser narrows to a **32-bit word**).
// ✅ 2026-10-04 复验仍成立:`0xFF` 得 255、`0xFF101820` 得 -15722464、`0b1011` 得 11,实参位置也不再被吃掉。
// ✅ Re-verified 2026-10-04, still holds: `0xFF` gives 255, `0xFF101820` gives -15722464, `0b1011` gives 11, and argument positions are no longer swallowed.
// 下面那张是当年的对照表,留作记录。
// The table below is the old cross-reference, kept for the record.
// BG 0xFF101018 = -15724520 PANEL 0xFF232A38 = -14472648
// TILE_A 0xFF4E9AE0 = -11625760 TILE_B 0xFF3E7FC0 = -12681280
// TILE_F 0xFF8A5AD0 = -7710000 号越大越偏紫,拼对的位置用绿色
// BLANKC 0xFF161B24 = -15328476 TITLE 0xFFE8E8F0 = -1513232
// MUTED 0xFF9AA0B0 = -6643536 GREEN 0xFF51E86E = -11409298
// GOLD 0xFFFFC040 = -16320 RED 0xFFE06050 = -2072496
var LANG = 0
var SW = 0
var SH = 0
var BX = 0
var BY = 0
var CELL = 0
// 棋盘:下标 = row*4+col,值 = 1..15,或 0 表示空格
// Board: index = row*4+col, value = 1..15, or 0 for the blank
var gTiles = arrayOf(
0,0,0,0,
0,0,0,0,
0,0,0,0,
0,0,0,0)
var gMoves = 0
var gSeed = 1
var gStartMs = 0
var gSolved = 0
var gWonMs = 0
var gMaxPrims = 0
var gFrame = 0
var gFrameLimit = 0
/// 空格在哪一格
//where the blank is
fun blankAt(): Int {
var i = 0
while (i < CELLS) {
if (gTiles[i] == BLANK) { return i }
i = i + 1
}
return -1
}
// 扩展函数
// extension functions
fun Int.row4(): Int { return this / SIDE }
fun Int.col4(): Int { return this % SIDE }
/// 确定性伪随机(**不用 `ui_math_rand`**:验收要能复现同一个局面)
/// A deterministic PRNG (**not `ui_math_rand`**: acceptance needs to reproduce the same position)
fun rnd(n: Int): Int {
gSeed = gSeed * 1103515245 + 12345
if (gSeed < 0) { gSeed = 0 - gSeed }
return gSeed % n
}
/// 从**已复原**出发做 `times` 次合法滑动 —— 保证有解,而且不会原地来回(每次都换一个方向)
/// `times` **legal slides** from the **solved** state — always solvable, and it never just
/// undoes the previous move (each step picks a different direction)
fun shuffle(times: Int) {
var i = 0
// 先摆 1..15,末尾那一格再放 0(空格)
// Fill in 1..15 first, then put a 0 (the blank) in the last cell
while (i < CELLS) { gTiles[i] = i + 1; i = i + 1 }
gTiles[CELLS - 1] = BLANK
var k = 0
while (k < times) {
var b = blankAt()
var r = b.row4()
var c = b.col4()
// 候选方向按 (0,1,2,3) 的顺序数到第 pick 个可用的
// Count the candidate directions in (0,1,2,3) order and take the pick-th usable one
var nd = 0
if (r > 0) { nd = nd + 1 }
if (r < SIDE - 1) { nd = nd + 1 }
if (c > 0) { nd = nd + 1 }
if (c < SIDE - 1) { nd = nd + 1 }
var pick = rnd(nd)
var dir = -1
var seen = 0
if (r > 0) { if (seen == pick) { dir = 0 }; seen = seen + 1 }
if (dir < 0 && r < SIDE - 1) { if (seen == pick) { dir = 1 }; seen = seen + 1 }
if (dir < 0 && c > 0) { if (seen == pick) { dir = 2 }; seen = seen + 1 }
if (dir < 0 && c < SIDE - 1) { if (seen == pick) { dir = 3 }; seen = seen + 1 }
slideDir(dir)
k = k + 1
}
gMoves = 0
gSolved = 0
gStartMs = ui_time_get_ms()
}
/// 按方向滑一格:0=上 1=下 2=左 3=右("空格往这个方向挪",也就是那一边的格子滑进来)
/// Slide one step: 0=up 1=down 2=left 3=right (the blank moves that way, so the tile on that side slides in)
fun slideDir(dir: Int): Int {
if (gSolved == 1) { return 0 }
var b = blankAt()
var r = b.row4()
var c = b.col4()
var t = -1
if (dir == 0 && r > 0) { t = b - SIDE }
if (dir == 1 && r < SIDE - 1) { t = b + SIDE }
if (dir == 2 && c > 0) { t = b - 1 }
if (dir == 3 && c < SIDE - 1) { t = b + 1 }
if (t < 0) { return 0 }
gTiles[b] = gTiles[t]
gTiles[t] = BLANK
return 1
}
/// 点某一格:只有**紧邻空格**才滑得动(横向差 1 且同一行;纵向差一整行)
/// Tap a cell: only a tile **adjacent** to the blank can slide
/// (one column apart on the same row, or exactly one row apart)
fun tryMove(idx: Int): Int {
if (gSolved == 1) { return 0 }
if (idx < 0 || idx >= CELLS) { return 0 }
var b = blankAt()
var dr = idx.row4() - b.row4()
var dc = idx.col4() - b.col4()
var ok = 0
if (dr == 0 && dc == 1) { ok = 1 }
if (dr == 0 && dc == -1) { ok = 1 }
if (dr == 1 && dc == 0) { ok = 1 }
if (dr == -1 && dc == 0) { ok = 1 }
if (ok == 0) { ui_audio_beep(260, 50); return 0 }
gTiles[b] = gTiles[idx]
gTiles[idx] = BLANK
afterSlide()
return 1
}
/// 滑完一格之后的收尾:计数 + 判复原 + 音效。 / ⚠ 键盘那条路(`slideDir`)**不许再写一遍**:本文件第一版就是两处各写一份, / 结果键盘那边**漏了 `gMoves++`**(实测:方向键滑了一格,步数还是 0)。
/// The tail after a slide: count + solved check + sound.
/// ⚠ The keyboard path (`slideDir`) must **not** duplicate it: the first version of this file did,
/// and the keyboard side silently missed `gMoves++` (measured: an arrow slid a tile while the counter stayed 0).
fun afterSlide() {
gMoves = gMoves + 1
if (isSolved() == 1) {
gSolved = 1
gWonMs = ui_time_get_ms()
ui_audio_beep(1047, 320)
} else {
ui_audio_beep(700, 50)
}
}
fun isSolved(): Int {
var i = 0
while (i < CELLS - 1) {
if (gTiles[i] != i + 1) { return 0 }
i = i + 1
}
if (gTiles[CELLS - 1] != BLANK) { return 0 }
return 1
}
/// **独立**算一遍可解性(不复用打乱那段):逆序数 + 空格所在行(从下往上数)同奇偶即有解。 / 这是"打乱保证有解"的**判据**,不是把打乱的代码抄一遍。
/// Recompute solvability **independently**: inversions plus the blank's row counted from the bottom
/// must have the same parity. This is the *criterion* for "the shuffle guarantees solvability",
/// not a copy of the shuffle code.
fun solvable(): Int {
var inv = 0
var i = 0
while (i < CELLS) {
if (gTiles[i] != BLANK) {
var j = i + 1
while (j < CELLS) {
if (gTiles[j] != BLANK && gTiles[j] < gTiles[i]) { inv = inv + 1 }
j = j + 1
}
}
i = i + 1
}
var rowFromBottom = SIDE - blankAt().row4()
// 判据是"**异号**"而不是"同号":已复原的局面(逆序数 0 = 偶,空格在最后一行 = 从下往上数第 1 行 = 奇)**必须算有解** —— 写成"同号"会把**已复原**判成无解 (本文件第一版就是这么写的,实测 `solvable=0`)。 两个数各自的奇偶性必须**相反**。
// The criterion is "**different** parity", not "same": the solved position (0 inversions = even,
// blank on the last row = 1st from the bottom = odd) **must** come out solvable. Written as "same",
// it calls the **solved** position unsolvable — the first version of this file did exactly that
// (measured `solvable=0`). The two parities must be **opposite**.
if (inv % 2 != rowFromBottom % 2) { return 1 }
return 0
}
/// 瓦片配色 —— **`when` 作表达式**(修之前它把数据标签的地址当结果返回)
/// Tile color — a **`when` as an expression** (it used to return a data-label address)
fun tileColor(v: Int, idx: Int): Int {
return when {
// 已经在自己该在的位置上 → 绿
// Already sitting in its own place → green
v == idx + 1 -> 0xFF51E86E
v <= 5 -> 0xFF4E9AE0
v <= 10 -> 0xFF3E7FC0
else -> 0xFF8A5AD0
}
}
fun relayout() {
SW = ui_scr_get_w()
SH = ui_scr_get_h()
if (SW <= 0) { SW = 411 }
if (SH <= 0) { SH = 726 }
CELL = (SW - 32) / SIDE
BX = (SW - CELL * SIDE) / 2
BY = 92
}
fun elapsedSec(): Int {
var now = ui_time_get_ms()
if (gSolved == 1) { now = gWonMs }
var ms = now - gStartMs
if (ms < 0) { ms = 0 }
return ms / 1000
}
fun draw() {
ui_2d_clear(0xFF101018)
if (LANG == 0) { ui_2d_text_styled(SW / 2, 18, "数字华容道", 0xFFE8E8F0, 20, VML_ANCHOR_CENTER, VML_FONT_BOLD) }
else { ui_2d_text_styled(SW / 2, 18, "15 Puzzle", 0xFFE8E8F0, 20, VML_ANCHOR_CENTER, VML_FONT_BOLD) }
// HUD:**字符串模板**(修之前这两句会静默打出一串数字,而不是画在屏幕上)
// HUD: **string templates** (before the fix these printed digits instead of drawing)
// ✅ 2026-10-04 复验:字符串模板已可用(`"n=$n"` ⇒ `n=7`),这一段保持原样。
// ✅ Re-verified 2026-10-04: string templates work (`"n=$n"` => `n=7`); this section is unchanged.
if (LANG == 0) { ui_2d_text(SW / 2 - 62, 58, "步数 $gMoves", 0xFFE8E8F0, 15, VML_ANCHOR_CENTER) }
else { ui_2d_text(SW / 2 - 62, 58, "Moves $gMoves", 0xFFE8E8F0, 15, VML_ANCHOR_CENTER) }
if (LANG == 0) { ui_2d_text(SW / 2 + 62, 58, "用时 ${elapsedSec()} 秒", 0xFFE8E8F0, 15, VML_ANCHOR_CENTER) }
else { ui_2d_text(SW / 2 + 62, 58, "Time ${elapsedSec()} s", 0xFFE8E8F0, 15, VML_ANCHOR_CENTER) }
// 棋盘底
// board panel
ui_2d_rect(BX - 8, BY - 8, CELL * SIDE + 16, CELL * SIDE + 16, 0xFF232A38, 1, 0, 10)
// 16 格:空格画成暗底(**看得到洞在哪**,不然玩家要自己找)
// 16 cells; the blank is drawn dark so the hole is visible (otherwise the player must hunt for it)
for (i in 0..15) {
var px = BX + i.col4() * CELL
var py = BY + i.row4() * CELL
var v = gTiles[i]
if (v == BLANK) {
ui_2d_rect(px + 5, py + 5, CELL - 10, CELL - 10, 0xFF161B24, 1, 0, 8)
} else {
ui_2d_rect(px + 5, py + 5, CELL - 10, CELL - 10, tileColor(v, i), 1, 0, 8)
ui_2d_text_v(px + CELL / 2, py + CELL / 2, "$v", 0xFFE8E8F0, 26, VML_ANCHOR_CENTER, VML_VANCHOR_MIDDLE, VML_FONT_BOLD)
}
}
// 复原了就把整块盖一层绿框 + 一句话
// on solving, frame the board and say so
if (gSolved == 1) {
ui_2d_rect(BX - 8, BY - 8, CELL * SIDE + 16, CELL * SIDE + 16, 0xFF51E86E, 0, 3, 10)
if (LANG == 0) { ui_2d_text(SW / 2, BY + CELL * SIDE + 52, "复原了!用了 $gMoves 步", 0xFF51E86E, 18, VML_ANCHOR_CENTER) }
else { ui_2d_text(SW / 2, BY + CELL * SIDE + 52, "Solved! $gMoves moves", 0xFF51E86E, 18, VML_ANCHOR_CENTER) }
} else {
if (LANG == 0) { ui_2d_text(SW / 2, BY + CELL * SIDE + 52, "点空格旁边那一格让它滑过来", 0xFF9AA0B0, 13, VML_ANCHOR_CENTER) }
else { ui_2d_text(SW / 2, BY + CELL * SIDE + 52, "Tap a tile next to the hole to slide it", 0xFF9AA0B0, 13, VML_ANCHOR_CENTER) }
}
// 重开按钮
// restart button
var bw = 170
var bx = SW / 2 - bw / 2
var byy = BY + CELL * SIDE + 84
ui_2d_rect(bx, byy, bw, 44, 0xFF232A38, 1, 0, 8)
if (LANG == 0) { ui_2d_text(SW / 2, byy + 22, "重新打乱", 0xFFE8E8F0, 16, VML_ANCHOR_CENTER) }
else { ui_2d_text(SW / 2, byy + 22, "Shuffle", 0xFFE8E8F0, 16, VML_ANCHOR_CENTER) }
if (LANG == 0) { ui_2d_text(SW / 2, byy + 70, "方向键让那一侧的格子滑过来 · R 重开 · Esc 退出", 0xFF9AA0B0, 12, VML_ANCHOR_CENTER) }
else { ui_2d_text(SW / 2, byy + 70, "Arrow = slide that side in · R shuffle · Esc quit", 0xFF9AA0B0, 12, VML_ANCHOR_CENTER) }
// 图元数自检(`ui_2d_present` 之前的场景图元数)
// Primitive self-check: the scene primitive count before `ui_2d_present`
var n = ui_res_get_count(0)
if (n > gMaxPrims) { gMaxPrims = n }
ui_2d_present()
}
fun dumpBoard() {
for (i in 0..15) {
printf("%d,", gTiles[i])
if (i.col4() == SIDE - 1) { printf("\n") }
}
}
fun main() {
var w = 0
var h = 0
var tid = 0
LANG = ui_scr_get_language()
w = ui_scr_get_w()
h = ui_scr_get_h()
if (w <= 0) { w = 411 }
if (h <= 0) { h = 726 }
// 竖屏 + 不要手柄区
// portrait + no gamepad area
// ⚠ 上面那两句是**开窗前的估算值**,只作兜底;开窗后 `relayout()` 会重新问一次拿到**实测画布**。
// ⚠ The two reads above are the **pre-open estimate**, a fallback only; `relayout()` re-reads the **measured canvas** after the open.
// 开窗:尺寸传 0 ⇒ 交给宿主给真实可用区(开窗**之前**问到的只是估算值)。
// Opening: pass size 0 ⇒ the host supplies the real available area (a **pre-open** read is only an estimate).
if (LANG == 0) { ui_win_open_ex("数字华容道", 0, 0, VML_WIN_PORTRAIT, VML_WIN_NO_GAMEPAD) } else { ui_win_open_ex("15 Puzzle", 0, 0, VML_WIN_PORTRAIT, VML_WIN_NO_GAMEPAD) }
ui_scr_lock_orientation(VML_LOCK_PORTRAIT) // VML_LOCK_PORTRAIT
ui_scr_set_keep_on(1)
// `--arg demo`:固定种子(局面可复现)+ 跑 8 拍后自己退出,供脚本化验收
// `--arg demo`: fixed seed (a reproducible position) + exits after 8 ticks, for scripted acceptance
if (ui_arg_get_count() > 1) {
gSeed = 12345
gFrameLimit = 8
} else {
gSeed = ui_time_get_ms()
if (gSeed < 1000) { gSeed = gSeed + 1000 }
}
relayout()
if (gFrameLimit > 0) { printf("puzzle15: seed0=%d\n", gSeed) }
shuffle(SHUFFLE_MOVES)
if (gFrameLimit > 0) {
printf("puzzle15: solvable=%d blank=%d\n", solvable(), blankAt())
dumpBoard()
}
draw()
tid = ui_timer_set(250, 1)
while (ui_win_is_closed() == 0) {
var t = ui_msg_fetch_wait(120)
if (t == 10) { break }
if (t == 9) { // MSG_TIMER
gFrame = gFrame + 1
draw()
if (gFrameLimit > 0 && gFrame >= gFrameLimit) { break }
continue
}
if (t == 1) { // MSG_KEYDOWN
var k = ui_msg_get_a()
if (k == 27) { break }
// 方向键 = "让那一侧的格子滑过来":按 ← 就是把空格**右边**那格滑进来
// Arrows mean "slide that side's tile in": ← slides the tile to the right of the blank
var mv = 0
if (k == 37) { mv = slideDir(3) }
if (k == 39) { mv = slideDir(2) }
if (k == 38) { mv = slideDir(1) }
if (k == 40) { mv = slideDir(0) }
if (k == 37 || k == 39 || k == 38 || k == 40) {
if (mv == 1) { afterSlide() } else { ui_audio_beep(260, 50) }
}
if (k == 82) { shuffle(SHUFFLE_MOVES); ui_audio_beep(500, 90) } // 'R'
draw()
continue
}
if (t == 6 || t == 4) { // MSG_TOUCHDOWN / MSG_MOUSEDOWN
var x = ui_msg_get_a()
var y = ui_msg_get_b()
var bw2 = 170
var byy2 = BY + CELL * SIDE + 84
if (y >= byy2 && y <= byy2 + 44 && x >= SW / 2 - bw2 / 2 && x <= SW / 2 + bw2 / 2) {
shuffle(SHUFFLE_MOVES)
ui_audio_beep(500, 90)
} else {
var c = (x - BX) / CELL
var r = (y - BY) / CELL
// `in a..b && in a..b` —— 修之前那个 `&&` 会被当成**范围的上界**(判定恒假)
// the `&&` used to be swallowed into the range bound (making the test always false)
if (r in 0..(SIDE - 1) && c in 0..(SIDE - 1)) { tryMove(r * SIDE + c) }
}
draw()
continue
}
if (t == 11) { relayout(); draw(); continue }
if (t == 12) { continue }
}
ui_timer_kill(tid)
ui_scr_set_keep_on(0)
ui_win_close()
// 无头验收的确定性判据
// deterministic checkpoints for headless acceptance
dumpBoard()
printf("puzzle15: moves=%d solved=%d blank=%d sec=%d\n", gMoves, gSolved, blankAt(), elapsedSec())
printf("puzzle15: solvable=%d maxprims=%d\n", solvable(), gMaxPrims)
printf("puzzle15: done\n")
}