<-- Home |--jetpack

Jetpack Compose for Desktop-004 快速开发井字棋

快速入水

要学习一样跟程序设计有关的东西,最好的办法始终是把手打湿,整一个能够运行,可以实验的东西出来。

也只有在程序开发中,我们才能想一个魔法师而不是魔术师,我们真的能够创造一个东西。而且编译器不会因为我们很丑、我们学历不行、我们没女朋友、我们很穷而拒绝我们,只要我们严格按照手册,就真的可以梦想成真。真是幸运啊!

所以,我们先来写一个简单的程序,一个大家都完全不会感兴趣的游戏:井字棋。

这个游戏实在是无聊,我从来没有成功说服任何一个人跟我一起好好玩过……不过……无聊也是它的好处,马上就行。

构建工具链

现代化的程序设计语言通常都有完整的工具链,负责完成:

  • 管理工程-文件的组织
  • 编译源代码
  • 运行、运行、分发程序

热门但是无法流行的伟大Rust语言,它的工具链是cargo

不热门但是很流行的C/C++语言,它的工具链是make,现在也有cmake,或者ninja

热门流行的Java语言,它的工具链是antmavengradle

Kotlin是基于Java的,它的工具链我们一般选择gradle。实际上gradlemavenant都是Java的构建工具。

gradle构建Kotlin程序,最好玩的是,我们可以编写kotlin来完成。当然,以前还用groovy,现在大概可能或许提倡用kotlin

构成一个gradle工程的文件有:

  • settings.gradle.kts:工程的设置
  • build.gradle.kts:工程的构建
  • src:源代码目录
  • gradle.properties:gradle的属性文件
  • gradlewgradlew.bat:gradle的wrapper的启动脚本
  • gradle/wrapper目录:gradle的配置文件和wrapper
    • gradle-wrapper.jar
    • gradle-wrapper.properties

这是在IDEA中一个典型工程的结构:

IDEA工程结构

这里展示了全部的文件,包括隐藏文件:

  • .gradle:gradle的缓存目录
  • .idea:IDEA的配置目录
  • build:gradle的构建目录
  • .kotlin:kotlin的配置目录
  • .run:运行配置目录

有些时候,还有:

  • out:编译输出目录

当然,这些都不需要自己来创建,只需要在IDEA中创建一个新的gradle工程就行了。甚至,下面大部分配置都不需要自己来创建,只需要安装一个Jetpack Compose的插件,然后新建一个Compose工程就行了。

但是,我们这里还是絮絮叨叨一下,可以大概知道这些都是什么。

Gradle Wrapper

为了让工程更加有移植性,我们一般会使用gradle wrapper,这个工具会自动下载指定版本的gradle,并且把gradle的启动脚本放在工程的根目录下。

我们可以通过gradlew或者gradlew.bat来启动gradle,这样就不需要在系统中安装gradle了。

这相关的几个文件,我们可以通过gradle wrapper命令来生成。

1gradle wrapper

这个命令会生成gradlewgradlew.batgradle/wrapper/gradle-wrapper.jargradle/wrapper/gradle-wrapper.properties这几个文件。

在最后那个文件中,通常包含有下载的地址和版本号。

1distributionBase=GRADLE_USER_HOME
2distributionPath=wrapper/dists
3distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip
4zipStoreBase=GRADLE_USER_HOME
5zipStorePath=wrapper/dists

我们通(bi)常(xu)更改那个distributionUrl地址,指向我们自己的镜像源,这样下载会快一些。

gradle.properties

这个文件通常用来存放一些工程的属性,比如版本号、插件版本等等。

1org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2kotlin.code.style=official
3kotlin.version=2.0.0
4compose.version=1.6.10

第一行的org.gradle.jvmargsgradle的启动参数,这里设置了堆内存和文件编码。

在调用gradle的时候,我们可以通过-P参数来传递这些属性,比如:

1gradle build -Pkotlin.version=1.5.31

这里设定的属性,可以在build.gradle.kts文件中使用。

settings.gradle.kts

我们需要在settings.gradle.kts文件中添加一些内容,这个文件是一个Kotlin脚本,用来描述工程的设置。

 1pluginManagement {
 2    repositories {
 3        maven("https://maven.aliyun.com/repository/public")
 4        maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
 5        google()
 6        gradlePluginPortal()
 7        mavenCentral()
 8    }
 9
10    plugins {
11        kotlin("jvm").version(extra["kotlin.version"] as String)
12        id("org.jetbrains.compose").version(extra["compose.version"] as String)
13        id("org.jetbrains.kotlin.plugin.compose").version(extra["kotlin.version"] as String)
14    }
15}
16
17rootProject.name = "Demo004"

这个文件首先描述了插件的管理,然后指定了工程的名称。

当然,我们也在这里添加了一些镜像源,这样下载插件会快一些。这个技能必须要掌握……不掌握就会觉得体验极其糟糕……什么都打不开,什么都运行不了。

build.gradle.kts

我们需要在build.gradle.kts文件中添加一些内容,这个文件是一个Kotlin脚本,用来描述工程的构建过程。

 1import org.jetbrains.compose.desktop.application.dsl.TargetFormat
 2import org.jetbrains.kotlin.gradle.targets.js.npm.fromSrcPackageJson
 3
 4plugins {
 5    kotlin("jvm")
 6    id("org.jetbrains.compose")
 7    id("org.jetbrains.kotlin.plugin.compose")
 8}
 9
10group = "org.cardc.fdii"
11version = "1.0.0"
12
13repositories {
14    maven("https://maven.aliyun.com/repository/public")
15    mavenCentral()
16    maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
17    google()
18}
19
20dependencies {
21    // Note, if you develop a library, you should use compose.desktop.common.
22    // compose.desktop.currentOs should be used in launcher-sourceSet
23    // (in a separate module for demo project and in testMain).
24    // With compose.desktop.common you will also lose @Preview functionality
25    implementation(compose.desktop.currentOs)
26
27    // Gson dependency
28    implementation("com.google.code.gson:gson:2.11.0")
29}
30
31compose.desktop {
32    application {
33        mainClass = "MainKt"
34
35        nativeDistributions {
36            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
37            packageName = "Demo004"
38            packageVersion = "1.0.0"
39        }
40    }
41}

这里面,通常只更改了几处:

  • group:工程的组织
  • version:工程的版本,通常我会从1.0-SNAPSHOT改成1.0.0,前者在分发的时候会有问题
  • repositories:镜像源,这里添加了一些镜像源,同样!这个技能必须要掌握!
  • dependencies:依赖,这里compose.desktop.currentOs,这是Compose Desktop的依赖,如果是Compose工程,自动就有;后面的gson是一个JSON库,我们可能会用到。

src目录

这个目录是源代码目录,我们可以在这个目录下创建kotlinjavaresources等等目录,用来存放源代码和资源文件。

当然,以这个工程为例,我们只需要在src/main/kotlin目录下创建一个Main.kt文件,这个文件就是程序的入口。

  1import androidx.compose.desktop.ui.tooling.preview.Preview
  2import androidx.compose.foundation.background
  3import androidx.compose.foundation.layout.*
  4import androidx.compose.material.Button
  5import androidx.compose.material.ButtonDefaults
  6import androidx.compose.material.Text
  7import androidx.compose.runtime.*
  8import androidx.compose.ui.Alignment
  9import androidx.compose.ui.Modifier
 10import androidx.compose.ui.graphics.Color
 11import androidx.compose.ui.res.useResource
 12import androidx.compose.ui.text.style.TextAlign
 13import androidx.compose.ui.unit.DpSize
 14import androidx.compose.ui.unit.dp
 15import androidx.compose.ui.unit.sp
 16import androidx.compose.ui.window.Window
 17import androidx.compose.ui.window.WindowPosition
 18import androidx.compose.ui.window.application
 19import androidx.compose.ui.window.rememberWindowState
 20import java.io.File
 21
 22@Composable
 23fun TicTocTile(
 24    x: Int,
 25    y: Int,
 26    ticTacToe: TicTacToe
 27) {
 28
 29    Box(
 30        modifier = Modifier
 31            .size(100.dp)
 32            .background(Color.LightGray),
 33        contentAlignment = Alignment.Center
 34    ) {
 35
 36        Button(modifier = Modifier
 37            .padding(5.dp)
 38            .fillMaxSize(),
 39            colors = ButtonDefaults.buttonColors(backgroundColor = ticTacToe[x, y].color()),
 40            onClick = {
 41                if (ticTacToe.isGameOver()) {
 42                    ticTacToe.startNewGame()
 43                    return@Button
 44                }
 45
 46                ticTacToe.nextMove(x, y)
 47
 48            }) {
 49            Text(
 50                text = ticTacToe.textAt(x, y),
 51                fontSize = 36.sp,
 52                color = Color.Black
 53            )
 54        }
 55    }
 56}
 57
 58
 59@Composable
 60@Preview
 61fun TicToc() {
 62    val board = remember {
 63        mutableStateMapOf<Pair<Int, Int>, Player>(
 64            Pair(0, 0) to Player.NULL,
 65            Pair(0, 1) to Player.NULL,
 66            Pair(0, 2) to Player.NULL,
 67            Pair(1, 0) to Player.NULL,
 68            Pair(1, 1) to Player.NULL,
 69            Pair(1, 2) to Player.NULL,
 70            Pair(2, 0) to Player.NULL,
 71            Pair(2, 1) to Player.NULL,
 72            Pair(2, 2) to Player.NULL
 73        )
 74    }
 75    val player = remember { mutableStateOf(Player.X) }
 76
 77    val ticTacToe = TicTacToe(board, player)
 78
 79    Column {
 80        for (i in 0..2) {
 81            Row {
 82                for (j in 0..2) {
 83                    TicTocTile(i, j, ticTacToe)
 84                }
 85            }
 86        }
 87        Text(
 88            modifier = Modifier.padding(10.dp).align(Alignment.CenterHorizontally),
 89            text = ticTacToe.gameText(),
 90            fontSize = 30.sp,
 91            color = ticTacToe.color(),
 92            textAlign = TextAlign.Center
 93        )
 94
 95    }
 96
 97}
 98
 99fun main() = application {
100    useResource("config.json") {
101        config = loadConfig(it)
102    }
103
104    Window(
105        onCloseRequest = ::exitApplication,
106        title = "Tic Tac Toe",
107        state = rememberWindowState(
108            position = WindowPosition.Aligned(Alignment.Center),
109            size = DpSize(320.dp, 400.dp)
110        )
111    ) {
112        Box(
113            modifier = Modifier.background(Color.White).fillMaxSize(),
114            contentAlignment = Alignment.Center
115        ) {
116            TicToc()
117        }
118
119    }
120}

然后是TicTacToe的实现:

 1enum class Player {
 2    NULL, X, O;
 3
 4    fun nameString(): String {
 5        return when (this) {
 6            X -> "X"
 7            O -> "O"
 8            else -> ""
 9        }
10    }
11
12
13}
14
15import androidx.compose.runtime.MutableState
16import androidx.compose.runtime.snapshots.SnapshotStateMap
17
18
19data class TicTacToe(val board: SnapshotStateMap<Pair<Int, Int>, Player>, val player: MutableState<Player>) {
20    fun gameText(): String {
21        val winner = winner()
22        if (isGameOver()) {
23            if (winner == Player.NULL) {
24                return "Game Over Tie."
25            }
26            return "Player ${winner.nameString()} won!"
27        }
28        return "Player ${player.value.nameString()}'s turn"
29    }
30
31
32    fun winner(): Player {
33        for (i in 0..2) {
34            if (get(i, 0) != Player.NULL && get(i, 0) == get(i, 1) && get(i, 1) == get(i, 2)) {
35                return get(i, 0)
36            }
37            if (get(0, i) != Player.NULL && get(0, i) == get(1, i) && get(1, i) == get(2, i)) {
38                return get(0, i)
39            }
40        }
41        if (get(0, 0) != Player.NULL && get(0, 0) == get(1, 1) && get(1, 1) == get(2, 2)) {
42            return get(0, 0)
43        }
44        if (get(0, 2) != Player.NULL && get(0, 2) == get(1, 1) && get(1, 1) == get(2, 0)) {
45            return get(0, 2)
46        }
47        return Player.NULL
48    }
49
50
51    fun nextMove(x: Int, y: Int) {
52        if (isTaking(x, y)) return
53        board.put(Pair(x, y), player.value)
54        nextPlayer()
55    }
56
57    operator fun get(x: Int, y: Int): Player {
58        return board[Pair(x, y)] ?: Player.NULL
59    }
60
61    fun textAt(x: Int, y: Int): String {
62        return get(x, y).nameString()
63    }
64
65    fun startNewGame() {
66        for (key in board.keys) {
67            this.board.put(key, Player.NULL)
68        }
69        player.value = Player.X
70    }
71
72    fun isGameOver(): Boolean {
73        return winner() != Player.NULL || isFilled()
74    }
75
76    private fun isFilled(): Boolean {
77        board.values.find { it == Player.NULL }?.let {
78            return false
79        }
80        return true
81    }
82
83    private fun isTaking(x: Int, y: Int): Boolean {
84        return get(x, y) != Player.NULL
85    }
86
87    private fun nextPlayer() {
88        player.value = if (player.value == Player.X) Player.O else Player.X
89    }
90
91
92}

为了配合显示颜色,我们还需要一个扩展函数:

 1import Player.O
 2import Player.X
 3import androidx.compose.ui.graphics.Color
 4import com.google.gson.Gson
 5import java.io.File
 6import java.io.InputStream
 7
 8
 9data class Config(
10    val PlayerXColor: String="00FF00",
11    val PlayerOColor: String="#0000FF",
12    val GameOverColor: String="##FF0000"
13)
14
15fun loadConfig(file: InputStream): Config {
16
17    try {
18        val json = file.readAllBytes().decodeToString()
19        return Gson().fromJson(json, Config::class.java)
20    } catch (e: Exception) {
21        e.printStackTrace()
22        return Config("#FF0000", "#0000FF", "#00FF00")
23    }
24}
25
26val String.color
27    get() = Color(removePrefix("#").toInt(16)).copy(alpha = 1f)
28
29lateinit var config: Config
30
31fun Player.color(): Color {
32    return when (this) {
33        X -> config.PlayerXColor.color
34        O -> config.PlayerOColor.color
35        else -> Color.White
36    }
37}
38
39fun TicTacToe.color(): Color {
40    if (isGameOver()) {
41        return config.GameOverColor.color
42    }
43    return player.value.color()
44}

这个负责从config.json文件中读取颜色配置,然后在游戏中对不同玩家显示不同颜色,并给出游戏结束时的颜色。

这个config.json文件的内容是:

1{
2  "PlayerXColor": "#00FF00",
3  "PlayerOColor": "#0000FF",
4  "GameOverColor": "#FF0000"
5}

这样,我们就完成了一个简单的井字棋游戏。

运行

在windows下面,我们可以通过gradlew.bat来运行这个程序:

1./gradlew.bat run

还能够通过gradle来构建这个程序:

1./gradlew.bat createDistributable

这样就会在build/compose/binaries目录下生成一个可执行文件程序文件夹,里面包含了可执行文件和依赖库。

这个文件拷贝到其他地方,就可以运行了。

当然在IDEA中——默认你使用的是IDEA——你可以直接右边的Gradle工具栏中选择Tasks,然后选择application,然后选择run,就可以运行这个程序。

play

总结

这个程序是一个简单的井字棋游戏,我们通过Jetpack Compose来实现了界面,通过Kotlin来实现了逻辑。

至于程序的实现细节,源代码的解读,就放在下次。


文章标签

|-->jetpack |-->compose-desktop |-->kotlin


GitHub