JavaFX+Kotlin游戏从入门到放弃:拯救蛇蛇大作战又名454行实现几何数独游戏
数独游戏
某一天,看到微博@屠龙的胭脂介绍的几何数独游戏视频介绍,一看挺不错,很好玩!
要不要买一个给我儿子玩呢?回头想了一下,觉得以我儿子的智慧,可能不会玩。不由得感叹,像我这样才华横溢的程序员,怎么儿子是个大笨蛋呢?还不如我来编一个放在平板上,看看我儿子到底会不会玩。
因为我儿子是蛇蛇爱好者(前几年是奥特曼爱好者,6岁就开始不喜欢咸蛋超人),就来一个拯救蛇蛇大作战:蛇蛇数独游戏源代码@gitcode.net。
我不费吹灰之力,实现如下图的精美游戏SnakeSudoku,三条蛇蛇图片网上下载的,左上角的Logo我亲自书写的!结果我儿子一局都没玩通,我自己倒是玩了几十局……就刚才还在玩。
解压运行版下载地址
玩法也很简单:双击左上角白色区域,重开一局。按照第一行,第一列的要求,每种蛇蛇3条,放置于3x3区域。
如何做一个游戏或者如何用Java FX+Kotlin做一个游戏
做一个游戏,我自己感觉可以分为几个步骤:
- 游戏的核心概念;
- 游戏的玩家(单人/多人/分组);
- 游戏形式和胜负判定。
数独,是一个填数字的游戏。
数独,起源于18世纪初,瑞士数学家Leonhard Euler的拉丁方阵。到了1880年,建筑师Howard Garns 又在拉丁方阵的基础上创造了一种有意思的填数游戏,这就是数独的原型。
到了1970年,美国一本叫做《Math Puzzles and Logic Problems》的益智书上也出现了数独,但是,在那时,数独是被叫做“填数字”。
到了1984年,一位日本学者将数独带到了日本,并将其刊登在一本游戏图书上,起初叫做“数字は独身に限る”(单身数字),后又改名为“数独”(すうどく),其中“数”(すう)指的是“数字”,“独”(どく)指的是“唯一”。
这里我们设计是一个对玩家没有限定的填图片游戏,共有9张蛇蛇照片(每种3张),填入3x3方格,要求
- 每行的约束条件;
- 每列的约束条件;
这里行和列的约束条件都是某种蛇多少条(0-2)。
我一拍脑袋,就是显示一个4x4的方格,第一行和第一列的后三个方块显示约束,右下角3x3为填空区域,反正只有3钟蛇,就设计为点击更换。
剩下的就是本文所主要想交流的工作:如何利用Kotlin+Java FX把这个功能实现出来。
非常简单,整个454行程序分为四个文件:
- SnakeSudoku:实现数独游戏的逻辑,约束,如何判定解和求解算法;
- SnakeSudokuApp:游戏的入口,
Application
对象(JavaFX) - SnakeSudokuView:游戏界面的实现
- SnakeSudokuViewModel:沟通逻辑和界面的部分
入口程序
不管三七二十一,先从主程序开始。JavaFX应用程序,都有一个入口,也就是Application
的子类,这里的程序与Kotlin编写JavaFX的顺滑 中的例子并没有什么区别。这里不同的是,Stage
的初始样式,设定为UNDECORATED
,也就是没有默认的标题栏,因为我们是游戏,没有标题栏或者自己搞个有个性的伪标题栏显得Bigger更高……
这里有个函数askToExit
,是最VBox中上面的“X”按钮的响应,显示一个对话框确认是否退出。这个函数中的链式调用,包括.filter
,.isPresent
是Java
更新版本和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
到图片。
然后是一个从Image
到ImageView
的映射,这里写成一个扩展函数的形式,不得不说,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行程序的确非常简单,虽然我儿子并不在意,在我平板上点了几把搞不定就直接去疯跑去了……可怜的老父亲只好自己多玩了几把……