JavaFX+Kotlin游戏从入门到放弃:拯救蛇蛇大作战又名454行实现几何数独游戏

数独游戏

某一天,看到微博@屠龙的胭脂介绍的几何数独游戏视频介绍,一看挺不错,很好玩!

要不要买一个给我儿子玩呢?回头想了一下,觉得以我儿子的智慧,可能不会玩。不由得感叹,像我这样才华横溢的程序员,怎么儿子是个大笨蛋呢?还不如我来编一个放在平板上,看看我儿子到底会不会玩。

因为我儿子是蛇蛇爱好者(前几年是奥特曼爱好者,6岁就开始不喜欢咸蛋超人),就来一个拯救蛇蛇大作战:蛇蛇数独游戏源代码@gitcode.net

我不费吹灰之力,实现如下图的精美游戏SnakeSudoku,三条蛇蛇图片网上下载的,左上角的Logo我亲自书写的!结果我儿子一局都没玩通,我自己倒是玩了几十局……就刚才还在玩。
蛇蛇大作战
解压运行版下载地址

玩法也很简单:双击左上角白色区域,重开一局。按照第一行,第一列的要求,每种蛇蛇3条,放置于3x3区域。

如何做一个游戏或者如何用Java FX+Kotlin做一个游戏

做一个游戏,我自己感觉可以分为几个步骤:

  1. 游戏的核心概念;
  2. 游戏的玩家(单人/多人/分组);
  3. 游戏形式和胜负判定。

数独,是一个填数字的游戏。

数独,起源于18世纪初,瑞士数学家Leonhard Euler的拉丁方阵。到了1880年,建筑师Howard Garns 又在拉丁方阵的基础上创造了一种有意思的填数游戏,这就是数独的原型。

到了1970年,美国一本叫做《Math Puzzles and Logic Problems》的益智书上也出现了数独,但是,在那时,数独是被叫做“填数字”。

到了1984年,一位日本学者将数独带到了日本,并将其刊登在一本游戏图书上,起初叫做“数字は独身に限る”(单身数字),后又改名为“数独”(すうどく),其中“数”(すう)指的是“数字”,“独”(どく)指的是“唯一”。

这里我们设计是一个对玩家没有限定的填图片游戏,共有9张蛇蛇照片(每种3张),填入3x3方格,要求

  1. 每行的约束条件;
  2. 每列的约束条件;

这里行和列的约束条件都是某种蛇多少条(0-2)。

我一拍脑袋,就是显示一个4x4的方格,第一行和第一列的后三个方块显示约束,右下角3x3为填空区域,反正只有3钟蛇,就设计为点击更换。

剩下的就是本文所主要想交流的工作:如何利用Kotlin+Java FX把这个功能实现出来。
454行程序
非常简单,整个454行程序分为四个文件:

  • SnakeSudoku:实现数独游戏的逻辑,约束,如何判定解和求解算法;
  • SnakeSudokuApp:游戏的入口,Application对象(JavaFX)
  • SnakeSudokuView:游戏界面的实现
  • SnakeSudokuViewModel:沟通逻辑和界面的部分

入口程序

不管三七二十一,先从主程序开始。JavaFX应用程序,都有一个入口,也就是Application的子类,这里的程序与Kotlin编写JavaFX的顺滑 中的例子并没有什么区别。这里不同的是,Stage的初始样式,设定为UNDECORATED,也就是没有默认的标题栏,因为我们是游戏,没有标题栏或者自己搞个有个性的伪标题栏显得Bigger更高……

这里有个函数askToExit,是最VBox中上面的“X”按钮的响应,显示一个对话框确认是否退出。这个函数中的链式调用,包括.filter.isPresentJava更新版本和Kotlin的函数式编程的全新流派……

package org.cardc.snakesudoku
/**
 * 退出确认
 */
fun askToExit() {
    Alert(Alert.AlertType.CONFIRMATION, "您确认需要退出游戏吗?")
        .showAndWait()
        .filter { response -> response === ButtonType.OK }
        .ifPresent { Platform.exit() }
}


class SnakeSudokuApp : Application() {
    private val bc = "HONEYDEW"
    override fun start(stage: Stage) {
        val scene = Scene(VBox().apply {
            style = "-fx-background-color: $bc"
            Color.BLUE
            alignment = Pos.CENTER_RIGHT
            padding = Insets(0.0)
            children.add(Button("×").apply {
                style = "-fx-background-color:$bc;-fx-font-size:32;"
                alignment = Pos.BOTTOM_CENTER
                setOnAction { askToExit() }
                VBox.setVgrow(this, Priority.ALWAYS)
                padding = Insets(0.0)
            })
            children.add(root().apply {
                padding = Insets(0.0)
            })
        }, 4 * size + 5.0, 4 * size + 15.0).apply {
            setOnKeyPressed {
                if (it.code == KeyCode.ESCAPE) {
                    askToExit()
                }
            }
        }
        stage.initStyle(StageStyle.UNDECORATED)
        stage.fullScreenExitKeyCombination = KeyCombination.keyCombination("Ctrl+F11")
        stage.isFullScreen = false
        stage.icons.add(icon)
        stage.scene = scene
        stage.centerOnScreen()
        stage.show()
    }
}

fun main() {
    Application.launch(SnakeSudokuApp::class.java)
}

游戏的主要界面,除了最上方的关闭按钮,都由一个叫root的函数提供。自然而言,接下来我们就是要看root函数。

游戏界面

游戏界面首先是方格的尺寸,蛇蛇图片,这里导入一个Image,保持长宽比,实际上在编辑图片是已经是正方形。还有一个图片就是Logo图片,同样的。

然后再存一个映射,从Snake到图片。

然后是一个从ImageImageView的映射,这里写成一个扩展函数的形式,不得不说,Kotlin扩展函数真香。

root函数的第一行就能看到,是一个GridPane。在界面窗格的文章中有介绍和演示。

package org.cardc.snakesudoku

const val size = 200.0

val brownSnake = Image(SnakeSudokuApp::class.java.getResourceAsStream("snake-brown.png"), size, size, true, true)
val redSnake = Image(SnakeSudokuApp::class.java.getResourceAsStream("snake-red.png"), size, size, true, true)
val greenSnake = Image(SnakeSudokuApp::class.java.getResourceAsStream("snake-green.png"), size, size, true, true)

val icon = Image(SnakeSudokuApp::class.java.getResourceAsStream("ss.png"), size, size, true, true)

val snakes = mapOf(
    Snake.RED to redSnake,
    Snake.GREEN to greenSnake,
    Snake.BROWN to brownSnake
)

fun Image.view(d: Double = 100.0): ImageView {

    return ImageView(this).apply {
        maxWidth(d)
        maxHeight(d)
        isPreserveRatio = true
    }
}

const val textStyle = "-fx-font-size: 64; -fx-family:fantasy; -fx-font-weight:bold; -fx-fill: orchid;"
fun root(): Parent = GridPane().apply {
    add(StackPane().apply {
        setOnMouseClicked { it ->
            if (it.clickCount == 2) { //
                viewModel.newPuzzle()
            }
            if (it.clickCount == 1 && it.isAltDown) {  // 上上下左左右右AB,通关秘籍
                viewModel.solve()
            }
            if (it.clickCount == 1 && it.isControlDown) { //调试的时候才使用的秘籍
                viewModel.solve(false) {
                    if (it==null){
                        println("May have no solution.")
                    }else{
                        println(it)
                    }
                }
            }
        }
        children.add(icon.view())
        children.add(Text("Double click to start a new game!\nEsc to Exit!").apply {
            textAlignment = TextAlignment.CENTER
            style = "-fx-family:fantasy; -fx-font-weight:bold; -fx-fill: darkblue;"
            StackPane.setAlignment(this, Pos.BOTTOM_CENTER)
        })
    }, 0, 0)

    // column 0, row constraints
    viewModel.rc.value.forEachIndexed { index, pair ->
        add(
            StackPane().apply {
                children.add(snakes[pair.first]!!.view().apply {
                    imageProperty().bind(Bindings.createObjectBinding({
                        snakes[viewModel.rowSnake(index)]
                    }, viewModel.rc))
                })
                children.add(Text("${pair.second}").apply {
                    StackPane.setAlignment(this, Pos.CENTER_RIGHT)
                    textProperty().bind(Bindings.createStringBinding({
                        "${viewModel.rowCount(index)}"
                    }, viewModel.rc))
                    style = textStyle
                })
            }, 0, index + 1
        )
    }

    // row 0, column constraints
    viewModel.cc.value.forEachIndexed { index, pair ->
        add(
            StackPane().apply {
                children.add(snakes[pair.first]!!.view().apply {
                    imageProperty().bind(Bindings.createObjectBinding({
                        snakes[viewModel.colSnake(index)]
                    }, viewModel.cc))
                })
                children.add(Text("${pair.second}").apply {
                    StackPane.setAlignment(this, Pos.BOTTOM_CENTER)
                    textProperty().bind(Bindings.createStringBinding({
                        "${viewModel.colCount(index)}"
                    }, viewModel.cc))
                    style = textStyle
                })
            }, index + 1, 0
        )
    }

    (0..2).forEach { i ->
        (0..2).forEach { j ->
            add(snakes[viewModel[i, j]]!!.view().apply {
                // 绑定每个ImageView的图像
                imageProperty().bind(
                    Bindings.createObjectBinding( // 创建一个对象绑定到VM的解
                        {
                            snakes[viewModel[i, j]]
                        },
                        viewModel.sol
                    )
                )
                setOnMouseClicked {
                    viewModel.changeAt(i, j) //
                    if (viewModel.isSolved) {
                        // show congratulation message.
                        Alert(Alert.AlertType.INFORMATION,"您救了小蛇蛇们,真棒!").showAndWait()
                    }
                }
            }, j + 1, i + 1)
        }
    }
}

这里面还有一个比较有意思的就是蛇蛇填空约束的行和列中,是一个图片加一个数字,那么就用StackPane,同样在界面窗格的文章中有介绍和演示。

这里的图片和数字更新,采用的就是JavaFX的属性绑定高级应用,下次有时间专门来写一篇Bindings的高级用法。

以蛇蛇图片为例,ImageView实例有一个属性,通过imageProperty()来得到,这里把它绑定到用Bindings.createObjectBinding返回的一个对象绑定上,这个函数的第一个参数是返回一个Image的匿名函数,第二个参数是一个Observable对象。简答一句话,当这个Observable对象发生变化时,这里的图像就会自动更新为这个匿名函数的返回值。

imageProperty().bind(
                    Bindings.createObjectBinding( // 创建一个对象绑定到VM的解
                        {
                            snakes[viewModel[i, j]]
                        },
                        viewModel.sol
                    )

这里点击的事件,通过setOnAction设定为一个匿名函数。

要不怎么说函数式编程呢。

这个界面部分,共有若干种操作请求:

  • 新建游戏
  • 【秘籍】自动求解
  • 更换3x3数独中的蛇蛇
  • 判断是否完成数独的求解

那么这几个逻辑,就体现在ViewModel中。这个设计架构,在Kotlin编写JavaFX的顺滑一文中有详细描述。

连接界面与逻辑的视图模型

这里的视图模型,也就是viewModel,在Kotlin中是一个特殊设计,就是单例模式,object

这里唯一值得注意的就是,一共建了三个SimpleObjectProperty对象,貌似一个就行,但是我懒得弄了。

package org.cardc.snakesudoku

object viewModel {
    val rc = SimpleObjectProperty(SnakeSudoku().rowCount)
    val cc = SimpleObjectProperty(SnakeSudoku().columnCount)
    val sol = SimpleObjectProperty(SnakeSudoku())

    private fun update(s: SnakeSudoku) {
        rc.value = s.rowCount
        cc.value = s.rowCount
        sol.value = s
    }

    /**
     * UI触发点击3x3中间的一个位置
     * @param i Int
     * @param j Int
     */
    fun changeAt(i: Int, j: Int) {
        sol.value = sol.value.cycleAt(i, j)
    }

    /**
     * UI 触发解开Sudoku
     * @param isUpdate Boolean      :是否更新界面,默认是更新,否则仅在终端打印
     * @param func Function1<SnakeSudoku?, Unit> :求解后的回调函数,该函数会在Platform.runLater中调用
     */
    fun solve(isUpdate: Boolean = true, func: (SnakeSudoku?) -> Unit = {}) {
        sol.value.randomSolve {
            it?.let {
                Platform.runLater { func(it) }
            }
            if (isUpdate)
                it?.let {
                    Platform.runLater { update(it) }
                }
        }
    }

    /**
     * 重开一局
     */
    fun newPuzzle() {
        update(newSudoku())
    }

    fun rowSnake(index: Int) = rc.value[index].first
    fun rowCount(index: Int) = rc.value[index].second

    fun colSnake(index: Int) = cc.value[index].first
    fun colCount(index: Int) = cc.value[index].second

    operator fun get(i: Int, j: Int) = sol.value[i, j]

    val isSolved: Boolean get() = sol.value.isSolved

}

对应着用户界面的四个请求,分别有函数与之对应,此外,这个部分还提供了用户界面中对游戏逻辑部分数据的访问。

最后,当然是这里所调用的游戏逻辑。游戏逻辑的部分呢,就什么都不涉及,只是游戏本身。

游戏逻辑

这个游戏非常简单,因此游戏逻辑的类也很简单,甚至我用的data class,就是传值调用的类。

package org.cardc.snakesudoku

/**
 * Enum for three tiles
 */
enum class Snake {
    RED, GREEN, BROWN;

    fun next() = when (this) {
        RED -> GREEN
        GREEN -> BROWN
        BROWN -> RED
    }
}

/**
 * Sudoku Puzzle data class
 * @property rowCount Array<Pair<Snake, Int>>       : constraints on three rows
 * @property columnCount Array<Pair<Snake, Int>>    : constraints on three columns
 * @property solution Array<Array<Snake>>           : 3x3 grid of solution
 * @constructor: all parameters are optional.
 */
data class SnakeSudoku(
    var rowCount: Array<Pair<Snake, Int>> = arrayOf(
        Snake.RED to 1,
        Snake.GREEN to 1,
        Snake.BROWN to 0,
    ), var columnCount: Array<Pair<Snake, Int>> = arrayOf(
        Snake.RED to 0,
        Snake.GREEN to 1,
        Snake.BROWN to 0,
    ),
    var solution: Array<Array<Snake>> = arrayOf(
        arrayOf(
            Snake.RED, Snake.GREEN, Snake.GREEN
        ), arrayOf(
            Snake.RED, Snake.GREEN, Snake.BROWN
        ), arrayOf(
            Snake.RED, Snake.BROWN, Snake.BROWN
        )
    )

) {

    /**
     * String representation of the solution and constraints
     * @return String
     */
    override fun toString(): String {

        val ss = "%8s %5s(%d) %5s(%d) %5s(%d)\n%5s(%d) %8s %8s %8s\n%5s(%d) %8s %8s %8s\n%5s(%d) %8s %8s %8s"
        return ss.format(
            "",
            columnCount[0].first,
            columnCount[0].second,
            columnCount[1].first,
            columnCount[1].second,
            columnCount[2].first,
            columnCount[2].second,
            rowCount[0].first,
            rowCount[0].second,
            get(0, 0),
            get(0, 1),
            get(0, 2),
            rowCount[1].first,
            rowCount[1].second,
            get(1, 0),
            get(1, 1),
            get(1, 2),
            rowCount[2].first,
            rowCount[2].second,
            get(2, 0),
            get(2, 1),
            get(2, 2),
        )
    }

    private fun set(ss: Array<Snake>) {
        (0..2).forEach { i ->
            (0..2).forEach { j ->
                solution[i][j] = ss[i * 3 + j]
            }
        }
    }

    fun randomSolve(notify: (SnakeSudoku?) -> Unit) {
        val ss = SnakeSudoku(rowCount, columnCount, solution)
        val sv = arrayOf(
            Snake.RED, Snake.RED, Snake.RED,
            Snake.GREEN, Snake.GREEN, Snake.GREEN,
            Snake.BROWN, Snake.BROWN, Snake.BROWN
        )
        var i = 0.0
        Thread {
            while (!ss.isSolved) {
                sv.shuffle(r)
                ss.set(sv)
                i += 1.0
                if (i > 100 * 3.0.pow(9.0)) {
                    notify(null)
                    return@Thread
                }
            }
            notify(ss)
        }.start()
    }

    companion object {
        private val r = Random(System.nanoTime())
        fun newSudoku() = SnakeSudoku(
            Snake.values().apply { shuffle(r) }.map {
                it to r.nextInt(0, 2)
            }.toTypedArray(),
            Snake.values().apply { shuffle(r) }.map {
                it to r.nextInt(0, 2)
            }.toTypedArray()
        )
    }

    private fun sumSnake(snake: Snake) =
        row(0).count { it == snake } + row(1).count { it == snake } + row(2).count { it == snake }

    private fun checkSolution(): Boolean {
        return arrayOf(
            *(0..2).map { checkRow(it) }.toTypedArray(),
            *(0..2).map { checkCol(it) }.toTypedArray(),
            *(arrayOf(Snake.RED, Snake.BROWN, Snake.GREEN).map {
                sumSnake(it) == 3
            }.toTypedArray())
        ).all { it }
    }

    private fun checkRow(i: Int) = row(i).count {
        it == columnCount[i].first
    }.apply {
    } == rowCount[i].second

    private fun checkCol(i: Int) = col(i).count { it == columnCount[i].first }.apply {
    } == columnCount[i].second

    fun cycleAt(index: Int, index2: Int) = SnakeSudoku(rowCount, columnCount, solution).apply {
        set(index, index2, get(index, index2).next())
    }


    private fun col(i: Int): Array<Snake> {
        return arrayOf(
            solution[0][i], solution[1][i], solution[2][i]
        )
    }

    private fun row(i: Int) = solution[i]

    operator fun get(i: Int, j: Int): Snake {
        return solution[i.coerceIn(0, 2)][j.coerceIn(0, 2)]
    }

    private operator fun set(i: Int, j: Int, s: Snake) {
        solution[i.coerceIn(0, 2)][j.coerceIn(0, 2)] = s
    }


    private fun toColumVector() = arrayOf(
        *col(0),
        *col(1),
        *col(2)
    )

    private fun toRowVector() = arrayOf(
        *row(0), *row(1), *row(2)
    )

    val isSolved get() = checkSolution()
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as SnakeSudoku

        if (!rowCount.contentEquals(other.rowCount)) return false
        if (!columnCount.contentEquals(other.columnCount)) return false
        if (!solution.contentDeepEquals(other.solution)) return false

        return true
    }

    override fun hashCode(): Int {
        var result = rowCount.contentHashCode()
        result = 31 * result + columnCount.contentHashCode()
        result = 31 * result + solution.contentDeepHashCode()
        return result
    }
}

结语

454行程序的确非常简单,虽然我儿子并不在意,在我平板上点了几把搞不定就直接去疯跑去了……可怜的老父亲只好自己多玩了几把……

posted @ 2022-07-12 16:22  大福是小强  阅读(12)  评论(0编辑  收藏  举报  来源