diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 3ecbd1e..762dddc 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -5,20 +5,16 @@ on: [push, pull_request] jobs: build: - runs-on: macos-latest + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 17 - name: Build Android client run: ./gradlew assembleDebug - - name: Build Compose Desktop client - run: ./gradlew :compose-desktop:assemble - - name: Build iOS shared code - run: ./gradlew :shared:compileKotlinIosArm64 - - name: Tests - run: ./gradlew :shared:testDebugUnitTest + - name: Run shared tests + run: ./gradlew :shared:jvmTest diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 0000000..317d0ef --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,18 @@ +name: Compose Desktop CI + +on: [push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 17 + - name: Build Compose Desktop client + run: ./gradlew :compose-desktop:assemble diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..f84f912 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,25 @@ +name: iOS CI + +on: [push, pull_request] + +jobs: + build: + + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 17 + - name: Build iOS app + run: | + xcodebuild -project iosApp/iosApp.xcodeproj \ + -scheme iosApp \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + build diff --git a/.gitignore b/.gitignore index bd2df55..7cdd3cc 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ gradle-app.setting xcschememanagement.plist *.xcbkptlist /wearApp/build/ + +# Kotlin build/session cache +.kotlin/ diff --git a/README.md b/README.md index 9f609d3..6acaa1f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # WordMasterKMP -![kotlin-version](https://img.shields.io/badge/kotlin-2.2.0-blue?logo=kotlin) +![kotlin-version](https://img.shields.io/badge/kotlin-2.4.10-blue?logo=kotlin) Kotlin Multiplatform sample heavily inspired by [Wordle](https://www.powerlanguage.co.uk/wordle/) game and also [Word Master](https://github.com/octokatherine/word-master) and [wordle-solver](https://github.com/dlew/wordle-solver) samples. The main game logic/state is included in shared KMP code with basic UI then in following clients - iOS (SwiftUI) @@ -15,19 +15,24 @@ Kotlin Multiplatform sample heavily inspired by [Wordle](https://www.powerlangua ### Shared KMP game logic/state -The shared `WordMasterService` class includes following `StateFlow`s representing the current set of guesses and updated status info for each letter. +The shared `WordMasterService` class exposes the game state as `StateFlow`s: ``` -val boardGuesses = StateFlow>>() -val boardStatus = StateFlow>>() +val boardGuesses = StateFlow>>() // letters entered on each row +val boardStatus = StateFlow>>() // per-letter result +val keyStatus = StateFlow>() // best status per key, colours the keyboard +val guessError = StateFlow() // validation feedback ``` -The various clients call `WordService.setGuess()` when a user enters a letter and then `WordService.checkGuess()` after row of letters -are entered...UI then reflects any resulting updates to above `StateFlow`'s. The Compose clients for example do that using following (with any updates to those `StateFlow's` triggering recomposition) +Each client renders a board of read-only tiles plus an on-screen keyboard. Key presses call +`WordMasterService.addLetter()` / `removeLetter()`, and `submitGuess()` validates the row against the +word list (surfacing "Not enough letters" / "Not in word list" via `guessError`) before evaluating it +and updating the flows above. The Compose clients observe state as following (with any updates to those +`StateFlow`'s triggering recomposition) ``` -val boardGuesses by wordMasterService.boardGuesses.collectAsState() val boardStatus by wordMasterService.boardStatus.collectAsState() +val keyStatus by wordMasterService.keyStatus.collectAsState() ``` @@ -41,12 +46,12 @@ On iOS we're using the [KMP-NativeCoroutines](https://github.com/rickclephas/KMP which are then updated using for example ``` -let stream = asyncSequence(for: wordMasterService.boardStatusNative) +let stream = asyncSequence(for: wordMasterService.boardStatus) for try await data in stream { self.boardStatus = data as! [[LetterStatus]] } -let stream = asyncSequence(for: wordMasterService.boardGuessesNative) +let stream = asyncSequence(for: wordMasterService.boardGuesses) for try await data in stream { self.boardGuesses = data as! [[String]] } diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 8ca09c9..810ea48 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -1,6 +1,5 @@ plugins { id("com.android.application") - kotlin("android") alias(libs.plugins.compose.compiler) } diff --git a/androidApp/src/main/java/dev/johnoreilly/wordmaster/androidApp/MainActivity.kt b/androidApp/src/main/java/dev/johnoreilly/wordmaster/androidApp/MainActivity.kt index d015f7b..8b5963e 100644 --- a/androidApp/src/main/java/dev/johnoreilly/wordmaster/androidApp/MainActivity.kt +++ b/androidApp/src/main/java/dev/johnoreilly/wordmaster/androidApp/MainActivity.kt @@ -1,51 +1,37 @@ package dev.johnoreilly.wordmaster.androidApp -import android.annotation.SuppressLint import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.core.Animatable import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Arrangement.Absolute.Center +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.type +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.johnoreilly.wordmaster.androidApp.theme.WordMasterTheme import dev.johnoreilly.wordmaster.shared.LetterStatus import dev.johnoreilly.wordmaster.shared.WordMasterService -import dev.johnoreilly.wordmaster.androidApp.theme.WordMasterTheme -import androidx.lifecycle.compose.collectAsStateWithLifecycle class MainActivity : ComponentActivity() { @@ -61,19 +47,20 @@ class MainActivity : ComponentActivity() { } } -@SuppressLint("UnusedMaterialScaffoldPaddingParameter") @Composable fun MainLayout() { + val snackbarHostState = remember { SnackbarHostState() } Scaffold( - topBar = { WordMasterTopAppBar("WordMaster KMP") } + topBar = { WordMasterTopAppBar("WordMaster KMP") }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { innerPadding -> - WordMasterView(Modifier.padding(innerPadding).imePadding()) + WordMasterView(Modifier.padding(innerPadding).imePadding(), snackbarHostState) } } @Composable -fun WordMasterView(padding: Modifier) { +fun WordMasterView(padding: Modifier, snackbarHostState: SnackbarHostState) { val context = LocalContext.current val wordMasterService = remember { @@ -83,204 +70,174 @@ fun WordMasterView(padding: Modifier) { val boardGuesses by wordMasterService.boardGuesses.collectAsStateWithLifecycle() val boardStatus by wordMasterService.boardStatus.collectAsStateWithLifecycle() + val keyStatus by wordMasterService.keyStatus.collectAsStateWithLifecycle() val revealedAnswer by wordMasterService.revealedAnswer.collectAsStateWithLifecycle() val lastGuessCorrect by wordMasterService.lastGuessCorrect.collectAsStateWithLifecycle() + val guessError by wordMasterService.guessError.collectAsStateWithLifecycle() - val focusManager = LocalFocusManager.current - // FocusRequesters for every cell to enable precise intra-row navigation (e.g., Backspace behavior) - val cellRequesters = remember { - List(WordMasterService.MAX_NUMBER_OF_GUESSES) { List(WordMasterService.NUMBER_LETTERS) { FocusRequester() } } + // Horizontal shake offset applied to the active row when a guess is rejected. + val shakeOffset = remember { Animatable(0f) } + LaunchedEffect(guessError) { + val error = guessError ?: return@LaunchedEffect + snackbarHostState.showSnackbar(error) + val shift = 16f + for (step in listOf(-shift, shift, -shift, shift, -shift / 2, shift / 2, 0f)) { + shakeOffset.animateTo(step) + } + wordMasterService.clearGuessError() } - Row(padding.fillMaxSize().padding(16.dp), horizontalArrangement = Center, verticalAlignment = Alignment.CenterVertically) { + Column( + padding.fillMaxSize().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + for (guessAttempt in 0 until WordMasterService.MAX_NUMBER_OF_GUESSES) { + val rowModifier = if (guessAttempt == wordMasterService.currentGuessAttempt) { + Modifier.offset { androidx.compose.ui.unit.IntOffset(shakeOffset.value.toInt(), 0) } + } else { + Modifier + } + Row(rowModifier, horizontalArrangement = Arrangement.Center) { + for (character in 0 until WordMasterService.NUMBER_LETTERS) { + LetterTile( + letter = boardGuesses[guessAttempt][character], + status = boardStatus[guessAttempt][character] + ) + } + } + } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - for (guessAttempt in 0 until WordMasterService.MAX_NUMBER_OF_GUESSES) { - Row(horizontalArrangement = Arrangement.Center) { - for (character in 0 until WordMasterService.NUMBER_LETTERS) { - Column( - Modifier.padding(4.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { + Spacer(Modifier.height(16.dp)) - var modifier = Modifier.width(55.dp).height(55.dp).focusRequester(cellRequesters[guessAttempt][character]) + if (revealedAnswer != null) { + Text( + text = "Answer: $revealedAnswer", + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(12.dp)) + } - TextField( - value = boardGuesses[guessAttempt][character], - onValueChange = { newValue -> - if (guessAttempt == wordMasterService.currentGuessAttempt) { - val upper = newValue.uppercase() - val capped = if (upper.length > 1) upper.substring(0, 1) else upper - val previous = boardGuesses[guessAttempt][character] + Keyboard( + keyStatus = keyStatus, + onLetter = { wordMasterService.addLetter(it) }, + onEnter = { wordMasterService.submitGuess() }, + onDelete = { wordMasterService.removeLetter() } + ) - if (capped != previous) { - wordMasterService.setGuess( - guessAttempt, - character, - capped - ) - } + Spacer(Modifier.height(12.dp)) - if (capped.isNotEmpty()) { - if (character < WordMasterService.NUMBER_LETTERS - 1) { - // Advance to next column in the same row - focusManager.moveFocus(FocusDirection.Next) - } - } else { - // If we deleted the last character in this cell, move back to previous cell in same row - if (previous.isNotEmpty() && character > 0) { - cellRequesters[guessAttempt][character - 1].requestFocus() - } - } - } - }, - modifier = modifier - .onPreviewKeyEvent { - if (guessAttempt == wordMasterService.currentGuessAttempt && (it.key == Key.Backspace || it.key == Key.Delete) && it.type == KeyEventType.KeyDown) { - val currentVal = boardGuesses[guessAttempt][character] - if (currentVal.isEmpty() && character > 0) { - cellRequesters[guessAttempt][character - 1].requestFocus() - return@onPreviewKeyEvent true - } - } - false - } - .onKeyEvent { - if (it.type == KeyEventType.KeyUp && it.key == Key.Backspace) { - if (guessAttempt == wordMasterService.currentGuessAttempt) { - val currentVal = boardGuesses[guessAttempt][character] - if (currentVal.isEmpty() && character > 0) { - cellRequesters[guessAttempt][character - 1].requestFocus() - return@onKeyEvent true - } - } - } else if (it.type == KeyEventType.KeyUp && (it.key == Key.Enter || it.key == Key.NumPadEnter)) { - if (guessAttempt == wordMasterService.currentGuessAttempt) { - var filled = true - for (c in 0 until WordMasterService.NUMBER_LETTERS) { - if (boardGuesses[guessAttempt][c].isEmpty()) { filled = false; break } - } - if (filled) { - wordMasterService.checkGuess() - // After submitting a guess, move focus to the next row's first cell - focusManager.moveFocus(FocusDirection.Next) - return@onKeyEvent true - } - } - } - false - } - .border(1.dp, Color.Black.copy(alpha = 0.6f), androidx.compose.foundation.shape.RoundedCornerShape(10.dp)), - singleLine = true, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Characters, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { - if (guessAttempt == wordMasterService.currentGuessAttempt) { - var filled = true - for (c in 0 until WordMasterService.NUMBER_LETTERS) { - if (boardGuesses[guessAttempt][c].isEmpty()) { filled = false; break } - } - if (filled) { - wordMasterService.checkGuess() - // After submitting a guess, move focus to the next row's first cell - focusManager.moveFocus(FocusDirection.Next) - } - } - } - ), - textStyle = TextStyle(fontSize = 14.sp, textAlign = TextAlign.Center), - shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp), - colors = TextFieldDefaults.colors( - focusedTextColor = mapLetterStatusToTextColor(boardStatus[guessAttempt][character]), - unfocusedTextColor = mapLetterStatusToTextColor(boardStatus[guessAttempt][character]), - disabledTextColor = mapLetterStatusToTextColor(boardStatus[guessAttempt][character]), - cursorColor = mapLetterStatusToTextColor(boardStatus[guessAttempt][character]), - focusedContainerColor = mapLetterStatusToBackgroundColor(boardStatus[guessAttempt][character]), - unfocusedContainerColor = mapLetterStatusToBackgroundColor(boardStatus[guessAttempt][character]), - disabledContainerColor = mapLetterStatusToBackgroundColor(boardStatus[guessAttempt][character]), - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Transparent, - ), - ) + Button(onClick = { wordMasterService.resetGame() }) { + Text("New Game") + } - if (guessAttempt == 0 && character == 0) { - DisposableEffect(Unit) { - cellRequesters[0][0].requestFocus() - onDispose { } - } - } - } + if (lastGuessCorrect) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { }, + title = { Text("You win!") }, + text = { Text("Great job guessing the word.") }, + confirmButton = { + Button(onClick = { wordMasterService.resetGame() }) { + Text("OK") } } - } + ) + } + } +} - Spacer(Modifier.height(16.dp)) +@Composable +private fun LetterTile(letter: String, status: LetterStatus) { + Box( + Modifier + .padding(3.dp) + .size(52.dp) + .clip(RoundedCornerShape(8.dp)) + .background(mapLetterStatusToBackgroundColor(status)) + .border(1.5.dp, Color.Black.copy(alpha = 0.3f), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = letter, + color = mapLetterStatusToTextColor(status), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + } +} - if (revealedAnswer != null) { - Text( - text = "Answer: $revealedAnswer", - style = TextStyle(fontSize = 18.sp, color = MaterialTheme.colorScheme.onSurface) - ) - Spacer(Modifier.height(12.dp)) - } +private val KEYBOARD_ROWS = listOf("QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM") +@Composable +private fun Keyboard( + keyStatus: Map, + onLetter: (String) -> Unit, + onEnter: () -> Unit, + onDelete: () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + KEYBOARD_ROWS.forEachIndexed { index, row -> Row(horizontalArrangement = Arrangement.Center) { - Button(onClick = { - // Only submit and advance focus if the current row is filled - val current = wordMasterService.currentGuessAttempt - var filled = true - for (c in 0 until WordMasterService.NUMBER_LETTERS) { - if (boardGuesses[current][c].isEmpty()) { filled = false; break } - } - if (filled) { - wordMasterService.checkGuess() - // Move focus to next row's first cell - focusManager.moveFocus(FocusDirection.Next) - } - }) { - Text("Guess") + if (index == KEYBOARD_ROWS.size - 1) { + KeyButton("ENTER", onClick = onEnter, flexWidth = true) } - Spacer(Modifier.width(16.dp)) - Button(onClick = { - wordMasterService.resetGame() - cellRequesters[0][0].requestFocus() - }) { - Text("New Game") + row.forEach { char -> + val letter = char.toString() + KeyButton( + label = letter, + onClick = { onLetter(letter) }, + status = keyStatus[letter] ?: LetterStatus.UNGUESSED + ) + } + if (index == KEYBOARD_ROWS.size - 1) { + KeyButton("DEL", onClick = onDelete, flexWidth = true) } - } - - if (lastGuessCorrect) { - androidx.compose.material3.AlertDialog( - onDismissRequest = { /* keep dialog until OK pressed */ }, - title = { Text("You win!") }, - text = { Text("Great job guessing the word.") }, - confirmButton = { - Button(onClick = { - wordMasterService.resetGame() - // Re-focus first cell after reset - cellRequesters[0][0].requestFocus() - }) { - Text("OK") - } - } - ) } } } +} + +@Composable +private fun KeyButton( + label: String, + onClick: () -> Unit, + status: LetterStatus = LetterStatus.UNGUESSED, + flexWidth: Boolean = false +) { + val background = if (status == LetterStatus.UNGUESSED) { + Color(0xFFD3D6DA) + } else { + mapLetterStatusToBackgroundColor(status) + } + val textColor = if (status == LetterStatus.UNGUESSED) Color.Black else mapLetterStatusToTextColor(status) + Box( + Modifier + .padding(2.dp) + .height(48.dp) + .then(if (flexWidth) Modifier.width(52.dp) else Modifier.width(32.dp)) + .clip(RoundedCornerShape(6.dp)) + .background(background) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + color = textColor, + fontSize = if (flexWidth) 11.sp else 15.sp, + fontWeight = FontWeight.Bold + ) + } } fun mapLetterStatusToBackgroundColor(letterStatus: LetterStatus): Color { return when (letterStatus) { LetterStatus.UNGUESSED -> Color.White - LetterStatus.CORRECT_POSITION -> Color(0xFF008000) + LetterStatus.CORRECT_POSITION -> Color(0xFF2E7D32) LetterStatus.INCORRECT_POSITION -> Color(0xFF9B870C) - LetterStatus.NOT_IN_WORD -> Color.Gray + LetterStatus.NOT_IN_WORD -> Color(0xFF787C7E) } } diff --git a/build.gradle.kts b/build.gradle.kts index 81f6150..df6c458 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ plugins { alias(libs.plugins.androidApplication) apply false - alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.androidKmpLibrary) apply false alias(libs.plugins.jetbrainsCompose) apply false alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.ksp) apply false diff --git a/compose-desktop/src/main/kotlin/main.kt b/compose-desktop/src/main/kotlin/main.kt index 839e143..c24fddc 100644 --- a/compose-desktop/src/main/kotlin/main.kt +++ b/compose-desktop/src/main/kotlin/main.kt @@ -1,35 +1,42 @@ +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.* -import androidx.compose.material.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color.Companion.Black -import androidx.compose.ui.graphics.Color.Companion.Gray -import androidx.compose.ui.graphics.Color.Companion.Transparent import androidx.compose.ui.graphics.Color.Companion.White import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.key.utf16CodePoint +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.* -import dev.johnoreilly.wordmaster.shared.WordMasterService +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.singleWindowApplication import dev.johnoreilly.wordmaster.shared.LetterStatus +import dev.johnoreilly.wordmaster.shared.WordMasterService fun main() = singleWindowApplication( title = "WordMaster KMP", - state = WindowState(size = DpSize(460.dp, 700.dp)) + state = WindowState(size = DpSize(560.dp, 760.dp)) ) { WordMasterView() } @@ -41,144 +48,93 @@ fun WordMasterView() { val boardGuesses by wordMasterService.boardGuesses.collectAsState() val boardStatus by wordMasterService.boardStatus.collectAsState() + val keyStatus by wordMasterService.keyStatus.collectAsState() val revealedAnswer by wordMasterService.revealedAnswer.collectAsState() val lastGuessCorrect by wordMasterService.lastGuessCorrect.collectAsState() - - val focusManager = LocalFocusManager.current - // FocusRequesters for every cell to precisely control focus navigation within rows - val cellRequesters = remember { List(WordMasterService.MAX_NUMBER_OF_GUESSES) { List(WordMasterService.NUMBER_LETTERS) { FocusRequester() } } } - - // Ensure focus shifts to the first cell of the current row after a guess submission/recomposition - val currentAttempt = wordMasterService.currentGuessAttempt - LaunchedEffect(currentAttempt) { - if (currentAttempt in 0 until WordMasterService.MAX_NUMBER_OF_GUESSES) { - cellRequesters[currentAttempt][0].requestFocus() + val guessError by wordMasterService.guessError.collectAsState() + + val rootFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { rootFocus.requestFocus() } + + // Horizontal shake offset applied to the active row when a guess is rejected. + val shakeOffset = remember { Animatable(0f) } + LaunchedEffect(guessError) { + if (guessError == null) return@LaunchedEffect + val shift = 16f + for (step in listOf(-shift, shift, -shift, shift, -shift / 2, shift / 2, 0f)) { + shakeOffset.animateTo(step) } + wordMasterService.clearGuessError() } - Box(Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { - - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.onKeyEvent { - if (it.key == Key.Enter) { - // Submit only if current row is fully filled - val current = wordMasterService.currentGuessAttempt - var filled = true - for (c in 0 until WordMasterService.NUMBER_LETTERS) { - if (boardGuesses[current][c].isEmpty()) { filled = false; break } - } - if (filled) { - wordMasterService.checkGuess() - // Move focus explicitly to next row’s first cell - val nextRow = current + 1 - if (nextRow < WordMasterService.MAX_NUMBER_OF_GUESSES) { - cellRequesters[nextRow][0].requestFocus() + Box( + Modifier + .fillMaxSize() + .padding(16.dp) + .focusRequester(rootFocus) + .focusable() + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + when (event.key) { + Key.Enter, Key.NumPadEnter -> { + wordMasterService.submitGuess(); true } - true - } else false - } else { - false - } - }) { + Key.Backspace, Key.Delete -> { + wordMasterService.removeLetter(); true + } + else -> { + val char = event.utf16CodePoint.toChar() + if (char.isLetter()) { + wordMasterService.addLetter(char.toString()); true + } else false + } + } + }, + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { for (guessAttempt in 0 until WordMasterService.MAX_NUMBER_OF_GUESSES) { - Row(horizontalArrangement = Arrangement.SpaceBetween) { - + val rowModifier = if (guessAttempt == wordMasterService.currentGuessAttempt) { + Modifier.offset { IntOffset(shakeOffset.value.toInt(), 0) } + } else { + Modifier + } + Row(rowModifier, horizontalArrangement = Arrangement.Center) { for (character in 0 until WordMasterService.NUMBER_LETTERS) { - Column( - Modifier.padding(4.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - - var modifier = Modifier - .padding(2.dp) - .width(64.dp) - .height(64.dp) - modifier = modifier.focusRequester(cellRequesters[guessAttempt][character]) - - TextField( - enabled = guessAttempt == wordMasterService.currentGuessAttempt, - value = boardGuesses[guessAttempt][character], - onValueChange = { - if (guessAttempt == wordMasterService.currentGuessAttempt) { - val capped = it.take(1).uppercase() - val current = boardGuesses[guessAttempt][character] - if (capped != current) { - wordMasterService.setGuess( - guessAttempt, - character, - capped - ) - if (capped.isNotEmpty() && character < WordMasterService.NUMBER_LETTERS - 1) { - // Advance within the row only - focusManager.moveFocus(FocusDirection.Next) - } - } - } - }, - modifier = modifier.border(1.dp, Black.copy(alpha = 0.6f), RoundedCornerShape(10.dp)).onKeyEvent { - if (it.key == Key.Backspace && guessAttempt == wordMasterService.currentGuessAttempt) { - val currentVal = boardGuesses[guessAttempt][character] - if (currentVal.isEmpty() && character > 0) { - cellRequesters[guessAttempt][character - 1].requestFocus() - true - } else false - } else false - }, - singleLine = true, - textStyle = TextStyle(fontSize = 20.sp, textAlign = TextAlign.Center), - colors = TextFieldDefaults.textFieldColors( - textColor = mapLetterStatusToTextColor(boardStatus[guessAttempt][character]), - backgroundColor = mapLetterStatusToBackgroundColor(boardStatus[guessAttempt][character]), - disabledTextColor = mapLetterStatusToTextColor(boardStatus[guessAttempt][character]), - unfocusedIndicatorColor = Transparent, - focusedIndicatorColor = Transparent, - disabledIndicatorColor = Transparent, - ) - ) - - if (guessAttempt == 0 && character == 0) { - DisposableEffect(Unit) { - cellRequesters[0][0].requestFocus() - onDispose { } - } - } - } + LetterTile( + letter = boardGuesses[guessAttempt][character], + status = boardStatus[guessAttempt][character] + ) } } } Spacer(Modifier.height(16.dp)) + guessError?.let { + Text(it, fontSize = 16.sp, color = Color(0xFFB00020), fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + } + if (revealedAnswer != null) { - Text("Answer: ${'$'}revealedAnswer", style = TextStyle(fontSize = 18.sp)) + Text("Answer: $revealedAnswer", fontSize = 18.sp) Spacer(Modifier.height(12.dp)) } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - Button(onClick = { - // Only submit if current row is filled - val current = wordMasterService.currentGuessAttempt - var filled = true - for (c in 0 until WordMasterService.NUMBER_LETTERS) { - if (boardGuesses[current][c].isEmpty()) { filled = false; break } - } - if (filled) { - wordMasterService.checkGuess() - // Move focus explicitly to next row’s first cell - val nextRow = current + 1 - if (nextRow < WordMasterService.MAX_NUMBER_OF_GUESSES) { - cellRequesters[nextRow][0].requestFocus() - } - } - }) { - Text("Guess") - } - Spacer(Modifier.width(16.dp)) - Button(onClick = { - wordMasterService.resetGame() - cellRequesters[0][0].requestFocus() - }) { - Text("New Game") - } + Keyboard( + keyStatus = keyStatus, + onLetter = { wordMasterService.addLetter(it) }, + onEnter = { wordMasterService.submitGuess() }, + onDelete = { wordMasterService.removeLetter() } + ) + + Spacer(Modifier.height(12.dp)) + + Button(onClick = { + wordMasterService.resetGame() + rootFocus.requestFocus() + }) { + Text("New Game") } if (lastGuessCorrect) { @@ -189,7 +145,7 @@ fun WordMasterView() { confirmButton = { Button(onClick = { wordMasterService.resetGame() - cellRequesters[0][0].requestFocus() + rootFocus.requestFocus() }) { Text("OK") } @@ -198,7 +154,86 @@ fun WordMasterView() { } } } +} +@Composable +private fun LetterTile(letter: String, status: LetterStatus) { + Box( + Modifier + .padding(3.dp) + .size(60.dp) + .clip(RoundedCornerShape(8.dp)) + .background(mapLetterStatusToBackgroundColor(status)) + .border(1.5.dp, Black.copy(alpha = 0.3f), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = letter, + color = mapLetterStatusToTextColor(status), + fontSize = 26.sp, + fontWeight = FontWeight.Bold + ) + } +} + +private val KEYBOARD_ROWS = listOf("QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM") + +@Composable +private fun Keyboard( + keyStatus: Map, + onLetter: (String) -> Unit, + onEnter: () -> Unit, + onDelete: () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + KEYBOARD_ROWS.forEachIndexed { index, row -> + Row(horizontalArrangement = Arrangement.Center) { + if (index == KEYBOARD_ROWS.size - 1) { + KeyButton("ENTER", onClick = onEnter, flexWidth = true) + } + row.forEach { char -> + val letter = char.toString() + KeyButton( + label = letter, + onClick = { onLetter(letter) }, + status = keyStatus[letter] ?: LetterStatus.UNGUESSED + ) + } + if (index == KEYBOARD_ROWS.size - 1) { + KeyButton("DEL", onClick = onDelete, flexWidth = true) + } + } + } + } +} + +@Composable +private fun KeyButton( + label: String, + onClick: () -> Unit, + status: LetterStatus = LetterStatus.UNGUESSED, + flexWidth: Boolean = false +) { + val background = if (status == LetterStatus.UNGUESSED) Color(0xFFD3D6DA) else mapLetterStatusToBackgroundColor(status) + val textColor = if (status == LetterStatus.UNGUESSED) Black else mapLetterStatusToTextColor(status) + + Box( + Modifier + .padding(2.dp) + .height(52.dp) + .then(if (flexWidth) Modifier.width(56.dp) else Modifier.width(38.dp)) + .clip(RoundedCornerShape(6.dp)) + .background(background) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + color = textColor, + fontSize = if (flexWidth) 12.sp else 16.sp, + fontWeight = FontWeight.Bold + ) + } } fun mapLetterStatusToBackgroundColor(letterStatus: LetterStatus): Color { @@ -206,7 +241,7 @@ fun mapLetterStatusToBackgroundColor(letterStatus: LetterStatus): Color { LetterStatus.UNGUESSED -> White LetterStatus.CORRECT_POSITION -> Color(0xFF2E7D32) LetterStatus.INCORRECT_POSITION -> Color(0xFF9B870C) - LetterStatus.NOT_IN_WORD -> Gray + LetterStatus.NOT_IN_WORD -> Color(0xFF787C7E) } } @@ -214,7 +249,7 @@ fun mapLetterStatusToTextColor(letterStatus: LetterStatus): Color { return when (letterStatus) { LetterStatus.UNGUESSED -> Black LetterStatus.CORRECT_POSITION -> White - LetterStatus.INCORRECT_POSITION -> Black + LetterStatus.INCORRECT_POSITION -> White LetterStatus.NOT_IN_WORD -> White } } diff --git a/gradle.properties b/gradle.properties index fd28f15..f558dc2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,12 +4,10 @@ org.gradle.parallel=true org.gradle.caching=true org.gradle.configureondemand=true org.gradle.daemon=true -org.gradle.unsafe.configuration-cache=true +org.gradle.configuration-cache=true kotlin.code.style=official android.useAndroidX=true -kotlin.native.binary.memoryModel=experimental kotlin.mpp.stability.nowarn=true -kotlin.mpp.androidSourceSetLayoutVersion=2 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 18ed623..6d9a98a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,20 +1,20 @@ [versions] -kotlin = "2.2.0" -ksp = "2.2.0-2.0.2" -kotlinx-coroutines = "1.10.2" +kotlin = "2.4.10" +ksp = "2.3.10" +kotlinx-coroutines = "1.11.0" -agp = "8.12.0" -android-compileSdk = "36" +agp = "9.1.1" +android-compileSdk = "37" android-minSdk = "24" -android-targetSdk = "36" -androidx-activityCompose = "1.10.1" -androidxComposeBom = "2025.08.00" -compose-plugin = "1.8.2" -kmp-nativecoroutines = "1.0.0-ALPHA-45" -okio = "3.16.0" +android-targetSdk = "37" +androidx-activityCompose = "1.13.0" +androidxComposeBom = "2026.06.01" +compose-plugin = "1.11.1" +kmp-nativecoroutines = "1.0.5" +okio = "3.17.0" junit = "4.13.2" -androidxLifecycle = "2.8.6" +androidxLifecycle = "2.11.0" [libraries] junit = { module = "junit:junit", version.ref = "junit" } @@ -35,7 +35,7 @@ okio = { module = "com.squareup.okio:okio", version.ref = "okio" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } -androidLibrary = { id = "com.android.library", version.ref = "agp" } +androidKmpLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index f6b961f..b1b8ef5 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dbf75c6..a9db115 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,9 @@ -#Sat Jan 21 13:46:25 GMT 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index cccdd3d..249efbb 100755 --- a/gradlew +++ b/gradlew @@ -1,78 +1,128 @@ -#!/usr/bin/env sh +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,92 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" fi +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index e95643d..8508ef6 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,84 +1,82 @@ -@if "%DEBUG%" == "" @echo off +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 -:mainEnd -if "%OS%"=="Windows_NT" endlocal +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index f9d3506..cc37059 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -406,7 +406,7 @@ repositoryURL = "https://github.com/rickclephas/KMP-NativeCoroutines.git"; requirement = { kind = exactVersion; - version = "1.0.0-ALPHA-4"; + version = "1.0.5"; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 39f5e54..e54c510 100644 --- a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/rickclephas/KMP-NativeCoroutines.git", "state" : { - "revision" : "bb6e5ea0504db706ef52577a66861c015daf2265", - "version" : "1.0.0-ALPHA-4" + "revision" : "94b7047bfc08061706807f594120a7de810e9901", + "version" : "1.0.5" } }, { diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index efd795c..38f7352 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -5,146 +5,106 @@ import KMPNativeCoroutinesAsync struct ContentView: View { @StateObject private var viewModel = ViewModel() - // Focus handling for per-cell focus movement - private struct FocusPos: Hashable { let row: Int; let col: Int } - @FocusState private var focusedPos: FocusPos? @State private var showWinAlert: Bool = false - + @State private var shakeTrigger: CGFloat = 0 + + private let keyboardRows = ["QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"] + var body: some View { NavigationView { VStack(spacing: 16) { - ForEach(0 ..< viewModel.getMaxNumberGuesses(), id: \.self) { guessNumber in - HStack(spacing: 8) { - ForEach(0 ..< viewModel.getMaxNumberLetters(), id: \.self) { character in - let guessBinding = Binding( - get: { viewModel.getGuess(guessAttempt: guessNumber, character: character) }, - set: { newValue in - // Force uppercase and limit to first character - let upper = newValue.uppercased() - let capped = String(upper.prefix(1)) - - let previous = viewModel.getGuess(guessAttempt: guessNumber, character: character) - if capped != previous { - viewModel.setGuess(guessAttempt: guessNumber, character: character, guess: capped) - - // Move focus to the next cell when a single character is entered - if !capped.isEmpty { - let nextCol = character + 1 - if nextCol < viewModel.getMaxNumberLetters() { - // Advance to next column in same row - DispatchQueue.main.async { - focusedPos = FocusPos(row: guessNumber, col: nextCol) - } - } - } else { - // If we deleted the last character in this cell, move back to previous cell in same row - if !previous.isEmpty && character > 0 { - DispatchQueue.main.async { - focusedPos = FocusPos(row: guessNumber, col: character - 1) - } - } - } - } - } - ) - - TextField("", text: guessBinding) - .textInputAutocapitalization(.characters) - .disableAutocorrection(true) - .font(.system(size: 20, weight: .semibold, design: .monospaced)) - .multilineTextAlignment(.center) - .frame(width: 56, height: 56) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(viewModel.getLetterStatusBackgroundColor(guessAttempt: guessNumber, character: character)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.black.opacity(0.6), lineWidth: 1) - ) - .focused($focusedPos, equals: FocusPos(row: guessNumber, col: character)) - .submitLabel(.done) - .onSubmit { - // If current row is fully filled, trigger Guess - if guessNumber == viewModel.getCurrentGuessAttempt() { - let maxLetters = viewModel.getMaxNumberLetters() - var filled = true - for col in 0.. Void + + var body: some View { + Button(action: action) { + Text(label) + .font(.system(size: label.count > 1 ? 12 : 16, weight: .bold)) + .foregroundColor(foreground) + .frame(width: width, height: 48) + .background( + RoundedRectangle(cornerRadius: 6).fill(background) + ) + } + .buttonStyle(.plain) + } +} + +// Horizontal shake applied to the active row when a guess is rejected. +private struct ShakeEffect: GeometryEffect { + var travelDistance: CGFloat = 8 + var shakesPerUnit = 3 + var animatableData: CGFloat + + func effectValue(size: CGSize) -> ProjectionTransform { + let translation = travelDistance * sin(animatableData * .pi * CGFloat(shakesPerUnit)) + return ProjectionTransform(CGAffineTransform(translationX: translation, y: 0)) + } +} diff --git a/iosApp/iosApp/ViewModel.swift b/iosApp/iosApp/ViewModel.swift index 2422fe5..e7a6dd8 100644 --- a/iosApp/iosApp/ViewModel.swift +++ b/iosApp/iosApp/ViewModel.swift @@ -9,19 +9,20 @@ class ViewModel: ObservableObject { private let wordMasterService: WordMasterService @Published public var boardStatus: [[LetterStatus]] = [] @Published public var boardGuesses: [[String]] = [] + @Published public var keyStatus: [String: LetterStatus] = [:] @Published public var revealedAnswer: String? = nil @Published public var lastGuessCorrect: Bool = false - + @Published public var guessError: String? = nil + init() { let wordsPath = Bundle.main.path(forResource: "words", ofType: "txt") ?? "" wordMasterService = WordMasterService(wordsFilePath: wordsPath) - + Task { do { let stream = asyncSequence(for: wordMasterService.boardStatus) for try await data in stream { self.boardStatus = data as! [[LetterStatus]] - print(boardStatus) } } catch { print("Failed with error: \(error)") @@ -32,12 +33,20 @@ class ViewModel: ObservableObject { let stream = asyncSequence(for: wordMasterService.boardGuesses) for try await data in stream { self.boardGuesses = data as! [[String]] - print(boardGuesses) } } catch { print("Failed with error: \(error)") } - + } + Task { + do { + let stream = asyncSequence(for: wordMasterService.keyStatus) + for try await data in stream { + self.keyStatus = data as! [String: LetterStatus] + } + } catch { + print("Failed with error: \(error)") + } } Task { do { @@ -59,6 +68,16 @@ class ViewModel: ObservableObject { print("Failed with error: \(error)") } } + Task { + do { + let stream = asyncSequence(for: wordMasterService.guessError) + for try await data in stream { + self.guessError = data + } + } catch { + print("Failed with error: \(error)") + } + } } func getMaxNumberGuesses() -> Int { @@ -72,10 +91,6 @@ class ViewModel: ObservableObject { func getCurrentGuessAttempt() -> Int { return Int(wordMasterService.currentGuessAttempt) } - - func setGuess(guessAttempt: Int, character: Int, guess: String) { - wordMasterService.setGuess(guessAttempt: Int32(guessAttempt), character: Int32(character), guess: guess) - } func getGuess(guessAttempt: Int, character: Int) -> String { if (!boardGuesses.isEmpty) { @@ -85,35 +100,61 @@ class ViewModel: ObservableObject { } } - func getLetterStatusBackgroundColor(guessAttempt: Int, character: Int) -> Color { + func getLetterStatus(guessAttempt: Int, character: Int) -> LetterStatus { if (boardStatus.count > 0) { - let status = boardStatus[guessAttempt][character] - - var color: Color = Color.white - switch status { - case .unguessed: - color = .white - case .correctPosition: - color = .green - case .notInWord: - color = .gray - case .incorrectPosition: - color = .yellow - default: - color = .white - } - return color + return boardStatus[guessAttempt][character] } else { - return .white + return .unguessed + } + } + + func backgroundColor(for status: LetterStatus) -> Color { + switch status { + case .correctPosition: return Color(red: 0.18, green: 0.49, blue: 0.20) + case .incorrectPosition: return Color(red: 0.61, green: 0.53, blue: 0.05) + case .notInWord: return Color(red: 0.47, green: 0.49, blue: 0.49) + default: return .white + } + } + + func textColor(for status: LetterStatus) -> Color { + switch status { + case .unguessed: return .black + default: return .white + } + } + + // Colour for an on-screen keyboard key (unguessed keys use a light grey). + func keyBackgroundColor(_ letter: String) -> Color { + let status = keyStatus[letter] ?? .unguessed + if status == .unguessed { + return Color(red: 0.83, green: 0.84, blue: 0.85) } + return backgroundColor(for: status) } - func checkGuess() { - wordMasterService.checkGuess() + func keyTextColor(_ letter: String) -> Color { + let status = keyStatus[letter] ?? .unguessed + return status == .unguessed ? .black : .white } - + + func addLetter(_ letter: String) { + wordMasterService.addLetter(letter: letter) + } + + func removeLetter() { + wordMasterService.removeLetter() + } + + func submitGuess() { + wordMasterService.submitGuess() + } + + func clearGuessError() { + wordMasterService.clearGuessError() + } + func newGame() { wordMasterService.resetGame() } } - diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f107282..e1f563c 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,12 +1,21 @@ plugins { kotlin("multiplatform") - id("com.android.library") + alias(libs.plugins.androidKmpLibrary) alias(libs.plugins.ksp) alias(libs.plugins.kmpNativeCoroutines) } kotlin { - androidTarget() + android { + namespace = "dev.johnoreilly.wordmaster.shared" + + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } jvm() listOf( @@ -32,19 +41,6 @@ kotlin { } } -android { - namespace = "dev.johnoreilly.wordmaster.shared" - - compileSdk = libs.versions.android.compileSdk.get().toInt() - defaultConfig { - minSdk = libs.versions.android.minSdk.get().toInt() - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } -} - kotlin.sourceSets.all { languageSettings.optIn("kotlin.experimental.ExperimentalObjCName") } diff --git a/shared/src/commonMain/kotlin/dev/johnoreilly/wordmaster/shared/WordMasterService.kt b/shared/src/commonMain/kotlin/dev/johnoreilly/wordmaster/shared/WordMasterService.kt index 12b07f4..7b2634d 100644 --- a/shared/src/commonMain/kotlin/dev/johnoreilly/wordmaster/shared/WordMasterService.kt +++ b/shared/src/commonMain/kotlin/dev/johnoreilly/wordmaster/shared/WordMasterService.kt @@ -9,7 +9,6 @@ import okio.Path.Companion.toPath import dev.johnoreilly.wordmaster.shared.LetterStatus.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope -import kotlinx.coroutines.flow.StateFlow import okio.SYSTEM @@ -23,6 +22,7 @@ class WordMasterService(wordsFilePath: String) { val coroutineScope: CoroutineScope = MainScope() private val validWords = mutableListOf() + private val validWordsSet = mutableSetOf() var answer = "" var currentGuessAttempt = 0 @@ -39,6 +39,14 @@ class WordMasterService(wordsFilePath: String) { @NativeCoroutines val lastGuessCorrect: MutableStateFlow = MutableStateFlow(false) + // Best-known status for each letter typed so far, used to colour the on-screen keyboard. + @NativeCoroutines + val keyStatus: MutableStateFlow> = MutableStateFlow(emptyMap()) + + // Transient message shown to the user when a guess is rejected (e.g. too short / not a word). + @NativeCoroutines + val guessError: MutableStateFlow = MutableStateFlow(null) + init { println("wordsFilePath = $wordsFilePath") @@ -52,6 +60,8 @@ class WordMasterService(wordsFilePath: String) { println("answer! = $answer") revealedAnswer.value = null lastGuessCorrect.value = false + keyStatus.value = emptyMap() + guessError.value = null // set default values for guesses/letter status info val newBoardStatus = arrayListOf>() @@ -71,6 +81,56 @@ class WordMasterService(wordsFilePath: String) { boardGuesses.value = newBoardGuesses } + private fun isGameFinished(): Boolean = + lastGuessCorrect.value || currentGuessAttempt >= MAX_NUMBER_OF_GUESSES + + fun isValidWord(word: String): Boolean = validWordsSet.contains(word.uppercase()) + + // Append a letter to the next empty cell of the current guess row. + fun addLetter(letter: String) { + if (isGameFinished()) return + val character = letter.uppercase().take(1) + if (character.isEmpty()) return + + val row = boardGuesses.value[currentGuessAttempt] + val column = row.indexOfFirst { it.isEmpty() } + if (column == -1) return + + setGuess(currentGuessAttempt, column, character) + } + + // Clear the last filled cell of the current guess row. + fun removeLetter() { + if (isGameFinished()) return + + val row = boardGuesses.value[currentGuessAttempt] + val column = row.indexOfLast { it.isNotEmpty() } + if (column == -1) return + + setGuess(currentGuessAttempt, column, "") + } + + fun clearGuessError() { + guessError.value = null + } + + // Validate the current row and, if it's a legal word, evaluate it. + fun submitGuess() { + if (isGameFinished()) return + + val currentGuess = boardGuesses.value[currentGuessAttempt].joinToString("") + if (currentGuess.length < NUMBER_LETTERS) { + guessError.value = "Not enough letters" + return + } + if (!isValidWord(currentGuess)) { + guessError.value = "Not in word list" + return + } + guessError.value = null + checkGuess() + } + fun setGuess(guessAttempt: Int, character: Int, guess: String) { // need to make deep copy to trigger MutableStateFlow to emit update val currentBoardGuesses = boardGuesses.value @@ -94,6 +154,8 @@ class WordMasterService(wordsFilePath: String) { currentStatusCopy[currentGuessAttempt] = status boardStatus.value = currentStatusCopy + updateKeyStatus(currentGuess, status) + val isCorrect = status.all { it == CORRECT_POSITION } if ( isCorrect ) { lastGuessCorrect.value = true @@ -107,6 +169,27 @@ class WordMasterService(wordsFilePath: String) { } } + // Merge the latest guess result into the per-letter keyboard status, only ever upgrading + // (NOT_IN_WORD -> INCORRECT_POSITION -> CORRECT_POSITION). + private fun updateKeyStatus(guess: String, status: ArrayList) { + val newKeyStatus = keyStatus.value.toMutableMap() + guess.forEachIndexed { index, char -> + val letter = char.toString() + val newStatus = status[index] + if (statusRank(newStatus) > statusRank(newKeyStatus[letter])) { + newKeyStatus[letter] = newStatus + } + } + keyStatus.value = newKeyStatus + } + + private fun statusRank(status: LetterStatus?): Int = when (status) { + CORRECT_POSITION -> 3 + INCORRECT_POSITION -> 2 + NOT_IN_WORD -> 1 + else -> 0 + } + private fun checkWord(guess: String): ArrayList { val letterStatusList = arrayListOf(NOT_IN_WORD, NOT_IN_WORD, NOT_IN_WORD, NOT_IN_WORD, NOT_IN_WORD) @@ -143,6 +226,7 @@ class WordMasterService(wordsFilePath: String) { while (true) { val word = this.readUtf8Line() ?: break validWords.add(word) + validWordsSet.add(word.uppercase()) } } } @@ -151,4 +235,4 @@ class WordMasterService(wordsFilePath: String) { const val NUMBER_LETTERS = 5 const val MAX_NUMBER_OF_GUESSES = 6 } -} \ No newline at end of file +}