diff --git a/.gitignore b/.gitignore index 347e252..60ffe4f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,8 @@ google-services.json # Android Profiling *.hprof +/app/debug/output-metadata.json + +# Ha! +keystore/* +release \ No newline at end of file diff --git a/README.md b/README.md index 8646a6c..01fd259 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,17 @@ # TicTacToeAndroid -Simple tic-tac-toe game for practicing with Android/Kotlin. + +Simple tic-tac-toe game written for practicing Android/Kotlin/Compose. + +Allows player to choose whether they'd like to go first `X` or second `O`, +play with a friend or against a choice of algorithms, +and maybe even make the game mad. 🐇🥚 + +## Download + +App is hosted at [Hyperling.com](https://hyperling.com/home/#tictactoe) where +it is also available for +[Direct Download](https://hyperling.com/files/apks/TicTacToe.apk). + +## TODO + +May add it to F-Droid later if time permits, would like to learn how that works. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..a281a7c --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.jetbrainsKotlinAndroid) +} + +android { + namespace = "com.hyperling.tictactoe" + compileSdk = 34 + + defaultConfig { + applicationId = "com.hyperling.tictactoe" + minSdk = 21 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/hyperling/tictactoe/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/hyperling/tictactoe/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..c7eab98 --- /dev/null +++ b/app/src/androidTest/java/com/hyperling/tictactoe/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.hyperling.tictactoe + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.hyperling.tictactoe", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a3e89b7 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_icon_ttt-playstore.png b/app/src/main/ic_icon_ttt-playstore.png new file mode 100644 index 0000000..65c8bd9 Binary files /dev/null and b/app/src/main/ic_icon_ttt-playstore.png differ diff --git a/app/src/main/java/com/hyperling/tictactoe/MainActivity.kt b/app/src/main/java/com/hyperling/tictactoe/MainActivity.kt new file mode 100644 index 0000000..16f30ad --- /dev/null +++ b/app/src/main/java/com/hyperling/tictactoe/MainActivity.kt @@ -0,0 +1,838 @@ +package com.hyperling.tictactoe + +import android.app.Activity +import android.content.res.Configuration +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.tooling.preview.Preview +import com.hyperling.tictactoe.ui.theme.TicTacToeTheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TicTacToeTheme { + Surface { + Game() + } + } + } + } +} + +@Composable +fun Game() { + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + val activity = (LocalContext.current as? Activity) + + // Preview does not handle toasts well, easily turn them off and on. + val toastsEnabled = true + + /* State-aware variables. */ + + // Shown on the screen. + var msg by remember { mutableStateOf("") } + var clearText by remember { mutableStateOf("") } + + // Board pieces. + val grid = remember { mutableStateListOf() } + if (grid.isEmpty()) { + for (i in 0..8) { + grid.add("") + } + } + var turn by remember { mutableStateOf("X") } + var lastTurn by remember { mutableStateOf("O") } + + // Hidden flags for determining where we're at. + var status by remember{ mutableIntStateOf(0) } + var gameOver by remember{ mutableStateOf(false) } + var showClear by remember { mutableStateOf(true) } + + // Character pieces. + var player by rememberSaveable { mutableStateOf("X") } + var opponent by rememberSaveable { mutableStateOf("O") } + + // AI choices. + var opponentHuman by rememberSaveable { mutableStateOf(false) } + var opponentRandom by rememberSaveable { mutableStateOf(true) } + var opponentHard by rememberSaveable { mutableStateOf(false) } + var opponentEasy by rememberSaveable { mutableStateOf(false) } + var opponentAnnoying by rememberSaveable { mutableStateOf(false) } + var opponentShy by rememberSaveable { mutableStateOf(false) } + + // Being a goofball. + var mainText by remember { mutableStateOf("") } + var mainClicks by remember { mutableIntStateOf(0) } + // */ + + + /* Helper functions to change global variables in bulk. */ + + // Check if the game has ended. + fun checkStatus() { + if (status != 0) { + if (status == 1) { + msg = "Congratulations, $lastTurn won!" + } else if (status == 2) { + msg = "Tie, better luck next time!" + } + status = 0 + showClear = true + gameOver = true + } + } + + // Place a piece on the board, check for end game, and move to the next player's piece. + fun takeTurn(location: Int) { + lastTurn = turn + + if (!gameOver) { + grid[location] = turn + status = checkGrid(grid) + checkStatus() + turn = changeTurn(turn) + } + } + + // Set all the variables for starting the app near-fresh. + fun createGame() { + for (i in 0..8) { + grid[i] = "" + } + showClear = false + gameOver = false + turn = "X" + lastTurn = "O" + } + + // Toggle whether the primary player goes first or second. + fun togglePlayer() { + when (player) { + "X" -> { player = "O"; opponent = "X" } + "O" -> { player = "X"; opponent = "O" } + } + + if (!gameOver) { + createGame() + if (toastsEnabled) { + Toast.makeText( + context, + "Game has been cleared.", + Toast.LENGTH_SHORT + ).show() + } + } + + if (toastsEnabled) { + Toast.makeText( + context, + "Opponent is now $opponent.", + Toast.LENGTH_SHORT + ).show() + } + } + + // Sets all the radio buttons to false and returns true for the one being changed. + fun setRadiosFalse(): Boolean { + opponentHuman = false + opponentRandom = false + opponentHard = false + opponentEasy = false + opponentAnnoying = false + opponentShy = false + return true + } + // */ + + + /* AI logic. */ + if (turn == opponent && !gameOver) { + var play = -1 + if (opponentRandom) { + play = playWeightedMove(grid, -1, opponent) + } else if (opponentEasy) { + play = playWeightedMove(grid, 0, opponent) + } else if (opponentHard) { + play = playWeightedMove(grid, 1, opponent) + } else if (opponentAnnoying) { + play = playWeightedMove(grid, 2, opponent) + } else if (opponentShy) { + play = playWeightedMove(grid, 3, opponent) + } + if (!opponentHuman) { + takeTurn(play) + } + } + // */ + + + // Main layout object. + Column( + horizontalAlignment = Alignment.CenterHorizontally + , verticalArrangement = Arrangement.Center + , modifier = Modifier + .fillMaxSize() + ) { + + Spacer(modifier = Modifier.weight(0.1f)) + + /* Header text. */ + Column ( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ){ + when (mainClicks) { + 0 -> mainText = "Let's play Tic-Tac-Toe!" + 1 -> mainText = "Ouch! Why'd you hit me!?" + 2 -> mainText = "Hey! Stop that!" + 3 -> mainText = "Alright wise guy..." + 4 -> mainText = "Play the game!" + 5 -> mainText = "I'm getting tired of you!" + 6 -> mainText = "Last chance, jerk!!" + 7 -> { + createGame() + opponentHard = setRadiosFalse() + while (!gameOver) { + takeTurn(playWeightedMove(grid, 0, player)) + } + opponentEasy = setRadiosFalse() + mainClicks += 1 + } + 8 -> mainText = "OK, take this! I hope easy bot won!" + 9 -> mainText = "We're done here, play the game!" + 10 -> mainText = "What are you, deaf?" + 11 -> mainText = "You're determined, fella." + 12 -> mainText = "Not much more here, I'm boring." + 13 -> mainText = "Seriously though, bye!" + 14 -> mainText = "Fine, keep clicking, I'll play for you." + 15 -> { + if (gameOver) { createGame() } + takeTurn(playWeightedMove(grid, -1, player)) + mainClicks = 14 + } + else -> mainClicks = 14 + } + Text( + text = mainText, + textAlign = TextAlign.Center, + fontSize = 24.sp, + modifier = Modifier.clickable { + mainClicks++ + } + ) + + if (lastTurn != turn && !gameOver) { + msg = "Player $turn, your turn!" + } + Text( + text = msg, + textAlign = TextAlign.Center, + fontSize = 20.sp, + modifier = Modifier + .padding(10.dp) + ) + } + Spacer(modifier = Modifier.size(10.dp)) + // */ + + /* The Grid */ + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + + // OK, this is awesome, thanks Jetbrains Compose. + var count = 0 + for (row in 1..3) { + Row { + for (col in 1..3) { + val index = count + Button( + onClick = { + if (!gameOver && grid[index].isEmpty()) { + takeTurn(index) + } + }, modifier = Modifier + .size(69.dp) + ) { + Text( + text = grid[index], + fontSize = 28.sp, + textAlign = TextAlign.Center + ) + } + count++ + } + } + } + } + Spacer(modifier = Modifier.size(10.dp)) + // */ + + + /* Clear, restart, start new game button. */ + clearText = "Restart" + if (showClear) { + clearText = "Start New Game" + } + Row { + FilledTonalButton(onClick = { createGame() }) { + Text( + text = clearText, fontSize = 16.sp + ) + } + } + Spacer(modifier = Modifier.weight(0.05f)) + // */ + + + /* Allow player to go 2nd (play as O) */ + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { togglePlayer() } + ) { + Text( + text = "Primary user playing as $player.", + fontSize = 16.sp + ) + Spacer(modifier = Modifier.size(5.dp)) + Switch( + checked = (player == "X"), + onCheckedChange = { togglePlayer() } + ) + } + Spacer(modifier = Modifier.weight(0.05f)) + // */ + + + /* Opponent difficulty radio buttons. */ + // TBD: Make an array and build the radio buttons dynamically. + /* + MyRadioChoice { + selected: Boolean, + text: String + } + */ + Column ( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Bottom + ){ + Text( + text = stringResource(id = R.string.opponent_header), + fontSize = 16.sp + ) + Spacer(modifier = Modifier.size(5.dp)) + Row ( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + opponentHuman = setRadiosFalse() + } + ){ + RadioButton( + selected = opponentHuman, + onClick = { opponentHuman = setRadiosFalse() }, + ) + Text( + text = stringResource(id = R.string.opponent_human), + fontSize = 16.sp + ) + } + Row ( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + opponentRandom = setRadiosFalse() + } + ){ + RadioButton( + selected = opponentRandom, + onClick = { opponentRandom = setRadiosFalse() }, + ) + Text( + text = stringResource(id = R.string.opponent_random), + fontSize = 16.sp + ) + } + Row ( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + opponentHard = setRadiosFalse() + } + ){ + RadioButton( + selected = opponentHard, + onClick = { opponentHard = setRadiosFalse() }, + ) + Text( + text = stringResource(id = R.string.opponent_hard), + fontSize = 16.sp + ) + } + Row ( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + opponentEasy = setRadiosFalse() + } + ){ + RadioButton( + selected = opponentEasy, + onClick = { opponentEasy = setRadiosFalse() }, + ) + Text( + text = stringResource(id = R.string.opponent_easy), + fontSize = 16.sp + ) + } + Row ( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + opponentAnnoying = setRadiosFalse() + } + ){ + RadioButton( + selected = opponentAnnoying, + onClick = { opponentAnnoying = setRadiosFalse() }, + ) + Text( + text = stringResource(id = R.string.opponent_annoying), + fontSize = 16.sp + ) + } + Row ( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + opponentShy = setRadiosFalse() + } + ){ + RadioButton( + selected = opponentShy, + onClick = { opponentShy = setRadiosFalse() }, + ) + Text( + text = stringResource(id = R.string.opponent_shy), + fontSize = 16.sp + ) + } + } + Spacer(modifier = Modifier.weight(.05f)) + // */ + + + /* Media links. */ + Column( + verticalArrangement = Arrangement.Bottom + ) { + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + val text1 = stringResource(id = R.string.link1_text) + val uri1 = stringResource(id = R.string.link1_uri) + OutlinedButton( + onClick = { + uriHandler.openUri(uri1) + }, + modifier = Modifier + .padding(10.dp) + .weight(0.4f) + ) { + Text( + text = text1, + fontSize = 14.sp + ) + } + + Spacer(modifier = Modifier.weight(0.05f)) + + IconButton( + onClick = { activity?.finish() }, + modifier = Modifier.weight(0.1f) + ) { + Icon( + imageVector = Icons.Rounded.Close + , contentDescription = "Exit" + , modifier = Modifier.size(18.dp) + ) + } + + Spacer(modifier = Modifier.weight(0.05f)) + + val text2 = stringResource(id = R.string.link2_text) + val uri2 = stringResource(id = R.string.link2_uri) + OutlinedButton( + onClick = { + uriHandler.openUri(uri2) + }, + modifier = Modifier + .padding(10.dp) + .weight(0.4f) + ) { + Text( + text = text2, + fontSize = 14.sp + ) + } + } + + } + // */ + } +} + +/* Light Theme * / +@Preview( + name = "Normal" + , showBackground = true + , uiMode = Configuration.UI_MODE_NIGHT_NO + , device = Devices.PIXEL_3A + //, showSystemUi = true +) +// */ +/* Dark Theme */ +@Preview( + name = "Night" + , showBackground = true + , uiMode = Configuration.UI_MODE_NIGHT_YES + , device = Devices.PIXEL_3A + //, showSystemUi = true +) +// */ +@Composable +fun PreviewGame() { + TicTacToeTheme { + Surface { + Game() + } + } +} + +// Check whether any winning conditions have been met. +// Return values: +// 0: False, no victor yet. +// 1: Victory! +// 2: Game ended in a tie. +private fun checkGrid(grid: List): Int { + + // Left column + if (grid[0].isNotBlank() && grid[0] === grid[3] && grid[3] === grid[6]) { + return 1 + } + // Center column + if (grid[1].isNotBlank() && grid[1] == grid[4] && grid[4] === grid[7]) { + return 1 + } + // Right column + if (grid[2].isNotBlank() && grid[2] === grid[5] && grid[5] === grid[8]) { + return 1 + } + + // Top row + if (grid[0].isNotBlank() && grid[0] === grid[1] && grid[1] === grid[2]) { + return 1 + } + // Middle row + if (grid[3].isNotBlank() && grid[3] === grid[4] && grid[4] === grid[5]) { + return 1 + } + // Bottom row + if (grid[6].isNotBlank() && grid[6] === grid[7] && grid[7] === grid[8]) { + return 1 + } + + // Top left to bottom right diagonal + if (grid[0].isNotBlank() && grid[0] === grid[4] && grid[4] === grid[8]) { + return 1 + } + // Top right to bottom left diagonal + if (grid[2].isNotBlank() && grid[2] === grid[4] && grid[4] === grid[6]) { + return 1 + } + + var tie = true + for (item in grid) { + if (item.isBlank()) { + tie = false + break + } + } + if (tie) { return 2 } + + // No end game status yet. + return 0 +} + +// Change which player's turn it is. +private fun changeTurn(turn: String): String { + if (turn == "X") { + return "O" + } + return "X" +} + +/* Random Player * / +private fun playRandomMove(grid: MutableList): Int { + /* + var choice: Int + do { + choice = (0..8).random() + } + while (grid[choice].isNotEmpty()) + return choice + */ + return playWeightedMove(grid, -1, "X") +} +// */ + + +/* AI Players */ +// Behavior determines if the AI is: +// 0 - Plays to lose +// 1 - Plays to win +// 2 - Plays to tie +// * - Random, every open spot has the same weight. +// Returns the grid number which should get the AI's mark. +fun playWeightedMove(grid: MutableList, behavior: Int, piece: String) : Int { + val t = "playWeightedMove" + val d = false + + // Determine who's who. + val human: String = ( if (piece == "X") "O" else "X" ) + + // Custom weights depending on the AI's desire. + val played: Int = -100 + val side : Int + val corner: Int + val middle: Int + val causeWin: Int + val preventLoss: Int + when (behavior) { + // Easy, does not prevent human from winning and may try to win. + 0 -> { + causeWin = 100 + preventLoss = -1 + middle = 1 + corner = 1 + side = 1 + } + // Hard, will try to win for itself and prevent the human from winning. + 1 -> { + causeWin = 100 + preventLoss = 99 + middle = 4 + corner = 3 + side = 2 + } + // Annoying / stubborn, will not let you win but also will not try to win. + 2 -> { + causeWin = 99 + preventLoss = 100 + middle = 4 + corner = 3 + side = 2 + } + // Shy, refuses to complete the game unless it's the only move left. + 3 -> { + causeWin = -1 + preventLoss = -1 + middle = 1 + corner = 1 + side = 1 + } + // Random! No need for the other function anymore. ;) + else -> { + causeWin = 0 + preventLoss = 0 + middle = 0 + corner = 0 + side = 0 + } + } + + // Initialize a blank board's weights. + val weights: IntArray = intArrayOf( + corner, side, corner, side, middle, side, corner, side, corner + ) + + /* Check if any conditions are currently available. */ + + // Top Row, Win Conditions + if (grid[0] == piece && grid[1] == piece && grid[2].isBlank()) + weights[2] = causeWin + if (grid[0] == piece && grid[1].isBlank() && grid[2] == piece) + weights[1] = causeWin + if (grid[0].isBlank() && grid[1] == piece && grid[2] == piece) + weights[0] = causeWin + + // Top Row, Lose Conditions + if (grid[0] == human && grid[1] == human && grid[2].isBlank()) + weights[2] = preventLoss + if (grid[0] == human && grid[1].isBlank() && grid[2] == human) + weights[1] = preventLoss + if (grid[0].isBlank() && grid[1] == human && grid[2] == human) + weights[0] = preventLoss + + // Middle Row, Win Conditions + if (grid[3] == piece && grid[4] == piece && grid[5].isBlank()) + weights[5] = causeWin + if (grid[3] == piece && grid[4].isBlank() && grid[5] == piece) + weights[4] = causeWin + if (grid[3].isBlank() && grid[4] == piece && grid[5] == piece) + weights[3] = causeWin + + // Middle Row, Lose Conditions + if (grid[3] == human && grid[4] == human && grid[5].isBlank()) + weights[5] = preventLoss + if (grid[3] == human && grid[4].isBlank() && grid[5] == human) + weights[4] = preventLoss + if (grid[3].isBlank() && grid[4] == human && grid[5] == human) + weights[3] = preventLoss + + // Bottom Row, Win Conditions + if (grid[6] == piece && grid[7] == piece && grid[8].isBlank()) + weights[8] = causeWin + if (grid[6] == piece && grid[7].isBlank() && grid[8] == piece) + weights[7] = causeWin + if (grid[6].isBlank() && grid[7] == piece && grid[8] == piece) + weights[6] = causeWin + + // Bottom Row, Lose Conditions + if (grid[6] == human && grid[7] == human && grid[8].isBlank()) + weights[8] = preventLoss + if (grid[6] == human && grid[7].isBlank() && grid[8] == human) + weights[7] = preventLoss + if (grid[6].isBlank() && grid[7] == human && grid[8] == human) + weights[6] = preventLoss + + // Left Column, Win Conditions + if (grid[0] == piece && grid[3] == piece && grid[6].isBlank()) + weights[6] = causeWin + if (grid[0] == piece && grid[3].isBlank() && grid[6] == piece) + weights[3] = causeWin + if (grid[0].isBlank() && grid[3] == piece && grid[6] == piece) + weights[0] = causeWin + + // Left Column, Lose Conditions + if (grid[0] == human && grid[3] == human && grid[6].isBlank()) + weights[6] = preventLoss + if (grid[0] == human && grid[3].isBlank() && grid[6] == human) + weights[3] = preventLoss + if (grid[0].isBlank() && grid[3] == human && grid[6] == human) + weights[0] = preventLoss + + // Middle Column, Win Conditions + if (grid[1] == piece && grid[4] == piece && grid[7].isBlank()) + weights[7] = causeWin + if (grid[1] == piece && grid[4].isBlank() && grid[7] == piece) + weights[4] = causeWin + if (grid[1].isBlank() && grid[4] == piece && grid[7] == piece) + weights[1] = causeWin + + // Middle Column, Lose Conditions + if (grid[1] == human && grid[4] == human && grid[7].isBlank()) + weights[7] = preventLoss + if (grid[1] == human && grid[4].isBlank() && grid[7] == human) + weights[4] = preventLoss + if (grid[1].isBlank() && grid[4] == human && grid[7] == human) + weights[1] = preventLoss + + // Right Column, Win Conditions + if (grid[2] == piece && grid[5] == piece && grid[8].isBlank()) + weights[8] = causeWin + if (grid[2] == piece && grid[5].isBlank() && grid[8] == piece) + weights[5] = causeWin + if (grid[2].isBlank() && grid[5] == piece && grid[8] == piece) + weights[2] = causeWin + + // Right Column, Lose Conditions + if (grid[2] == human && grid[5] == human && grid[8].isBlank()) + weights[8] = preventLoss + if (grid[2] == human && grid[5].isBlank() && grid[8] == human) + weights[5] = preventLoss + if (grid[2].isBlank() && grid[5] == human && grid[8] == human) + weights[2] = preventLoss + + // Top Left Diagonal, Win Conditions + if (grid[0] == piece && grid[4] == piece && grid[8].isBlank()) + weights[8] = causeWin + if (grid[0] == piece && grid[4].isBlank() && grid[8] == piece) + weights[4] = causeWin + if (grid[0].isBlank() && grid[4] == piece && grid[8] == piece) + weights[0] = causeWin + + // Top Left Diagonal, Lose Conditions + if (grid[0] == human && grid[4] == human && grid[8].isBlank()) + weights[8] = preventLoss + if (grid[0] == human && grid[4].isBlank() && grid[8] == human) + weights[4] = preventLoss + if (grid[0].isBlank() && grid[4] == human && grid[8] == human) + weights[0] = preventLoss + + // Top Right Diagonal, Win Conditions + if (grid[2] == piece && grid[4] == piece && grid[6].isBlank()) + weights[6] = causeWin + if (grid[2] == piece && grid[4].isBlank() && grid[6] == piece) + weights[4] = causeWin + if (grid[2].isBlank() && grid[4] == piece && grid[6] == piece) + weights[2] = causeWin + + // Top Right Diagonal, Lose Conditions + if (grid[2] == human && grid[4] == human && grid[6].isBlank()) + weights[6] = preventLoss + if (grid[2] == human && grid[4].isBlank() && grid[6] == human) + weights[4] = preventLoss + if (grid[2].isBlank() && grid[4] == human && grid[6] == human) + weights[2] = preventLoss + + // */ + + // Set all the played pieces to a very low number. + for (i in 0..8) { + if (grid[i].isNotBlank()) { + weights[i] = played + } + } + + // Go through the weights, and put the indexes of the highest in a new array. + var highestIndexes: IntArray = intArrayOf() + var heaviest = played + 1 + if (d) Log.d(t, "*** Starting New Calculation ***") + for ((count, weight) in weights.withIndex()) { + if (d) Log.d(t, "count=$count, weight=$weight, heaviest=$heaviest") + if (weight > heaviest) { + heaviest = weight + highestIndexes = intArrayOf(count) + if (d) Log.d(t, "highestIndexes reset. " + highestIndexes.count().toString()) + } else if (weight == heaviest) { + highestIndexes += count + if (d) Log.d(t, "highestIndexes appended. " + highestIndexes.count().toString()) + } + } + + // In case there are multiple indexes with a tied weight, choose a random one. + return highestIndexes[highestIndexes.indices.random()] +} +// */ + diff --git a/app/src/main/java/com/hyperling/tictactoe/ui/theme/Color.kt b/app/src/main/java/com/hyperling/tictactoe/ui/theme/Color.kt new file mode 100644 index 0000000..47e3c85 --- /dev/null +++ b/app/src/main/java/com/hyperling/tictactoe/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.hyperling.tictactoe.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/hyperling/tictactoe/ui/theme/Theme.kt b/app/src/main/java/com/hyperling/tictactoe/ui/theme/Theme.kt new file mode 100644 index 0000000..d390455 --- /dev/null +++ b/app/src/main/java/com/hyperling/tictactoe/ui/theme/Theme.kt @@ -0,0 +1,70 @@ +package com.hyperling.tictactoe.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun TicTacToeTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/hyperling/tictactoe/ui/theme/Type.kt b/app/src/main/java/com/hyperling/tictactoe/ui/theme/Type.kt new file mode 100644 index 0000000..725dbd8 --- /dev/null +++ b/app/src/main/java/com/hyperling/tictactoe/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.hyperling.tictactoe.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_icon_ttt_background.xml b/app/src/main/res/drawable/ic_icon_ttt_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/app/src/main/res/drawable/ic_icon_ttt_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_icon_ttt.xml b/app/src/main/res/mipmap-anydpi-v26/ic_icon_ttt.xml new file mode 100644 index 0000000..47f02d0 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_icon_ttt.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_icon_ttt_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_icon_ttt_round.xml new file mode 100644 index 0000000..47f02d0 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_icon_ttt_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_icon_ttt.webp b/app/src/main/res/mipmap-hdpi/ic_icon_ttt.webp new file mode 100644 index 0000000..6e8ce83 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_icon_ttt.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_icon_ttt_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_icon_ttt_foreground.webp new file mode 100644 index 0000000..fa9e306 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_icon_ttt_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_icon_ttt_round.webp b/app/src/main/res/mipmap-hdpi/ic_icon_ttt_round.webp new file mode 100644 index 0000000..06027e7 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_icon_ttt_round.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_icon_ttt.webp b/app/src/main/res/mipmap-mdpi/ic_icon_ttt.webp new file mode 100644 index 0000000..f6ae459 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_icon_ttt.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_icon_ttt_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_icon_ttt_foreground.webp new file mode 100644 index 0000000..b0d8b1c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_icon_ttt_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_icon_ttt_round.webp b/app/src/main/res/mipmap-mdpi/ic_icon_ttt_round.webp new file mode 100644 index 0000000..c8fea52 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_icon_ttt_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_icon_ttt.webp b/app/src/main/res/mipmap-xhdpi/ic_icon_ttt.webp new file mode 100644 index 0000000..780212a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_icon_ttt.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_icon_ttt_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_icon_ttt_foreground.webp new file mode 100644 index 0000000..d359048 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_icon_ttt_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_icon_ttt_round.webp b/app/src/main/res/mipmap-xhdpi/ic_icon_ttt_round.webp new file mode 100644 index 0000000..1386c09 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_icon_ttt_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_icon_ttt.webp b/app/src/main/res/mipmap-xxhdpi/ic_icon_ttt.webp new file mode 100644 index 0000000..10da5e7 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_icon_ttt.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_icon_ttt_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_icon_ttt_foreground.webp new file mode 100644 index 0000000..2d4d273 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_icon_ttt_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_icon_ttt_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_icon_ttt_round.webp new file mode 100644 index 0000000..fdb55cc Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_icon_ttt_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_icon_ttt.webp b/app/src/main/res/mipmap-xxxhdpi/ic_icon_ttt.webp new file mode 100644 index 0000000..703b628 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_icon_ttt.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_icon_ttt_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_icon_ttt_foreground.webp new file mode 100644 index 0000000..7067f99 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_icon_ttt_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_icon_ttt_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_icon_ttt_round.webp new file mode 100644 index 0000000..151a67f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_icon_ttt_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..64640c2 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + Tic-Tac-Toe + + Choose your opponent: + Human - Someone next to you. + AI : Random - Fills any open spot. + AI : Hard - Your typical try-hard. + AI : Easy - Opportunist, may try to win. + AI : Stubborn - Won\'t let you win. + AI : Shy - Too scared to win. + + Website + https://hyperling.com + GitHub + https://github.com/Hyperling/TicTacToeAndroid + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..f89af7e --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +