Всем привет! В этой статье для начинающих я постараюсь шаг за шагом помочь вам создать настольное приложение с помощью Jetpack Compose. Мы создадим классическую игру «Камень-ножницы-бумага», чтобы она стала для вас увлекательной и познавательной. Итак, начинаем учиться!

Полный код: https://github.com/basilinnia/RockPaperScissors-DesktopGame.git

1. Начиная с пользовательского интерфейса

Чтобы вам было легче следить, я подготовил файл Figma со всем, что у нас есть, дизайном главного экрана и игрового экрана. Мы реализуем оба экрана в двух разных темах: темной и светлой.

Вот ссылка на файл Figma: интерфейс Figma Rock-Paper-Scissors

2. Создание проекта и темы

Я использую IntelliJ IDEA Community Edition 2022.1.2 для этого проекта. И мой проект выглядит так:

После создания моего проекта я добавляю расширенную зависимость значков, которую я буду использовать позже:

И это моя структура проекта:

По сути, я храню свои файлы шрифтов и движущиеся изображения в ресурсах, а каталог темы содержит «Theme.kt», в котором мы определяем темные и светлые цвета темы, и Font.kt, который содержит шрифты. Вы можете получить файлы изображений и шрифтов из моего репозитория. Начнем с файла Theme.kt:

package theme

import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color

// You can change the colors
val LightTheme = lightColors(
//affect the surfaces of components
    surface = Color(0xffD9D9D9),
    onSurface  = Color(0xff070A52),
//for key components across the UI
    primary = Color(0xffDF2E38),
    secondaryVariant = Color.Black,
// that sits on top of primary
    onPrimary = Color.White,
// that sits on top of secondary
    onSecondary = Color(0xffAF2D2D)
)
val DarkTheme = darkColors(
    surface = Color(0xff353535),
    onSurface  = Color(0xffBEBFD1),
    primary = Color(0xff2751A3),
    onPrimary = Color.White,
    secondaryVariant = Color.White,
    onSecondary =Color(0xff6881D8)
)

@Composable
fun AppTheme(
// Shows which theme are you in 
    isDark: Boolean = true,
    content: @Composable () -> Unit,
) {
    MaterialTheme(
        colors = if (isDark) DarkTheme else LightTheme // Use the appropriate color list
    ) {
        Surface {
            content()
        }
    }
}

Чтобы понять, что делают эти цветовые схемы, вы можете проверить эту документацию и узнать больше. Затем у нас есть Fonts.kt, в основном мы получаем файлы шрифтов из нашего каталога resources , а затем добавляем вес шрифта:

val RedHatDisplay = FontFamily(
    Font("fonts/RedHatDisplay/RedHatDisplay-Medium.ttf", FontWeight.Medium),
    Font("fonts/RedHatDisplay/RedHatDisplay-SemiBold.ttf", FontWeight.SemiBold),
)

Вы можете проверить файлы шрифтов и получить их отсюда: https://fonts.google.com

3. Начиная с главного экрана

  • Аннотация @Preview сообщает Android Studio, что этот компонуемый объект должен отображаться в режиме конструктора этого файла.
  • Компонуемая функция App — это точка входа приложения. Эти переменные используются для отслеживания темы приложения (темный или светлый режим) и текущего состояния экрана (отображается ли основной экран или экран игры).
  • Функция main() устанавливает окно приложения с заголовком «Камень, ножницы, бумага» и фиксированным размером окна 800x650 dp. Затем он вызывает компонуемый App() для запуска приложения.
@Composable
@Preview
fun App() {
    //we're using "remember" because app's theme and screen
    //state persist during updates.
    val themeState = remember { mutableStateOf(true) }
    val screenState = remember { mutableStateOf(true) }

    AppTheme(isDark = themeState.value) {
        Box(
            modifier = Modifier.fillMaxSize().padding(vertical = 16.dp),
            contentAlignment = Alignment.TopEnd
        ) {
            // basically this is our theme toggler icon 
            IconButton(
                modifier = Modifier.padding(horizontal = 16.dp),
            //theme changes on every click
                onClick = ({ themeState.value = !themeState.value })
            ) {
                Icon(
                    if (themeState.value) Icons.Outlined.LightMode else Icons.Outlined.DarkMode,
                    contentDescription = "Icon",
                    modifier = Modifier.size(48.dp)
                )
            }
            //change screen
            if (screenState.value) {
                MainScreen { screenState.value = false }
            } else {
                GameScreen { screenState.value = true }
            }
        }
    }
}

//takes an lambda function which returns nothing as a parameter
@Composable
fun MainScreen(navigateToGameScreen: ()-> Unit ) {
   //Main Screen code
 }

fun main() = application {
    Window(onCloseRequest = ::exitApplication, title = "Rock Paper Scissors", state = rememberWindowState(size = DpSize(width = 800.dp, height = 650.dp)) // Set the window size here
    ) {
        App()
    }
}

По сути, после нажатия кнопки в компоновке MainScreen мы переключаемся на игровой экран:

Button(
    shape = RoundedCornerShape(14),
    onClick = {navigateToGameScreen()},
    modifier = Modifier.padding(end = 30.dp).size(120.dp, 80.dp)
) {
    Text(
        color = Color.White,
        fontSize = 22.sp,
        fontFamily = Nunito,
        fontWeight = FontWeight.Bold,
        text = "PLAY"
    )
}

Итак, это наш главный экран:

4. Создание игрового экрана

Чтобы избежать сложных длинных кодов, я разделил GameScreen на такие части:

// available moves
val moves = listOf("ROCK", "PAPER", "SCISSORS")

@Composable
fun GameScreen(navigateToMainScreen: () -> Unit) {
    val (playerMove, setPlayerMove) = remember { mutableStateOf("ROCK") }
    val computerMove = remember{mutableStateOf("ROCK")}
    val playerScore = remember { mutableStateOf(0) }
    val computerScore = remember { mutableStateOf(0) }

    Column(modifier = Modifier.fillMaxWidth()) {
    // Other screen elements like reset button and scores 
    }
    // Shows the result
    Text(getWinner(playerMove, computerMove.value), fontFamily = RedHatDisplay, fontSize = 46.sp, fontWeight = FontWeight.Bold, color = colors.onSecondary)
    // Shows the images of moves
    CurrentMove(playerMove,computerMove.value)
    // Move buttons
    Moves(setPlayerMove, computerMove, playerScore, computerScore)
        }
    }
}

Во-первых, давайте запустим функции, которые обновляют другие элементы, функция getWinner сравнивает ходы и возвращает строковое сообщение в соответствии с победителем, вкратце это логика игры:

fun getWinner(playerMove: String, computerMove: String): String {
    return when {
        playerMove == computerMove -> "DRAW"
        (playerMove == "ROCK" && computerMove == "SCISSORS") ||
        (playerMove == "PAPER" && computerMove == "ROCK") ||
        (playerMove == "SCISSORS" && computerMove == "PAPER") -> "YOU WON\uD83C\uDF89!"
        else -> "COMPUTER WON\uD83C\uDF89!"
    }
}

Затем мы проверяем текст, который возвращает функция getWinner, и затем решаем, счет какого игрока будет увеличен в соответствии с текстом:

fun updateScores(winner: String, playerScore:MutableState<Int>, computerScore:MutableState<Int>) {
    when (winner) {
        "YOU WON\uD83C\uDF89!"-> playerScore.value += 1
        "COMPUTER WON\uD83C\uDF89!"->computerScore.value += 1
    }
}

Затем у нас есть компонуемый Moves, который обновляет текущий ход, затем выполняет сравнение между игроками, а затем возвращает что-то в соответствии с победившей стороной при каждом нажатии:

@Composable
fun Moves(setMove: (String) -> Unit, computerMove: MutableState<String>, playerScore:MutableState<Int>, computerScore:MutableState<Int>) {
    Text("Choose your move, rock paper or scissors?", color = Color.Gray)

    Row(modifier = Modifier.fillMaxWidth().padding(vertical = 25.dp), horizontalArrangement = Arrangement.SpaceAround) {
        for (move in moves) {
            Button(
                shape = RoundedCornerShape(14),
                onClick = {
                    setMove(move)
                    computerMove.value = moves.random()
                    updateScores(getWinner(move, computerMove.value), playerScore, computerScore)
                },
                modifier = Modifier.size(180.dp, 60.dp)
            ) {
                Text(fontWeight = FontWeight.ExtraBold, fontFamily = Nunito, text = move)
            }
        }
    }
}

Кроме того, мы упомянули изображения ходов в нашем каталоге ресурсов, с помощью этой функции, по сути, мы получаем имена ходов и меняем изображения в соответствии с именами:

@Composable
fun CurrentMove(playerMove: String, computerMove:String) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceAround,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Image(painter = painterResource("moves/$playerMove.svg"),
            contentDescription = null,
            modifier = Modifier.size(230.dp),
            colorFilter = ColorFilter.tint(colors.secondaryVariant)
            )
        Text(fontWeight = FontWeight.ExtraBold, fontFamily = Nunito, text = "VS")
        Image(
            painter = painterResource("moves/$computerMove.svg"),
            contentDescription = null,
            // set color from the color scheme in our themes
            colorFilter = ColorFilter.tint(colors.secondaryVariant),
            // rotating the second image to better UI
            modifier = Modifier.size(230.dp).rotate(180f)
        )
    }
}

Наконец, это полный код GameScreen, в котором мы объединили все составные части:

@Composable
fun GameScreen(navigateToMainScreen: () -> Unit) {
    val (playerMove, setPlayerMove) = remember { mutableStateOf("ROCK") }
    val computerMove = remember{mutableStateOf("ROCK")}
    val playerScore = remember { mutableStateOf(0) }
    val computerScore = remember { mutableStateOf(0) }

    Column(modifier = Modifier.fillMaxWidth()) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            // go back to the main screen
            IconButton(onClick = navigateToMainScreen) {
                Icon(Icons.Outlined.ArrowBack, contentDescription = "back")
            }
            Text(
                fontWeight = FontWeight.ExtraBold,
                fontSize = 26.sp,
                text = "Rock Paper Scissors with Compose",
                fontFamily = Nunito
            )
        }
        Column(
            modifier = Modifier.fillMaxSize().padding(horizontal = 15.dp, vertical = 15.dp),
            verticalArrangement = Arrangement.SpaceBetween,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                // resets the scores 
                TextButton(onClick = {
                    playerScore.value = 0
                    computerScore.value = 0
                }) {
                    Text(
                        color = colors.secondaryVariant,
                        fontFamily = SignikaNegative,
                        fontSize = 20.sp,
                        text = "Reset The Tour",
                        fontWeight = FontWeight.SemiBold
                    )
                }
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.SpaceAround
                ) {
                    // shows the player score
                    Text(
                        fontFamily = RedHatDisplay,
                        color = colors.secondaryVariant,
                        text = "PLAYER SCORE: ${playerScore.value}",
                        fontSize = 15.sp
                    )
                    // shows the computer score
                    Text(
                        fontFamily = RedHatDisplay,
                        modifier = Modifier.padding(start = 120.dp),
                        color = colors.secondaryVariant,
                        text = "COMPUTER SCORE: ${computerScore.value}",
                        fontSize = 15.sp
                    )
                }
            }
            // add other composables
            Text(getWinner(playerMove, computerMove.value), fontFamily = RedHatDisplay, fontSize = 46.sp, fontWeight = FontWeight.Bold, color = colors.onSecondary)
            CurrentMove(playerMove,computerMove.value)
            Moves(setPlayerMove, computerMove, playerScore, computerScore)
        }
    }
}

И это результат! Надеюсь, вам понравилось! Если у вас есть вопросы или рекомендации, дайте мне знать!

Те же ресурсы, которые вы можете проверить: