← 都来码 / Dolaima← 例程索引kotlin/app/puzzle15.kt原文

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