From 1fc944ad51be2f6ecf789a967c75c8dd254f6838 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sat, 8 Jan 2022 22:06:38 +0100 Subject: [PATCH 01/19] [Mosaic] Bootstrap Compose Mosaic module https://github.com/JakeWharton/mosaic --- .github/workflows/Build.yml | 3 ++- build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + settings.gradle.kts | 1 + wordle-compose-mosaic/build.gradle.kts | 10 +++++++++ .../opatry/game/wordle/wordleComposeMosaic.kt | 21 +++++++++++++++++++ 6 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 wordle-compose-mosaic/build.gradle.kts create mode 100644 wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/wordleComposeMosaic.kt diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml index 46c9bbe..4c1c6f7 100644 --- a/.github/workflows/Build.yml +++ b/.github/workflows/Build.yml @@ -24,7 +24,7 @@ jobs: - name: Build run: | ./syncComposeSharedSources.sh - ./gradlew --no-daemon compileReleaseSources + ./gradlew --no-daemon compileReleaseSources assembleDist - name: Test run: ./gradlew --no-daemon test @@ -51,5 +51,6 @@ jobs: -Pplaystore.keystore.password="$KEYSTORE_PASSWORD" \ -Pplaystore.keystore.key_password="$KEYSTORE_KEY_PASSWORD" \ :wordle-compose-desktop:assembleDist \ + :wordle-compose-mosaic:assembleDist \ :wordle-compose-android:assembleRelease \ -x lint diff --git a/build.gradle.kts b/build.gradle.kts index d08b960..2fa29a0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,7 @@ plugins { alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.jetbrains.kotlin.jvm) apply false alias(libs.plugins.jetbrains.compose) apply false + alias(libs.plugins.mosaic) apply false } allprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ceb6ea1..b548d65 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,3 +47,4 @@ android-library = { id = "com.android.library", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose" } +mosaic = { id = "com.jakewharton.mosaic", version = "0.9.1" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index ee3e223..05aa3cf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,3 +13,4 @@ include("word-data") include("wordle-ascii-cli") include("wordle-compose-desktop") include("wordle-compose-android") +include("wordle-compose-mosaic") diff --git a/wordle-compose-mosaic/build.gradle.kts b/wordle-compose-mosaic/build.gradle.kts new file mode 100644 index 0000000..1d13467 --- /dev/null +++ b/wordle-compose-mosaic/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(libs.plugins.jetbrains.kotlin.jvm) + alias(libs.plugins.mosaic) + application +} + +dependencies { + implementation(project(":word-data")) + implementation(project(":game-logic")) +} diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/wordleComposeMosaic.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/wordleComposeMosaic.kt new file mode 100644 index 0000000..bdcc15f --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/wordleComposeMosaic.kt @@ -0,0 +1,21 @@ +package net.opatry.game.wordle + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.jakewharton.mosaic.ui.Text +import com.jakewharton.mosaic.runMosaic +import kotlinx.coroutines.delay + +suspend fun main() = runMosaic { + var count by mutableStateOf(0) + + setContent { + Text("The count is: $count") + } + + for (i in 1..20) { + delay(250) + count = i + } +} \ No newline at end of file From 21c5f58f9f5756afb79cbdff49805492620d4c89 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sat, 8 Jan 2022 23:28:05 +0100 Subject: [PATCH 02/19] [Mosaic] Roughly working Mosaic game loop Still needs to press "Enter" twice to refresh display (to be understood and fixed) --- .../game/wordle/mosaic/WordleViewModel.kt | 81 +++++++++ .../game/wordle/mosaic/wordleComposeMosaic.kt | 157 ++++++++++++++++++ .../opatry/game/wordle/ui/AnswerFlagExt.kt | 34 ++++ .../opatry/game/wordle/wordleComposeMosaic.kt | 21 --- 4 files changed, 272 insertions(+), 21 deletions(-) create mode 100644 wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/WordleViewModel.kt create mode 100644 wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt create mode 100644 wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/AnswerFlagExt.kt delete mode 100644 wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/wordleComposeMosaic.kt diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/WordleViewModel.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/WordleViewModel.kt new file mode 100644 index 0000000..7086b4f --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/WordleViewModel.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle.mosaic + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import net.opatry.game.wordle.Answer +import net.opatry.game.wordle.AnswerFlag +import net.opatry.game.wordle.InputState +import net.opatry.game.wordle.State +import net.opatry.game.wordle.WordleRules + +class WordleViewModel(private var rules: WordleRules) { + var state by mutableStateOf(rules.state) + var answer by mutableStateOf("") + private set + var grid by mutableStateOf>(emptyList()) + private set + + init { + updateGrid() + } + + private fun updateGrid() { + val answers = rules.state.answers.toMutableList() + val turn = answers.size + val maxTries = rules.state.maxTries + val wordSize = rules.wordSize + val emptyAnswer = Answer(CharArray(wordSize) { ' ' }, Array(wordSize) { AnswerFlag.NONE }) + repeat(maxTries - turn) { + answers += emptyAnswer + } + grid = answers.toList() + } + + private fun updateAnswer() { + answer = when (val state = rules.state) { + is State.Playing -> "" + is State.Lost -> state.selectedWord + is State.Won -> state.selectedWord + } + } + + fun playWord(word: String) { + val normalized = word.take(5).uppercase() + // TODO indicate error when input isn't valid + if (rules.playWord(normalized) == InputState.VALID) { + updateGrid() + } + updateAnswer() + state = rules.state + } + + fun restart() { + rules = WordleRules(rules.words) + updateGrid() + updateAnswer() + state = rules.state + } +} diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt new file mode 100644 index 0000000..6d0e36e --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle.mosaic + +import androidx.compose.runtime.Composable +import com.jakewharton.mosaic.ui.Color +import com.jakewharton.mosaic.ui.Column +import com.jakewharton.mosaic.ui.Row +import com.jakewharton.mosaic.ui.Text +import com.jakewharton.mosaic.runMosaic +import kotlinx.coroutines.delay +import net.opatry.game.wordle.Answer +import net.opatry.game.wordle.AnswerFlag +import net.opatry.game.wordle.State +import net.opatry.game.wordle.WordleRules +import net.opatry.game.wordle.ui.toEmoji + +private fun StringBuffer.appendClipboardAnswer(answer: Answer) { + answer.flags.map(AnswerFlag::toEmoji).forEach(::append) + append('\n') +} + +fun State.toClipboard(): String { + val buffer = StringBuffer() + buffer.append( + when (this) { + is State.Lost -> "Wordle X/$maxTries\n" + is State.Won -> "Wordle ${answers.size}/$maxTries\n" + else -> "" + } + ) + answers.forEach(buffer::appendClipboardAnswer) + + val clipboard = buffer.toString() + + // FIXME might not be cross platform/portable + // TODO "pbcopy <<< $clipboard".runCommand(File(System.getProperty("user.dir"))) + return clipboard +} + +suspend fun main() = runMosaic { + // TODO check terminal is compatible (eg. IDEA is not!) + var playing = true + val viewModel = WordleViewModel(WordleRules(listOf("Hello", "Great", "Tiles", "Tales"))) + + setContent { + GameScreen(viewModel) + } + + while (playing) { + delay(16) + while (viewModel.state is State.Playing) { + delay(16) + print(" ➡️ Enter a 5 letter english word: ") // FIXME shouldn't be done with compose/mosaic + val word = readLine().toString() // FIXME how to scan with compose/mosaic +// delay(16) + viewModel.playWord(word) + } + + delay(16) + + // TODO +// viewModel.state.toClipboard() +// println("Results copied to clipboard!") + println(viewModel.state.toClipboard()) + + print(" 🔄 Play again? (y/N) ") // FIXME shouldn't be done with compose/mosaic + playing = readLine().toString().equals("y", ignoreCase = true) // FIXME how to scan with compose/mosaic +// delay(16) + if (playing) { + viewModel.restart() + } + } +} + +@Composable +fun GameScreen(viewModel: WordleViewModel) { + Column { + Toolbar() + AnswerPlaceHolder(viewModel.answer) + WordleGrid(viewModel.grid) + } +} + +@Composable +fun Toolbar() { + Text("Wordle") +} + +@Composable +fun AnswerPlaceHolder(answer: String) { + Text(answer) +} + +@Composable +fun WordleGrid(grid: List) { + Column { + grid.forEach { row -> + WordleWordRow(row) + } + } +} + +@Composable +fun WordleWordRow(row: Answer) { + Row { + row.letters.forEachIndexed { index, char -> + WordleCharCell(char, row.flags[index]) + } + } +} + +fun AnswerFlag.cellColors(): Pair = when (this) { + AnswerFlag.NONE -> Color.Black to Color.White + AnswerFlag.PRESENT -> Color.Black to Color.Yellow + AnswerFlag.ABSENT -> Color.White to Color.Black + AnswerFlag.CORRECT -> Color.Black to Color.Green +} + +@Composable +fun WordleCharCell(char: Char, flag: AnswerFlag) { + val (foregroundColor, backgroundColor) = flag.cellColors() + + // TODO AnnotatedString " $char " https://github.com/JakeWharton/mosaic/issues/9 + Column { + Row { + Text(" ") + Text( + " $char ", + color = foregroundColor, + background = backgroundColor + ) + Text(" ") + } + Text(" ") + } +} diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/AnswerFlagExt.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/AnswerFlagExt.kt new file mode 100644 index 0000000..cfc13f2 --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/AnswerFlagExt.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle.ui + +import net.opatry.game.wordle.AnswerFlag + + +val AnswerFlag.toEmoji: String + get() = when (this) { + AnswerFlag.NONE -> "⬛" + AnswerFlag.PRESENT -> "🟨" + AnswerFlag.ABSENT -> "⬜" + AnswerFlag.CORRECT -> "🟩" + } diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/wordleComposeMosaic.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/wordleComposeMosaic.kt deleted file mode 100644 index bdcc15f..0000000 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/wordleComposeMosaic.kt +++ /dev/null @@ -1,21 +0,0 @@ -package net.opatry.game.wordle - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.jakewharton.mosaic.ui.Text -import com.jakewharton.mosaic.runMosaic -import kotlinx.coroutines.delay - -suspend fun main() = runMosaic { - var count by mutableStateOf(0) - - setContent { - Text("The count is: $count") - } - - for (i in 1..20) { - delay(250) - count = i - } -} \ No newline at end of file From a68328fee8533db32aa1b67009030c69669f51af Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sun, 9 Jan 2022 18:32:18 +0100 Subject: [PATCH 03/19] [Mosaic] Update README to showcase Mosaic version --- README.md | 5 +++++ raw/wordle-mosaic.png | Bin 0 -> 17958 bytes 2 files changed, 5 insertions(+) create mode 100644 raw/wordle-mosaic.png diff --git a/README.md b/README.md index 4b3655c..e3e2778 100644 --- a/README.md +++ b/README.md @@ -109,11 +109,16 @@ Congrats! You found the correct answer 🎉: HELLO +### Compose Mosaic mode + +![](raw/wordle-mosaic.png) + ## Tech Stack * [Kotlin](https://kotlinlang.org/) * [Jetbrains Compose for Desktop](https://www.jetbrains.com/lp/compose/) * [Jetpack Compose for Android](https://developer.android.com/jetpack/compose) +* [Mosaic Compose for Console UI](https://github.com/JakeWharton/mosaic) ## Development diff --git a/raw/wordle-mosaic.png b/raw/wordle-mosaic.png new file mode 100644 index 0000000000000000000000000000000000000000..f805825c08907e9619877b50a4ed28341494dfd8 GIT binary patch literal 17958 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%0Amy*y{WS^!iQZv$|sjU_?^GniWr_^u7 zhYQYlCOk=UbZxc@8C)U~|D-&2eE*M|JQ|Je##rgayPR2Ri+0AI^CJSqDwtG zSi;AzbcH}VW5It>I-gn!2!HvXndW$Iv+D*5Or;Th;S?h)=+%Crf{;$^pZ`GpBMQ0+ z#+Wqk{XJpKvSSh&f8=C%5EEky7CsT}Zag@O;lkcTf_)ze|r$T)bU6|<4EP>cp; z$9ghpHH+~~YUDR+NBGa0mt*Gb`^>7spa7C>hV(fNUL$P)K1#~anuQPzd`~ScFgOj% zjy8;7gz2oKWVB{p{hmFNkf+xDXFT2zgVNHWK_6u1ih_s+>RFCU^K$aY_A6}>oa97l zK6H1bemLQkK3NOqced(kw5=uMUqTQ+PiK6mtItpTSVOc~dqZ8$2~Z<)$q5)5pCeOfT0@l3hP_VPiE?c8?oKKb^$8%l zg4*$enpREn-M^Az%AxPcK2rxraCsm97SySm+C%sGY}^oDK{LU*m^&&L`JJ6kW6XuY zRGYvAU6${|-DrmyZwLz{B6r#)JZ&;GwP5YSm)3Gt*-7)zNB&!OJsk-6K!_vtkA-~o zoeVR$qGU}3bP5sE!{8wC886P8kO8OKWW|D4sI&X8UqYK=Ks4w!JgkCc1UBOHuu$6C zs4pnl$*I$2dG3W7BoL(iL`$o;dVjY({;{hh0B1lmm=K>tx)cA~)L${K6wR-B?>EL@ z2q-nb@D--C=Ov?yM~UtKIA|fErG+ouY0ry9w{RomeF5!0a=dLvvlMS!b}Z9oCXA$| zCe4YEK3u&;`Z_dH36G|vap(D*t1b#PT2x$J3jv?ta5wL`V1dW6UdS}%dd$t^dC|j) z)BJTv!A(SdXQtfE_w4;GyZ*EfdICA9(YXw|EQ#+bHCM%Q;0++PD%oY-%Uue1xwepg zw0iyTJfn6Fk+nB|(rT&r$(IH~Ge+NShmz3-_t^B?W!K8yc@YLTEeE@ZQ zsVaD&ndLWhy2T zXgYpW$#jtCGVpD1m8Gx}j1+RcrF(CZ9K5>byJ)Csro=-E+VEyI<8Qp1vkgj> zwX~{+;_=t`Wy_uaU-j=lE6qQPlOWu4tl22}efjpXiI@5AA~*7tIHhea<+7-#*FIA_ znuG^Qga8yka>7*(yY=uY)(bI&qf;lRLN}W}J^nM4*htR(}+bF!k}hghZi~0y(0OSOEKX z7OAM+U28h!^srn8e0+SlkD6EX`y`DklnX51h?VRma1m%RJt>&XR08#W*9ukO%O-q` zn{=(CVpvbQQ@`9ys)nj^BIIMP<2D^%%$mf<$7^hOOh^-j{BR>Oa>wCY@iuQ3Z3G-% zshdA1CnrW?ijbG%?|QfGVH?*BjM~?-)-~Hdeio_aHC(Ou!NeQM1xqtC8UX=;yPMf| zN3Z_H3KKCP+7OHQy4TdtjQe2yvK(neH0bd-^%eR0RcPYLVbQ7uoN|5N=lGk+b_3y` zVvFL&Rq)Ny7!8`n73^Y%KpE%&Z0FO(#kK%HIUz5=6^#SxF*E4l@1-4Xs-pk-gslI~ zULhPzc+k_EH%$S8P^IMvX@OFjjK9BsJk-9(NKhh;r_iYFdL7MjEOVsXsJSP&xUX?1 z)8Od8Z%ENvm=cQ)!K@4w6&2BRqE#3Fqj7OY<93U9%fldHprEG0*X0ipxY(%~LSEID zBdMf|Q4taAK)Na@D~kd=u)3b!pj}04Uz$_fAmF=`x!$50HGB3#FJNRk{PEq{*cp)Y z;xaQw!BJvvOY}58C(HTjw@;oMRGK~kYLIe2FR|q1pqE^A1hI#cSVNz!g#S;q(CLTB zeg8_mlL_FpwF_4OpPTIWr}2|ksrJeBMl7E%E6*>TeY+axicZx9LHk+xSqM2My-2)S z!&>gt^;E^D(?S!ht;^S;Za44#z`gW{*znpwvCLTq6`?N*GX*oU^1GI+S0DCy?LEhhRK{-I>BT!u&``K^Q|DaATR_k0&;Qe8C5 z{}jck)+@B#MMPEIfQ|LYC?+5JQ%Hq2Iq16d<-yahSdu53t!y9T*<|X3`$$ zez=eTXlq1tVr)4|&z%gfxRjK^_1?&E@Y$SCl&BWM)gu}H7AUQLSLGT` zNo+=yx-8$meG7K~4)I(ev#oI+bzpETrG76TrKy5c-Culr3KkgR#t{xCc+NRI-DVt1iqFl^m~0=ax!t<0L1;jMw?*~ z-O~|NEcw^3&G#nrTh1q6-`%c0Vm3M$2iKUT>kfaSHvn7XS#*Go?g8hd`iaWsP2G^{er_as+V!6B|HOZG{jS{{{*t9Z*T$ zbcgcHWy;ITr@K$ezcPV!|5$4`8%hhwAvbw#UsLQrIK=xg!{E{A@p!G9=Ycor@7a#q?P2{HP`=?S1Mhtc zRI(^9J?gB+C^eCr2xS4eM>)3*Xj-xhf%|Xy`Bhe9neQe) zF{#cNRcw=+7}SCsQ@b`z5D!RT&?^Dg04BNLKSKID6HQWEm3BY1W#%^YE=dXEPAyO7p9ym2zut^jVPIe|{r^?ayKJq4)GtBF zarvcZ6>{f2XIXP{)&7_PJQ}cw?u(_0?cZ2~*$9g7-oqn-%KucOKrsjCaUemefqo|w z%dXqZpE0WKP!n3`vpMhuaq$M!-rqocwOsEB7e}DgZQk7826-^hK~WE;H;PIu^X|_7 z@?gGuD1rI!4YWe9&EJPQY?+*yLrje7Q@6OLCO#~K0Rhc`3YMW<;4PsL@^tPc&@tso zAr|svQ7)|MjM-k@1}$YH&=H)07#a9?%OlWj{%w*Akn4|u%6tsIg~;YThUZFNZtlKA zjZ^zQZYl0c=nv4{Yq_Yw1HQRxe29b8iCOiM~gB3O#NNx z)Skt3ks6SVnJQcRDgB~iMQ@O^JB2R5f-4W=^=jFR3J%mGKA`k5jtQTk!^$+b)hKWS zB=2PUv5mjEqbO$t6~5B*P`^MqV`xUlgyM9=E9qyid8|kuYyDCe;C}?1f5XA-3BW7y z?GzN|Jg3#RCwM_1tqi@EJ%B7Ic{rKsfH>W(6ckm`=5|}(ejj}{CYBX=fAb|R&48W~ zr_3nR|5P5J42tU!V2vRWaD53_B=}jAAQ2FR!n4MHtU!$JU30Dkf&l;y^lMQ08w=j5E&DL zXzk9T?Vw$ztG3gU^z|!b&T`H8BP=Ny^z`(iMdJ^J{Dq5Km1X#%NHx;&5tb-1I4X5x zY=2E?;S)&bn69z~{-tZ~Rc!0&k&1|nd{HOTjzwWVpVGJcuONPSbG`7BH!m-5Z>kUr z2(17aB0LKO88fngC9MW*IXS?$V$4T3DJ3h*$2h*~WKbZ(<_^Y-gsq{S2PyXd#67@oE@5tCqt(X8`dJ7^sMd z2nkP~5WH6~>LLe)NZuc&cLM5$20~9x7ayJ@`HA=MNgi-rk$;-z+plg+_t{Qi(XFJW z5s-Se7L1o#zv3GSgAWh)w_siAo-WoP;d3Mh5oKfQDC<_7xm%czdlW^+7Xy~L_>7Ey z5Rp^R?c(R58RhYdlYT05-dTzs% z*`oD4C|sOw+lrZfe+1ybghA1h_b2nw@?>Kz4;N~A3RD$SA>puv8;J2aGl&tukwfGB zbM)uWJWxl0_uvIJq^TUqGF3)!Ld6aDXj&F1WQ~oDcTkf@Gq9|~96w-X;I^OR5Pi7b zK>TTGWi{382bx&C1_F0`I&56HMKReJ%5B6*w5|mzxqmgd3GJh+7Zj z4*vZ4LO>wp&vNq_a4bAyA#ET3p_!X?W6f})9ALlFk^(?j%t#s^Jcq>q_PfthqQ<}& z0pL%y-7MSs`ue`Vn9I79=h4atkb~i~mffe6diwkE@bLbD2=AW2ZUCE~NPVh1fiVG{R$yuWha zn+yS|8kdwrq@A0UWb9kK;u@_caxw}-{=mgTwL6!?;r#nmruRAuCWT-RK#-D-Q6TTr z0s1%{3wnqLcRV}L#S0>&#hCr59k>l@G*mEF{RbCziW*-Cw5^rHj5FEO- zwptzg0^Y~eh3T%SNk*O|u;LDnh;F(ylt3=%9tLvU6|6j8Qd5U=r6aEa;YTK%+|bbQ z%CiNAirmITl3+i;(DO>B%Jgeel7_l^7Xp!C2NC$q!aq(?E9{>$1`JB{_Kj#I>R9=Vv)Q{Nk6=F}hCKsAE7K#`S%BrToL z{xNn*^wS@EFSbJzG=|x`Ii5xu@zgF&&TE|??u+wDuop5R+O>th*+$#eE_G7L41rRq zBr;25(>w>v=f|&K@qzC%wBNvwI#XiAViT3HCS_^K5TIUpB_#*f#`tEZT#fo1Q+b5$ z#p_Ha%j3kUI zKm9u4{Al)K=vUK)fZ@6ks)STXL2hn(9IipeIkLD=y?D<`@wBlKgND zIOPA#4l^sEcXzBgtEw4Ox*~b1WjtK;!MCuxaQa za0v+n3=udtwqFwxib@2^Tmd`e&D_b+F)mrjp*ddC!BJ})&{Y0Yiw6124{Ii7rU@R_ zZ*R^ca(_GXhetnG172o>P(=ia?Dz)==3ij=<6 z4y~Q~u9sC#I{>>pK52yx{0};(m+9apv;Df~%)#ZRdOdK12T+yla_x*EPE+vTWs=9< zcYRlxZE;ONmgpxSB`~skyx2=cpTh(Cj8X$iS{G}fjf}Sh-gZZ#Gjs|g5b`N$G4gZt)3dOuTsl&>vV=Oo2?r;m^J_m4m3 z2nsvS%)_)KI+W+f;6;GmHP6Rpi#q+rqhYash6a~3zQ3bjp|aFdF_iIJjJ1Y?DR-BG z)Z2@aH#3E~wYCEBvJ$#5E}K)e=r~a=SN|mRGx1Paa`ACu4z@836Gd0a7{}LNte+GC zLm!5M@lLWdcgY}>P=XX8K6ib*OU0kSF~C7h89NvmB|1i?adx&!;xKG$g$cjwbuu_4 z=*yfmwaJ7NET<3A(iaF_6E79{{tD$<=l!D+1HU`{gp9p+cu`kb1?vzB5H53yyK-g& zX!6JplS0+cg1V2fmF#uF@$VHEKjou9a=JA6 zXm!G=r4+1xygJCW)XZ%|mcAJ4R*)j_3>cb7Z~v~L3~Z$1{uZeJ@tS7WU*g(DOf+o) zT`+x|IPsWJf!nY9C*nSjSCnDPT3sYH@{D`eql&#Fi-P(T2KkN@(S5g^l-5@hiMQ%g zvMau<#>13ur3sM&G{BISGanQ7vJE#lCKf!xgs(@jHK6PqN@>PjD_{#&jt8e*m(=2^ z$*(hu?{-MwIj)!fQkJ5HeG;(OfV0;Z!Abb%1k8|e7uLiIsQKn1utkC z1%8D5m$4p2Aly;YAJ^ui?3d|UvXa3MxE95_@bE@ws-Ls^|4_B{Xz5e40*TsM0&kfy zq=wG8+h-?J`p0s_8)D*jj>N=>45&pBD!%xDx~2S8E*3JjVjPvnucF_G!I7ev?lvk= zW>8-~xCMASnB^2Kb{G$y$Gi!>-Qm{I7{x zpXWs~&#HahR%~pdgldW4)y;vS!+hsG4WNj(ox?v~voT&cas#`0dU|>XbcAnpGnCA+ z!?K3c>(5fh4q$!v#xaC#ry2gVUK#&sIUD`_69xxhT@@f)0b~^iett=|G+>y|ixpLn zka${SInu?ES~t-9O+KE1o!tuTT>z-}GXU3seT08yYs&MB$ z&w?d-Z7TP+qW!@aa3RAm8UbLw)E^bV*7gQq=ra9U)cQq=YhfOq1Q?{azrDJ36qX$u z1cdYT@=;(vi~=@!ywL&M5d`>}0 z1|0>l+%EcZu|bT2x!Q#3ToNK03l^`gR{I$smjg#(@`-~nJ9mH+7=J&a5P zO%AiOj#JUn-QP1ku(^Y8*&Pp9>pg#;6%{dn4Ih17A!smv;@{IPn63Ez5t!4TFm|9{ z?4k4asC~?5C*#wICE8vvCMhHgK+Rv+DXNQa@2*dPu)6}f0l`HIu|WUv8*)sfte^)0 z0M0sWYZYQ~yNF1Ev{vH1mw_AmbK~vfnmQ&CU=biJC84&~ca>*wW9>Cx4x9q}_T1Mm zn!swlNkPJ43YQ`1VRp3Aijih&$qgbfN2kJ=#jpWeS`8*SfISyhx0i=6X=yn@(*Osf zkbE!pd#_f5s4jV*JOB*>K>03!1a~L$kbrr)!?B)E|4+P%=o%n|sd;(pLf=}}FM>wk z1;C(SI|$fC(6{WhYSixwdVVr^B+PHs)wp2yjJnX}-qa_M6{J}q3v77w$`)W$K-*K> zw*%-LbAW;`B8)%}0?dm5+RFX&WsT0vxZFh_UD(f7?$YU#0*2}rv9hys{8~-DCm?)6 zmy}W`f&x~~SnCILhHPGWiw{3d9=bsL`4cca3R+qOKa<##EFQZP^sSHc{BVcEUNMozOMl`QA79#hH)!A=m$Dt}?1`ua2xhr)22di69O|pZSj0lWQ}b>(WI2vPZYUZC|&XpB=5f zUtZ4#K?@Tpe~SRf?}+Dn%Kr9{tpsJ%$k%F7eR-Y0l}-z2;J4r|#SuC!xMsYA071v; zf9*^eaIOX@%hB3dV}p&`inf~w$xoP^0XOc$mfT06M&{j)kMl-GL^wAuql1R#9nK4x z;1>3gHBcSRdLsywa7i8?Fkv+A4n}R{h~_~mv0SG-~BMT-Azf`+jre>3i@!JbOsKlQqV##Wcf@@0&$wLC_A@Yf!DIe z^A8wlFoRrR4-cRZRb*sjOr3SXHUWNUSxv*^!!7iy0qCfxi}Zz7R83yhv^fZpW5Q%I z*p{U>(5sO?nGPKS?tCuL^27MYWk(n*45JM}qZR6YV3p}ZCmD)vQ(apL`cy71F0exh zv(X3;`Lg!*_Nw*8O}F2mzT?-w0G}lv7$^!h2u$V$9~;q+Ks(1FkcvL2U7N%#qz~v~ z#qc-lGwv*ec$QjQ%NS{M$M?#^mUyU$p|exFb!4cnY+(ObTfi+B7+pBn4k57c^e@!f z!S=K0m;~@F);m>b`~fu(V<|-PIX!)0r^CacQ_u!b-U@+|0;4@aCn@t2XlRl1HC9L~ zvE%}ApthcNV~II~z7yho5RnTL&43jdc>JCteRyIV`1dW$nr`f~ML_AZi6aWw9GS(p zAUI*!2e{?w>!ruA#Y0aGef=R&LbW&c^d2AXVLQ)?i;H30&171zuNnXZq!be~FQDYU znZm@~9LMAyPKcM#L7RKOcxX-Xqq9Viw7wm}`S+R6?hXmZ5i>u+YHSOlUt2|h0teFTvxy3Z)&0|2Nj4d2| z+YfdO0z$KD-X3hopy99v#IQT`S2R%Is=QCk!0wFMZ@xex86Kq18U|H^+_j@=|CB5& zFd*l=1=*ZfrWv#Elf?x7pJfR}GgOrD!DReZ@HpOH8u6;Jbck`Tl|+_oWaZC%4Ly*1E)aRUbG(jLib@oKK=AhMTZ&VQdZ}gz zL6x<#Vk8|kWHlg2YE;{f$9J-368Z2QTc5{8U}4)*Rc7{H==+6dITIDo$&y=o*ba|> zu;;(nvfzdJ%hYc;AF{pbP2GGl?NYLJBUW!%O`dyI2V=SY&jA3{0K)wXQpPhGkGi&uSmA}D;A!acA4OP2mT!N&D zrJ85Zn7IR&==-}^Wm>*lP1jkhg7t#wI=qNe2_~x6uP@^96`b~0&o>qy2=Y~oqs=Fx z@Fc8<`=>e;oBKw?)(8pN%gr7GRZ3^lG7c?PMWYKdW1;&&QI&gp4#d#oarce>$+XA!>$NUUh{gGMDPn53hXk?qI| zqPX|Y2k4Xi3L>R9cD5cHh($^oMxkA3uSr?>-YN)873zFRvikh1{vFoJtZvoP$h!ww z4)hGAH1tuyn&gH#VdbApnOF zJXr&KGX8Kg`L{bqvbv*vPhm-fvoXvAyVq3}ti5$@!p9lV1O6Bm(GbZjjDqtq5y=Ba zj9Dbrzl->;VuA_XrCdc#T#TEZMRx(H<o4$s#d{?t1A!|+ zh4Q1X+xVV^cJ`{b`OGd0y6dUx zs}V$g_K450n`MvBOdW$V)E{7Kv_pR4RxZn`?u+B%<4waFi-m znDS)i_ofGfN49T+rWu5ajk?d}@-)+}yl753O~L}^z3A(Mv}I1SJag&6#*WHa15L(> z-P1y&eX$Vdy!0He)_QVs*u-G4z%2cWZ-mq14-2$VRNEXR*1->7 zte-!G7z)GZR#ve0h4HN2X_-a3lhO*lzYYovDfXK(qSLT78K%z&ZPYGqpo_Wwwa zVU%||Xy@$o5WKs(6r-l4JkQN%_LaztlArWwt<}5jR4HO6=$csN51S%Z8<5C@^eETgq78+b(oM_j7K zPk*hD@`L`o|6(nn!n+|#8*^87cU zae4uq33e}qtEk2XJtHWUC83s`cX{wXMd^F5E(@%U#W33;a0rz_uI_!_2{OZRFBlM| zg7hP7+R-#-2v6bQ5Yk|O7eJ)^Elpp9?bFxZ0^SGhY0?Mx%;9a&8Nt=wCE={#Apg*d zyH#_lAnwrqkex*=)v0O@FAn?_U5v5-H%(>WvoB|B7*<(!QAH@#0zf3$v<+-O@4lx%zCUzikf1AuFF9 z?G}Dp8;L4^7K1y9?w?9v$0WFcJ1{rqLNk{_khpSUuV5l^SaM^ z-2c$OD019%LdWdlQfVgbpP{4uixcN|`Dnf2s!hs}E#r}IjG%{yd*LGFV9&0*@;XL# zBKn#^I9Z7}6c9ZlD&^CliH23*VzuBD?oVb5>^@RL;59<9jBzv1z8o zm&>FjzgS*co@Tsk+oM}^Vz$$-&11U^9~z=Rpo$nQw+wu`>Pk-s%PA}@srP@H?>WJTw;I*j(kut^fb=CR4 zgV`?@A*b?6PAg@US;f}{eqO6>mKl~?k?iD4Ze9ozoJ=}K1ikN`d>85*sbu8uzllqO z8nF=bBJ?Am=wdc;9=fWZ?#d@$3Wnbwy(tNtLg>vmf6QI7TDx1%Uaucycx%j5Sisxf zCb_03y-iiR>$z4%@>tGo#*pf!K0>Ul4IxM>#BPRAoo(~#KcCv@y11Nbi!pY`?$!&j z>)j|ax_v`(ws<_6)$4^cgL%;LSJIBw9IU#{`a1eD5{yRRtH)ueaEo(Z-oL;)!Uq}xpt4S3q z(>+{R5OHz&uYW6%wF;i)1zYcAsSbPKvA&)J)x2)lj3YJO0?Hi8>N{wS}eab2_Mex6Su=DJY15 zz5Rg9&UMj2?Oo>YC4C0{&jeuW_@Wy=B})wCr$>Sy1F)Q z8JhEjLLLu3y={*VF2u_Idd(E>#~E8Kc9nfy6`1TL5PRt=a&Uu7DP(Koooczo=%{)c z(Vo`aJLWGmT6OT`<{sE&gnr?PkGlwxKfBQP=RL9bkNy`xXII#Lz5WOlvNRe8SYRWxJVxXj2yx%9y^5urm!#9_n zYB(?H_8Am*Gt>L?@}Q`^22*4|8i;Z67Aq$foiFP$ZDmF(pzUOI)Dz_1>Hak1`!)5; z%EDQ!-Qy6$thP|QDM4JSTlA?8&i>JlHMnJ-CLN88SG2MSC9F)FuHQxcn!&b0=u+d} z8ciha-Lbksgkl=f*6ByI@Z2Z#p9g-(qYvqc?`ep-aQAvVx16NlkwSb`)gSD2R^9J@ zhwcZQ@9*YD-7}BU7QeZRp!C`cyulRNguVX5+^#soYA$|$@!!AJFn;p)@hP#X4)S<1 S>@UK=$xACsl}ng@_ Date: Sun, 9 Jan 2022 23:34:14 +0100 Subject: [PATCH 04/19] [Mosaic] Ease Mosaic version execution from CLI --- README.md | 13 +++++++++++++ runWordleMosaic.sh | 10 ++++++++++ wordle-compose-mosaic/build.gradle.kts | 4 ++++ 3 files changed, 27 insertions(+) create mode 100755 runWordleMosaic.sh diff --git a/README.md b/README.md index e3e2778..b2d7206 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,19 @@ Congrats! You found the correct answer 🎉: HELLO ![](raw/wordle-mosaic.png) +In a compatible terminal, build the binary distribution and execute it: + +```bash +$ ./gradlew wordle-compose-mosaic:installDist +$ ./wordle-compose-mosaic/build/install/wordle-compose-mosaic/bin/wordle-compose-mosaic +``` + +Or you can simply execute the convenience `runWordleMosaic.sh` script doing the same. + +```bash +$ ./runWordleMosaic.sh +``` + ## Tech Stack * [Kotlin](https://kotlinlang.org/) diff --git a/runWordleMosaic.sh b/runWordleMosaic.sh new file mode 100755 index 0000000..d226d3d --- /dev/null +++ b/runWordleMosaic.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -euo pipefail + +origin=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) || exit + +cd "$origin" + +./gradlew wordle-compose-mosaic:installDist +./wordle-compose-mosaic/build/install/wordle-compose-mosaic/bin/wordle-compose-mosaic diff --git a/wordle-compose-mosaic/build.gradle.kts b/wordle-compose-mosaic/build.gradle.kts index 1d13467..0763a57 100644 --- a/wordle-compose-mosaic/build.gradle.kts +++ b/wordle-compose-mosaic/build.gradle.kts @@ -8,3 +8,7 @@ dependencies { implementation(project(":word-data")) implementation(project(":game-logic")) } + +application { + mainClass = "net.opatry.game.wordle.mosaic.WordleComposeMosaicKt" +} \ No newline at end of file From 45cfd5bdcbd6527b8e58768e967efa45cdadf849 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 10 Jan 2022 00:55:49 +0100 Subject: [PATCH 05/19] [Mosaic] Properly implement game loop & reactive UI with Mosaic and jline for terminal interface --- gradle/libs.versions.toml | 2 + wordle-compose-mosaic/build.gradle.kts | 3 + .../game/wordle/mosaic/WordleViewModel.kt | 21 ++++- .../game/wordle/mosaic/wordleComposeMosaic.kt | 93 ++++++++++++------- 4 files changed, 79 insertions(+), 40 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b548d65..35ef109 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,8 @@ accompanist-insets = "com.google.accompanist:accompanist-insets:0.25.1" gson = "com.google.code.gson:gson:2.10.1" +jline = "org.jline:jline:3.25.0" + junit4 = "junit:junit:4.13.2" [bundles] diff --git a/wordle-compose-mosaic/build.gradle.kts b/wordle-compose-mosaic/build.gradle.kts index 0763a57..dbd939f 100644 --- a/wordle-compose-mosaic/build.gradle.kts +++ b/wordle-compose-mosaic/build.gradle.kts @@ -5,6 +5,9 @@ plugins { } dependencies { + implementation(libs.jline) { + because("need to handle terminal keyboard input") + } implementation(project(":word-data")) implementation(project(":game-logic")) } diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/WordleViewModel.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/WordleViewModel.kt index 7086b4f..f1cf235 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/WordleViewModel.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/WordleViewModel.kt @@ -32,6 +32,7 @@ import net.opatry.game.wordle.State import net.opatry.game.wordle.WordleRules class WordleViewModel(private var rules: WordleRules) { + var userInput by mutableStateOf("") var state by mutableStateOf(rules.state) var answer by mutableStateOf("") private set @@ -48,7 +49,10 @@ class WordleViewModel(private var rules: WordleRules) { val maxTries = rules.state.maxTries val wordSize = rules.wordSize val emptyAnswer = Answer(CharArray(wordSize) { ' ' }, Array(wordSize) { AnswerFlag.NONE }) - repeat(maxTries - turn) { + if (turn < maxTries) { + answers += Answer(userInput.padEnd(wordSize, ' ').toCharArray(), Array(wordSize) { AnswerFlag.NONE }) + } + repeat(maxTries - turn - 1) { answers += emptyAnswer } grid = answers.toList() @@ -62,10 +66,18 @@ class WordleViewModel(private var rules: WordleRules) { } } - fun playWord(word: String) { - val normalized = word.take(5).uppercase() + fun updateUserInput(input: String) { + val normalized = input.take(5).uppercase() + if (normalized != userInput) { + userInput = normalized + updateGrid() + } + } + + fun playWord() { // TODO indicate error when input isn't valid - if (rules.playWord(normalized) == InputState.VALID) { + if (rules.playWord(userInput) == InputState.VALID) { + userInput = "" updateGrid() } updateAnswer() @@ -74,6 +86,7 @@ class WordleViewModel(private var rules: WordleRules) { fun restart() { rules = WordleRules(rules.words) + userInput = "" updateGrid() updateAnswer() state = rules.state diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt index 6d0e36e..29d9015 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt @@ -28,12 +28,14 @@ import com.jakewharton.mosaic.ui.Column import com.jakewharton.mosaic.ui.Row import com.jakewharton.mosaic.ui.Text import com.jakewharton.mosaic.runMosaic -import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import net.opatry.game.wordle.Answer import net.opatry.game.wordle.AnswerFlag import net.opatry.game.wordle.State import net.opatry.game.wordle.WordleRules import net.opatry.game.wordle.ui.toEmoji +import org.jline.terminal.TerminalBuilder private fun StringBuffer.appendClipboardAnswer(answer: Answer) { answer.flags.map(AnswerFlag::toEmoji).forEach(::append) @@ -67,28 +69,34 @@ suspend fun main() = runMosaic { GameScreen(viewModel) } - while (playing) { - delay(16) - while (viewModel.state is State.Playing) { - delay(16) - print(" ➡️ Enter a 5 letter english word: ") // FIXME shouldn't be done with compose/mosaic - val word = readLine().toString() // FIXME how to scan with compose/mosaic -// delay(16) - viewModel.playWord(word) - } - - delay(16) - - // TODO -// viewModel.state.toClipboard() -// println("Results copied to clipboard!") - println(viewModel.state.toClipboard()) - - print(" 🔄 Play again? (y/N) ") // FIXME shouldn't be done with compose/mosaic - playing = readLine().toString().equals("y", ignoreCase = true) // FIXME how to scan with compose/mosaic -// delay(16) - if (playing) { - viewModel.restart() + withContext(Dispatchers.IO) { + val terminal = TerminalBuilder.terminal() + terminal.enterRawMode() + terminal.reader().use { reader -> + while (playing) { + while (viewModel.state is State.Playing) { + val userInput = viewModel.userInput + val read = reader.read() + when (val char = read.toChar()) { + 13.toChar(), '\n' -> viewModel.playWord() + 127.toChar(), '\b' -> if (userInput.isNotEmpty()) { + viewModel.updateUserInput(userInput.dropLast(1)) + } + in 'a'..'z', + in 'A'..'Z' -> viewModel.updateUserInput(userInput + char) + // '?' -> viewModel.showHelp() + // ',' -> viewModel.showSettings() + // 27.toChar() -> break + else -> Unit // TODO display something to user + } + } + + val read = reader.read() + playing = read.toChar().equals('y', ignoreCase = true) + if (playing) { + viewModel.restart() + } + } } } } @@ -96,20 +104,33 @@ suspend fun main() = runMosaic { @Composable fun GameScreen(viewModel: WordleViewModel) { Column { - Toolbar() - AnswerPlaceHolder(viewModel.answer) WordleGrid(viewModel.grid) - } -} -@Composable -fun Toolbar() { - Text("Wordle") -} + when (val state = viewModel.state) { + is State.Won -> { + Text("Wordle ${state.answers.size}/${state.maxTries}") + Text(viewModel.answer) + } + is State.Lost -> { + Text("Wordle X/${state.maxTries}") + Text(viewModel.answer) + } + is State.Playing -> { + Text(" ➡️ Enter a 5 letter english word") + Text("") // TODO display error here if any or define a placeholder on top of grid + } + } -@Composable -fun AnswerPlaceHolder(answer: String) { - Text(answer) +// viewModel.state.toClipboard() +// println("Results copied to clipboard!") + + // there must be stable number of lines for nice UI state + if (viewModel.state !is State.Playing) { + Text(" 🔄 Play again? (y/N)? ${viewModel.userInput}") + } else { + Text("") + } + } } @Composable @@ -131,7 +152,7 @@ fun WordleWordRow(row: Answer) { } fun AnswerFlag.cellColors(): Pair = when (this) { - AnswerFlag.NONE -> Color.Black to Color.White + AnswerFlag.NONE -> Color.Black to Color.BrightWhite AnswerFlag.PRESENT -> Color.Black to Color.Yellow AnswerFlag.ABSENT -> Color.White to Color.Black AnswerFlag.CORRECT -> Color.Black to Color.Green @@ -152,6 +173,6 @@ fun WordleCharCell(char: Char, flag: AnswerFlag) { ) Text(" ") } - Text(" ") + Text("") } } From 975c20c68dcc4e2c3a00e4cecf6faaeb4278930d Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 10 Jan 2022 09:06:36 +0100 Subject: [PATCH 06/19] [Mosaic] Distinguish style for empty cell from pending user input in Mosaic --- .../net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt index 29d9015..eb4d0b3 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt @@ -152,7 +152,7 @@ fun WordleWordRow(row: Answer) { } fun AnswerFlag.cellColors(): Pair = when (this) { - AnswerFlag.NONE -> Color.Black to Color.BrightWhite + AnswerFlag.NONE -> Color.Black to Color.White AnswerFlag.PRESENT -> Color.Black to Color.Yellow AnswerFlag.ABSENT -> Color.White to Color.Black AnswerFlag.CORRECT -> Color.Black to Color.Green @@ -160,7 +160,11 @@ fun AnswerFlag.cellColors(): Pair = when (this) { @Composable fun WordleCharCell(char: Char, flag: AnswerFlag) { - val (foregroundColor, backgroundColor) = flag.cellColors() + val (foregroundColor, backgroundColor) = + if (flag == AnswerFlag.NONE && !char.isWhitespace()) + Color.Black to Color.BrightWhite + else + flag.cellColors() // TODO AnnotatedString " $char " https://github.com/JakeWharton/mosaic/issues/9 Column { From 51fc17dae5a4952d62604aadbedc997af9002bd2 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 10 Jan 2022 09:07:32 +0100 Subject: [PATCH 07/19] [Mosaic] Make letters bold in Mosaic --- .../java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt index eb4d0b3..44a5b4b 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt @@ -27,6 +27,7 @@ import com.jakewharton.mosaic.ui.Color import com.jakewharton.mosaic.ui.Column import com.jakewharton.mosaic.ui.Row import com.jakewharton.mosaic.ui.Text +import com.jakewharton.mosaic.ui.TextStyle import com.jakewharton.mosaic.runMosaic import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -173,7 +174,8 @@ fun WordleCharCell(char: Char, flag: AnswerFlag) { Text( " $char ", color = foregroundColor, - background = backgroundColor + background = backgroundColor, + style = TextStyle.Bold ) Text(" ") } From c93760ac06e08ad2aef7025f0cd994aae1b0bbe1 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 10 Jan 2022 09:15:10 +0100 Subject: [PATCH 08/19] [Mosaic] Add some margin around "UI" --- runWordleMosaic.sh | 3 +++ .../java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt | 2 ++ 2 files changed, 5 insertions(+) diff --git a/runWordleMosaic.sh b/runWordleMosaic.sh index d226d3d..4037496 100755 --- a/runWordleMosaic.sh +++ b/runWordleMosaic.sh @@ -7,4 +7,7 @@ origin=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) || exit cd "$origin" ./gradlew wordle-compose-mosaic:installDist + +echo "" + ./wordle-compose-mosaic/build/install/wordle-compose-mosaic/bin/wordle-compose-mosaic diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt index 44a5b4b..46c6c7b 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt @@ -105,6 +105,8 @@ suspend fun main() = runMosaic { @Composable fun GameScreen(viewModel: WordleViewModel) { Column { + Text("") + WordleGrid(viewModel.grid) when (val state = viewModel.state) { From deaab9fba595b606020a3dc3776a82fcc026729d Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 10 Jan 2022 09:15:28 +0100 Subject: [PATCH 09/19] [Mosaic] Update README screenshot following last style update --- raw/wordle-mosaic.png | Bin 17958 -> 10719 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/raw/wordle-mosaic.png b/raw/wordle-mosaic.png index f805825c08907e9619877b50a4ed28341494dfd8..628c63dcc99fb9939de4679984b6e41080c857ff 100644 GIT binary patch literal 10719 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 literal 17958 zcmZ|11z1$wyFQE}-Q6KdHw<0UA>9oE(%p>#NTVR#AdRGScSwgcNXLM5NPcVH_nhDV zobUgAmlwl0duH!FYdz1i?&rSmMYyW6ECwnmDjXafhP<4VIvm^+Qt;!4j0k&$OiCL3 zLNt3VD+Tum`C@E+ zZ3pR}DpG2t)av;7hG8Fy!tA~=(UgWA$Rp1uHIEJ-#r|xyXq0z!bmaA3shAC*^%8Ki z93B2Ud?xHR>h`s)j0IjCTM!cZCYwQ_d09+L`3p5N4derL*f{ckJxIJj{(u}!jV!Sl zy#2j?W&Ka{Q9;n-!{+8DhsVBVUo=^NVOC(oh-IYQ+xwe~7xeU>Ch}ytz5W=wy1EVw z4!#*p7jUI}w#MIg@K~6g{cdy9qR9h__-eM?=*jAtPts3B>V<|d*4utt6A(g<57tM#d*Ojc21mgTuoX zC+odEJw36mtEp{@5@42%nzTjEj$l&%&Yg= z`|hM)a-o>bsOfV;LT^7+&@kVsfA{`OX?ScbR#*}>GR3sV(AZeN1sA9FICW;=jSXAd zwVAuoPH9CO>a&mczmyyu+2A~zGxjAAexG;i$V^Bs2tsNI0s_LpT-8VB@`k7JgFSOq<}QL6 z@83BFZ0_v19sd?*Yd+vIWFty43G~TWCFgg3hk{4P@pcHGiJ5u6*^Awv-tjG%WW^LN zgxLOWo5GB~&B6G_{gTq_y~3=`G875f=m||tO;t5DaTga(<-&Lozsu=L_e`H{5^#n_ zXn8YiqTO~z^Gixl;R>hpoWNYMPzwsCB2oEs91}M;y{)#N=c;pD9y;HfN)hqnohnqp zn%)7U1ZLAJD}Z-QiXw zq<98ZSk}INDL}a(ZB3P48ThW@)jv1b6}~O#@$Ak~)9vG8Z9P4*gb-fp+?*~G6O+V# z3+v36?)maC1?U@M#Lhnm5i~pE!38+5j0Ewvx3>o-9HiP&rQ>qbFEQepl-@`pq!l83 z{NuGxRHv7VPWAE|%Gc%sNN2X?Dxyx>^rMu&cqySEU>f%d(sb(`DcC_!ryX>k+OuPc zX&CREt;WQz9hCW^T8ht1UWdL+9uKe>aeDzkQ(>CZ&g zC{msX=8BfMfZNNH`>WOZ!M3}@U;KTT_0LwkBKQ^#EU-u+$nT<}qIefStwz;2UmlO{ zhK@?lCLAAq!j(I+q8Vb#Pzx@2lAD)D!tYG2B6bfksIz}FmMNn9T^knMx%ZYDxuvD0zqetjd9J>m z4LBU9RV$tm?K+XGaqxaLUmqG8>R1J_>byIi!y5xn97W9ev~*@DQ^fCSR>(~Rewvr9 z|7q>f#mtp&S}m-Y$|)+o6cQqk26>9(Vc zh(X#Dib3`b37JC0+SSd?rP0vPcQ4<;7pKCwl~hYBh2Mn+q*ceEXZ?KJQa}JYQTkik zGdvMcjnKPIhrTjONlQ<(`gt|pgv4wJ`m`FOcT=;m#dtt}eaXn6xNiAAS8GQErYXC+ zIxZZKKJ%DfPYoXtlUz<*{E3L~Iqc=p456u7JDt5J68)9d>B{tC57#e$X`uu*6L@eyh5lP5uTSKq>iW)=8 z(~~zM*Lf|3s4klJiSUg*36Y@zzIVWh1JuA5L^(e>CWrzu?76JKoAC24pr02XlsvwzdPPzP?}r_mZU3LX2kQ! z!QNg8oM$c>`#lyfIcurO+vOmd*ufjKxi-=HI?u%U#Y5}@Zj-3;pTp9KX_A-XWvvTV6xv;1ImcK5PbwQ)YxfS1b4SWKl*;H~epEojDm7Wc4 z>766bmzEl4#p>AC={G}2THl(`G_Zfr{hxToITtzl&8{q7AQ_J_*N~2N)DTyrj52kF7=}ydG5JQ6O~=E2@!4J-PJ8d+Acgo)#x?0R{61xniRP1c!p|wrTH8q> z=4T;9;O4GiUF*|xIX@^gtF2vLwjIwFhk=^`lJY)+`ra>kO1frvim#wqg?yPV6HcE~ z{2nN}6DvH7vrIZa*Wgw54+!Aq6eHPvmB>}<Jt|98_^936$=f{)`hRw|%6 z4UUdlULUVnSX!#<>M~f7&NWX(?iCSP)iQp=a!9j!@=-?L=TV}>+iZBUU?NRL20#Hybh@>OX>hF zi@es$vXmb`AZBJ}-Y308^PpB+)}%;B61wac7sjQfEz)5elyNZ(iFu|^R4Js17!@T(TVUqJxj00@C= zL)i5EvozT89$v4;QYj5`9O_j%ckHP3``)QMdaH}sAjtpA(-zV853@T|w+EpqC+L(j zO%#S|nry8Cp;(Fh*T>OJ_G+kj6_I&6gOeoE))FV3njt|}^bmPN(ij2ep)Hu2 z!^>ASqVMK-`B*j17H&b)fax#+A%rV;GAfjFKS+$|ME)?|X;>&%@$)6J#BGid`dld+ zFK!;rGn`aQl-CX}D3@)mx2Yef-Qi}7MePcj2Exc_kJTzVZ)oyc*a&xj3Ni5=8cKoaYS=+Sw-NPK&9jYbjyqGw0|EN%R+1 z4^z__(@jN;2Y2io?Xlbj5qOAFaf_xhJJv&J)W3`sTwOb8kzY4%&!y07r!HE2sSbJE zd0AUH^i526@lI;2sr*A%OX{KP)WI{vXzPGH&sF8Yb>`ikPu{R%@ZJZ}0fvhfbN)S+<(w+CA*;d!?f~!z3`( z@jpwB6{r6O3DG@bO5@Hdr~P03or}K&=mJ7HBfnk}3BDWG&BMoaUZosXJ+!3OD|wGz>TIyp| z_(gql6j=+FnSwx+y6t)Mp!%wI-xwt(=d4&$z8Y$c>^9Y}94X^p{y8@j-0#(MK;4sE z+}7*J{-V9|ph8Qh!Sb;`d4P0^H!)$nPh#W3*RD*on1P8+hlJO_|C6v>{9weOg~*!9 zWj%D+{%;?B14@S0fI$@G&8@CcRGbR5>YIe@ibifqT!V*{O;bOd^n4BRTky>(lBZ^LtatTxr|af z!IMlA9yT2QF;8MGkHFYatL=Iv*se^fx8z`HGoeplf`v4)_ruRE)UbC`dJ(;o{@jaV zTn)Z0^pi9i#$~@<{z_Gq4P>8(Nl2(K@>JFgWivb`%gOdLIb6Eu=V}%<{MPmB_fY4o zKTX&0i|X4s5-4V<<@%$}EW~T0nPB(r5_fhl7>`t1t#a}3{YB!zWiY1s;BZiO+{fmC z*c9VyKN2{VR-#q94Qn{w7K7eUuUHY|$C279L+`iHF$SG?vpYJ+H+SY)%N*R9oq788 zADJVD^7;CYJ8_Yy+SEc=72d3SxSR{?32va!LzD~gnwM9;NiS(?t*y@Ipv0BivGU${ z^>I%e7PvTh4`9wM_O=cC3)C(Dc)iULGW=$OpUJFJ8tUMQhpiu*%}H7_A5~y8DJT(% zOJEl^kUl^dT@JNgs3PZ9wkjTLcPv2D65)NpU4yXJBTgdh?Oa1i&lsU@KCEOn5eI)q zTB7kx-8%M%bdYtOG?@2~qN9mFl0Q&ODOxMiX<+0`K)y@&PyCBN4hxk^O4i*4^J|}p zBS(IQ$20>6u+<{_4n1eF35{ks(?%xs!AF&?%d}JWSdb;qnw2{dp@VRu;@C8n6U3qK z`!|YlurGrwxk5zQ=_WZTQr1|J`@_et5qde$Ta2Bel~**q6oLWn%9hw5!mpTR?lb`( zm1&Jv$*Uk6XAMh@m{;1iWO)KKY9X5g`ev43HT-5qzo zvcS8Cc$v_IAH24>lD6)bz+<^)?lcw0?vnmK=Ao{|b6l;A26oB zZ6q|8;XQTbh-J5B&0quRKWCe9Yq3t%?hZMP zj0NMYp#~Nwa#bww(Rq6rEv89tw5hIUOucD-6yX>}SvYbH2P)1yjogThPRVW&VA4u#62v<^?ASxK$=d=hJpvbi!xi zhV=iQ23vx(^6I!7YZfXPl6JlS9*Gk9?#M)P$bu`Rh5d`-xki9Vs>Fq7IDR$zmt~?H z3RI=a>&RX+o0A5`3;&*+Fu=^M3-W~Tqs{!Cy#wbBrZc-XDp<%0pF{3)IS{NxOf5i2 zT>9=5T3-%bmq7twc%BstYS<;K_7#zpAb#*A=m%&ZBlO8Uc?=5C z2v9KUv&DKe8(5c25xB%?FT$(;xqv1S3CQC?3*0ZTWqTkZDo9VSJ#8~~J`7y+_v9x%0Amy*y{WS^!iQZv$|sjU_?^GniWr_^u7 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 S>@UK=$xACsl}ng@_ Date: Mon, 10 Jan 2022 11:18:53 +0100 Subject: [PATCH 10/19] [Mosaic] Align with Compose UI view model Should be fully shared ideally at some point. Allows opening the alphabet UI in Mosaic. --- .../game/wordle/mosaic/WordleViewModel.kt | 94 -------- .../game/wordle/mosaic/wordleComposeMosaic.kt | 32 +-- .../opatry/game/wordle/ui/WordleViewModel.kt | 219 ++++++++++++++++++ 3 files changed, 225 insertions(+), 120 deletions(-) delete mode 100644 wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/WordleViewModel.kt create mode 100644 wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/WordleViewModel.kt diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/WordleViewModel.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/WordleViewModel.kt deleted file mode 100644 index f1cf235..0000000 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/WordleViewModel.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) 2022 Olivier Patry - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files (the "Software"), - * to deal in the Software without restriction, including without limitation - * the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the Software - * is furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY - * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, - * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE - * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package net.opatry.game.wordle.mosaic - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import net.opatry.game.wordle.Answer -import net.opatry.game.wordle.AnswerFlag -import net.opatry.game.wordle.InputState -import net.opatry.game.wordle.State -import net.opatry.game.wordle.WordleRules - -class WordleViewModel(private var rules: WordleRules) { - var userInput by mutableStateOf("") - var state by mutableStateOf(rules.state) - var answer by mutableStateOf("") - private set - var grid by mutableStateOf>(emptyList()) - private set - - init { - updateGrid() - } - - private fun updateGrid() { - val answers = rules.state.answers.toMutableList() - val turn = answers.size - val maxTries = rules.state.maxTries - val wordSize = rules.wordSize - val emptyAnswer = Answer(CharArray(wordSize) { ' ' }, Array(wordSize) { AnswerFlag.NONE }) - if (turn < maxTries) { - answers += Answer(userInput.padEnd(wordSize, ' ').toCharArray(), Array(wordSize) { AnswerFlag.NONE }) - } - repeat(maxTries - turn - 1) { - answers += emptyAnswer - } - grid = answers.toList() - } - - private fun updateAnswer() { - answer = when (val state = rules.state) { - is State.Playing -> "" - is State.Lost -> state.selectedWord - is State.Won -> state.selectedWord - } - } - - fun updateUserInput(input: String) { - val normalized = input.take(5).uppercase() - if (normalized != userInput) { - userInput = normalized - updateGrid() - } - } - - fun playWord() { - // TODO indicate error when input isn't valid - if (rules.playWord(userInput) == InputState.VALID) { - userInput = "" - updateGrid() - } - updateAnswer() - state = rules.state - } - - fun restart() { - rules = WordleRules(rules.words) - userInput = "" - updateGrid() - updateAnswer() - state = rules.state - } -} diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt index 46c6c7b..fc16d98 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt @@ -23,44 +23,21 @@ package net.opatry.game.wordle.mosaic import androidx.compose.runtime.Composable +import com.jakewharton.mosaic.runMosaic import com.jakewharton.mosaic.ui.Color import com.jakewharton.mosaic.ui.Column import com.jakewharton.mosaic.ui.Row import com.jakewharton.mosaic.ui.Text import com.jakewharton.mosaic.ui.TextStyle -import com.jakewharton.mosaic.runMosaic import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import net.opatry.game.wordle.Answer import net.opatry.game.wordle.AnswerFlag import net.opatry.game.wordle.State import net.opatry.game.wordle.WordleRules -import net.opatry.game.wordle.ui.toEmoji +import net.opatry.game.wordle.ui.WordleViewModel import org.jline.terminal.TerminalBuilder -private fun StringBuffer.appendClipboardAnswer(answer: Answer) { - answer.flags.map(AnswerFlag::toEmoji).forEach(::append) - append('\n') -} - -fun State.toClipboard(): String { - val buffer = StringBuffer() - buffer.append( - when (this) { - is State.Lost -> "Wordle X/$maxTries\n" - is State.Won -> "Wordle ${answers.size}/$maxTries\n" - else -> "" - } - ) - answers.forEach(buffer::appendClipboardAnswer) - - val clipboard = buffer.toString() - - // FIXME might not be cross platform/portable - // TODO "pbcopy <<< $clipboard".runCommand(File(System.getProperty("user.dir"))) - return clipboard -} - suspend fun main() = runMosaic { // TODO check terminal is compatible (eg. IDEA is not!) var playing = true @@ -79,10 +56,11 @@ suspend fun main() = runMosaic { val userInput = viewModel.userInput val read = reader.read() when (val char = read.toChar()) { - 13.toChar(), '\n' -> viewModel.playWord() + 13.toChar(), '\n' -> viewModel.validateUserInput() 127.toChar(), '\b' -> if (userInput.isNotEmpty()) { viewModel.updateUserInput(userInput.dropLast(1)) } + in 'a'..'z', in 'A'..'Z' -> viewModel.updateUserInput(userInput + char) // '?' -> viewModel.showHelp() @@ -114,10 +92,12 @@ fun GameScreen(viewModel: WordleViewModel) { Text("Wordle ${state.answers.size}/${state.maxTries}") Text(viewModel.answer) } + is State.Lost -> { Text("Wordle X/${state.maxTries}") Text(viewModel.answer) } + is State.Playing -> { Text(" ➡️ Enter a 5 letter english word") Text("") // TODO display error here if any or define a placeholder on top of grid diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/WordleViewModel.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/WordleViewModel.kt new file mode 100644 index 0000000..c1bfb32 --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/WordleViewModel.kt @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import net.opatry.game.wordle.Answer +import net.opatry.game.wordle.AnswerFlag +import net.opatry.game.wordle.InputState +import net.opatry.game.wordle.State +import net.opatry.game.wordle.WordleRules + +private val victoryMessages = arrayOf( + "Genius", + "Magnificent", + "Impressive", + "Splendid", + "Great", + "Phew" +) +private val State.message: String + get() { + val index = answers.size - 1 + return if (this is State.Won && index in victoryMessages.indices) { + victoryMessages[index] + } else { + "" + } + } + +private fun StringBuffer.appendClipboardAnswer(answer: Answer) { + answer.flags.map(AnswerFlag::toEmoji).forEach(::append) + append('\n') +} + +private fun State.toClipboard(): String { + val buffer = StringBuffer() + buffer.append( + when (this) { + is State.Lost -> "Wordle X/$maxTries\n" + is State.Won -> "Wordle ${answers.size}/$maxTries\n" + else -> "" + } + ) + answers.forEach(buffer::appendClipboardAnswer) + + return buffer.toString() +} + +class WordleViewModel(private var rules: WordleRules) { + var firstLaunch by mutableStateOf(true) + private set + var state by mutableStateOf(rules.state) + val stateLabel: String + get() = rules.state.toClipboard() + var victory by mutableStateOf(rules.state is State.Won) + private set + var answer by mutableStateOf("") + private set + var grid by mutableStateOf>(emptyList()) + private set + var userInput by mutableStateOf("") + private set + var alphabet by mutableStateOf(emptyMap()) + + private val _userFeedback = mutableListOf() + var userFeedback by mutableStateOf(_userFeedback.toList()) + private set + + init { + updateGrid() + updateAlphabet() + } + + private fun updateGrid() { + val answers = rules.state.answers.toMutableList() + val turn = answers.size + val maxTries = rules.state.maxTries.toInt() + val wordSize = rules.wordSize + if (turn < maxTries) { + answers += Answer(userInput.padEnd(wordSize, ' ').toCharArray(), Array(wordSize) { AnswerFlag.NONE }) + } + val emptyAnswer = Answer(CharArray(wordSize) { ' ' }, Array(wordSize) { AnswerFlag.NONE }) + repeat(maxTries - turn - 1) { + answers += emptyAnswer + } + grid = answers.toList() + } + + private fun updateAlphabet() { + // TODO couldn't we make this smarter? + val answers = rules.state.answers + val absent = mutableSetOf() + val present = mutableSetOf() + val correct = mutableSetOf() + + answers.forEach { + it.flags.forEachIndexed { index, flag -> + when (flag) { + AnswerFlag.ABSENT -> absent += it.letters[index] + AnswerFlag.PRESENT -> present += it.letters[index] + AnswerFlag.CORRECT -> correct += it.letters[index] + else -> Unit + } + } + } + + val alphabet = mutableMapOf().apply { + var c = 'A' + while (c <= 'Z') { + this += c to when { + correct.contains(c) -> AnswerFlag.CORRECT + present.contains(c) -> AnswerFlag.PRESENT + absent.contains(c) -> AnswerFlag.ABSENT + else -> AnswerFlag.NONE + } + ++c + } + } + this.alphabet = alphabet.toMap() + } + + private fun updateAnswer() { + answer = when (val state = rules.state) { + is State.Playing -> "" + is State.Lost -> state.selectedWord + is State.Won -> state.selectedWord + } + } + + fun updateUserInput(input: String) { + if (rules.state !is State.Playing) return + + val normalized = input.take(rules.wordSize).uppercase() + if (normalized != userInput) { + userInput = normalized + updateGrid() + } + } + + fun validateUserInput() { + if (rules.state !is State.Playing) return + + when (rules.playWord(userInput)) { + InputState.VALID -> { + userInput = "" + updateGrid() + updateAlphabet() + } + + InputState.NOT_IN_DICTIONARY -> { + _userFeedback.add("Not in word list") + userFeedback = _userFeedback.toList() + updateGrid() + } + + InputState.TOO_SHORT -> { + _userFeedback.add("Not enough letters") + userFeedback = _userFeedback.toList() + updateGrid() + } + + else -> Unit + } + val oldVictory = victory + updateAnswer() + + // notify user + victory = rules.state is State.Won + if (victory && !oldVictory) { + _userFeedback.add(rules.state.message) + userFeedback = _userFeedback.toList() + } + updateAnswer() + state = rules.state + } + + fun restart() { + rules = WordleRules(rules.words) + victory = rules.state is State.Won + userInput = "" + _userFeedback.clear() + userFeedback = _userFeedback.toList() + updateGrid() + updateAlphabet() + updateAnswer() + state = rules.state + } + + fun consumed(message: String) { + _userFeedback.remove(message) + userFeedback = _userFeedback.toList() + } + + fun firstLaunchDone() { + firstLaunch = false + } +} From efca2388abfd9f6e96b1c11a00db9928dfc045b7 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 10 Jan 2022 11:32:17 +0100 Subject: [PATCH 11/19] [Mosaic] Extract Mosaic Wordle related Composable in their own file --- .../game/wordle/mosaic/component/wordle.kt | 82 +++++++++++++++++++ .../game/wordle/mosaic/wordleComposeMosaic.kt | 55 +------------ 2 files changed, 83 insertions(+), 54 deletions(-) create mode 100644 wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/wordle.kt diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/wordle.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/wordle.kt new file mode 100644 index 0000000..b8793a5 --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/wordle.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle.mosaic.component + +import androidx.compose.runtime.Composable +import com.jakewharton.mosaic.ui.Color +import com.jakewharton.mosaic.ui.Column +import com.jakewharton.mosaic.ui.Row +import com.jakewharton.mosaic.ui.Text +import com.jakewharton.mosaic.ui.TextStyle +import net.opatry.game.wordle.Answer +import net.opatry.game.wordle.AnswerFlag + + +@Composable +fun WordleGrid(grid: List) { + Column { + grid.forEach { row -> + WordleWordRow(row) + } + } +} + +@Composable +fun WordleWordRow(row: Answer) { + Row { + row.letters.forEachIndexed { index, char -> + WordleCharCell(char, row.flags[index]) + } + } +} + +fun AnswerFlag.cellColors(): Pair = when (this) { + AnswerFlag.NONE -> Color.Black to Color.White + AnswerFlag.PRESENT -> Color.Black to Color.Yellow + AnswerFlag.ABSENT -> Color.BrightWhite to Color.Black + AnswerFlag.CORRECT -> Color.BrightWhite to Color.Green +} + +@Composable +fun WordleCharCell(char: Char, flag: AnswerFlag) { + val (foregroundColor, backgroundColor) = + if (flag == AnswerFlag.NONE && !char.isWhitespace()) + Color.Black to Color.BrightWhite + else + flag.cellColors() + + // TODO AnnotatedString " $char " https://github.com/JakeWharton/mosaic/issues/9 + Column { + Row { + Text(" ") + Text( + " $char ", + color = foregroundColor, + background = backgroundColor, + style = TextStyle.Bold + ) + Text(" ") + } + Text("") + } +} diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt index fc16d98..4c7a92b 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt @@ -24,17 +24,13 @@ package net.opatry.game.wordle.mosaic import androidx.compose.runtime.Composable import com.jakewharton.mosaic.runMosaic -import com.jakewharton.mosaic.ui.Color import com.jakewharton.mosaic.ui.Column -import com.jakewharton.mosaic.ui.Row import com.jakewharton.mosaic.ui.Text -import com.jakewharton.mosaic.ui.TextStyle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import net.opatry.game.wordle.Answer -import net.opatry.game.wordle.AnswerFlag import net.opatry.game.wordle.State import net.opatry.game.wordle.WordleRules +import net.opatry.game.wordle.mosaic.component.WordleGrid import net.opatry.game.wordle.ui.WordleViewModel import org.jline.terminal.TerminalBuilder @@ -115,52 +111,3 @@ fun GameScreen(viewModel: WordleViewModel) { } } } - -@Composable -fun WordleGrid(grid: List) { - Column { - grid.forEach { row -> - WordleWordRow(row) - } - } -} - -@Composable -fun WordleWordRow(row: Answer) { - Row { - row.letters.forEachIndexed { index, char -> - WordleCharCell(char, row.flags[index]) - } - } -} - -fun AnswerFlag.cellColors(): Pair = when (this) { - AnswerFlag.NONE -> Color.Black to Color.White - AnswerFlag.PRESENT -> Color.Black to Color.Yellow - AnswerFlag.ABSENT -> Color.White to Color.Black - AnswerFlag.CORRECT -> Color.Black to Color.Green -} - -@Composable -fun WordleCharCell(char: Char, flag: AnswerFlag) { - val (foregroundColor, backgroundColor) = - if (flag == AnswerFlag.NONE && !char.isWhitespace()) - Color.Black to Color.BrightWhite - else - flag.cellColors() - - // TODO AnnotatedString " $char " https://github.com/JakeWharton/mosaic/issues/9 - Column { - Row { - Text(" ") - Text( - " $char ", - color = foregroundColor, - background = backgroundColor, - style = TextStyle.Bold - ) - Text(" ") - } - Text("") - } -} From b8288fd4bf6e4d4cba372c0840cd1a72b40f403f Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 10 Jan 2022 11:33:16 +0100 Subject: [PATCH 12/19] [Mosaic] Add alphabet in Mosaic version (fixes #16) --- .../game/wordle/mosaic/component/alphabet.kt | 41 +++++++++++++++++++ .../game/wordle/mosaic/wordleComposeMosaic.kt | 9 +++- 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt new file mode 100644 index 0000000..200b166 --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle.mosaic.component + +import androidx.compose.runtime.Composable +import com.jakewharton.mosaic.ui.Column +import com.jakewharton.mosaic.ui.Row +import net.opatry.game.wordle.AnswerFlag + +@Composable +fun Alphabet(alphabet: Map) { + Column { + alphabet.keys.chunked(9).forEach { row -> + Row { + row.forEach { letter -> + WordleCharCell(letter, alphabet[letter]!!) + } + } + } + } +} diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt index 4c7a92b..a144569 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt @@ -25,11 +25,13 @@ package net.opatry.game.wordle.mosaic import androidx.compose.runtime.Composable import com.jakewharton.mosaic.runMosaic import com.jakewharton.mosaic.ui.Column +import com.jakewharton.mosaic.ui.Row import com.jakewharton.mosaic.ui.Text import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import net.opatry.game.wordle.State import net.opatry.game.wordle.WordleRules +import net.opatry.game.wordle.mosaic.component.Alphabet import net.opatry.game.wordle.mosaic.component.WordleGrid import net.opatry.game.wordle.ui.WordleViewModel import org.jline.terminal.TerminalBuilder @@ -81,7 +83,12 @@ fun GameScreen(viewModel: WordleViewModel) { Column { Text("") - WordleGrid(viewModel.grid) + Row { + Text(" ") + WordleGrid(viewModel.grid) + Text(" ") + Alphabet(viewModel.alphabet) + } when (val state = viewModel.state) { is State.Won -> { From 5bf6c877aa64d7bd26de9d91d0033ff09646e519 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 10 Jan 2022 11:42:38 +0100 Subject: [PATCH 13/19] [Mosaic] Extract Mosaic GameScreen composable in its own file --- .../opatry/game/wordle/mosaic/gameScreen.kt | 74 +++++++++++++++++++ .../game/wordle/mosaic/wordleComposeMosaic.kt | 47 ------------ 2 files changed, 74 insertions(+), 47 deletions(-) create mode 100644 wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt new file mode 100644 index 0000000..1bcf158 --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle.mosaic + +import androidx.compose.runtime.Composable +import com.jakewharton.mosaic.ui.Column +import com.jakewharton.mosaic.ui.Row +import com.jakewharton.mosaic.ui.Text +import net.opatry.game.wordle.State +import net.opatry.game.wordle.mosaic.component.Alphabet +import net.opatry.game.wordle.mosaic.component.WordleGrid +import net.opatry.game.wordle.ui.WordleViewModel + + +@Composable +fun GameScreen(viewModel: WordleViewModel) { + Column { + Text("") + + Row { + Text(" ") + WordleGrid(viewModel.grid) + Text(" ") + Alphabet(viewModel.alphabet) + } + + when (val state = viewModel.state) { + is State.Won -> { + Text("Wordle ${state.answers.size}/${state.maxTries}") + Text(viewModel.answer) + } + + is State.Lost -> { + Text("Wordle X/${state.maxTries}") + Text(viewModel.answer) + } + + is State.Playing -> { + Text(" ➡️ Enter a 5 letter english word") + Text("") // TODO display error here if any or define a placeholder on top of grid + } + } + +// viewModel.state.toClipboard() +// println("Results copied to clipboard!") + + // there must be stable number of lines for nice UI state + if (viewModel.state !is State.Playing) { + Text(" 🔄 Play again? (y/N)? ${viewModel.userInput}") + } else { + Text("") + } + } +} diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt index a144569..b31a1a9 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt @@ -22,17 +22,11 @@ package net.opatry.game.wordle.mosaic -import androidx.compose.runtime.Composable import com.jakewharton.mosaic.runMosaic -import com.jakewharton.mosaic.ui.Column -import com.jakewharton.mosaic.ui.Row -import com.jakewharton.mosaic.ui.Text import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import net.opatry.game.wordle.State import net.opatry.game.wordle.WordleRules -import net.opatry.game.wordle.mosaic.component.Alphabet -import net.opatry.game.wordle.mosaic.component.WordleGrid import net.opatry.game.wordle.ui.WordleViewModel import org.jline.terminal.TerminalBuilder @@ -77,44 +71,3 @@ suspend fun main() = runMosaic { } } } - -@Composable -fun GameScreen(viewModel: WordleViewModel) { - Column { - Text("") - - Row { - Text(" ") - WordleGrid(viewModel.grid) - Text(" ") - Alphabet(viewModel.alphabet) - } - - when (val state = viewModel.state) { - is State.Won -> { - Text("Wordle ${state.answers.size}/${state.maxTries}") - Text(viewModel.answer) - } - - is State.Lost -> { - Text("Wordle X/${state.maxTries}") - Text(viewModel.answer) - } - - is State.Playing -> { - Text(" ➡️ Enter a 5 letter english word") - Text("") // TODO display error here if any or define a placeholder on top of grid - } - } - -// viewModel.state.toClipboard() -// println("Results copied to clipboard!") - - // there must be stable number of lines for nice UI state - if (viewModel.state !is State.Playing) { - Text(" 🔄 Play again? (y/N)? ${viewModel.userInput}") - } else { - Text("") - } - } -} From 0257f5c5f3992fff30d36b09d9ebdb7864e854de Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 10 Jan 2022 12:56:00 +0100 Subject: [PATCH 14/19] [Mosaic] Display Mosaic game grid with table borders (fixes #15) --- .../game/wordle/mosaic/component/alphabet.kt | 4 +++ .../game/wordle/mosaic/component/wordle.kt | 34 ++++++++++++------- .../opatry/game/wordle/mosaic/gameScreen.kt | 2 ++ 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt index 200b166..dab43a1 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt @@ -25,6 +25,7 @@ package net.opatry.game.wordle.mosaic.component import androidx.compose.runtime.Composable import com.jakewharton.mosaic.ui.Column import com.jakewharton.mosaic.ui.Row +import com.jakewharton.mosaic.ui.Text import net.opatry.game.wordle.AnswerFlag @Composable @@ -33,9 +34,12 @@ fun Alphabet(alphabet: Map) { alphabet.keys.chunked(9).forEach { row -> Row { row.forEach { letter -> + Text(" ") WordleCharCell(letter, alphabet[letter]!!) + Text(" ") } } + Text(" ") } } } diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/wordle.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/wordle.kt index b8793a5..42710fe 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/wordle.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/wordle.kt @@ -35,18 +35,29 @@ import net.opatry.game.wordle.AnswerFlag @Composable fun WordleGrid(grid: List) { Column { - grid.forEach { row -> - WordleWordRow(row) - } - } -} + // Box drawing: https://en.wikipedia.org/wiki/Box-drawing_character#Box_Drawing + // FIXME dividers length depends on max of row.letters + // good enough for now given that we know it's a 5x6 grid + Text("╭─────┬─────┬─────┬─────┬─────╮") + grid.forEachIndexed { rowIndex, row -> + if (rowIndex > 0) { + Text("├─────┼─────┼─────┼─────┼─────┤") + } + Row { + row.letters.forEachIndexed { cellIndex, char -> + if (cellIndex == 0) + Text("│ ") + else + Text(" │ ") -@Composable -fun WordleWordRow(row: Answer) { - Row { - row.letters.forEachIndexed { index, char -> - WordleCharCell(char, row.flags[index]) + WordleCharCell(char, row.flags[cellIndex]) + + if (cellIndex == row.letters.size - 1) + Text(" │") + } + } } + Text("╰─────┴─────┴─────┴─────┴─────╯") } } @@ -68,15 +79,12 @@ fun WordleCharCell(char: Char, flag: AnswerFlag) { // TODO AnnotatedString " $char " https://github.com/JakeWharton/mosaic/issues/9 Column { Row { - Text(" ") Text( " $char ", color = foregroundColor, background = backgroundColor, style = TextStyle.Bold ) - Text(" ") } - Text("") } } diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt index 1bcf158..0b66023 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt @@ -44,6 +44,8 @@ fun GameScreen(viewModel: WordleViewModel) { Alphabet(viewModel.alphabet) } + Text("") + when (val state = viewModel.state) { is State.Won -> { Text("Wordle ${state.answers.size}/${state.maxTries}") From 678b24ffe3d85a143a942fd568b2eafd4568baa0 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 10 Jan 2022 13:21:08 +0100 Subject: [PATCH 15/19] [Mosaic] Add border around the Mosaic alphabet --- .../game/wordle/mosaic/component/alphabet.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt index dab43a1..2f29525 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt @@ -31,15 +31,28 @@ import net.opatry.game.wordle.AnswerFlag @Composable fun Alphabet(alphabet: Map) { Column { - alphabet.keys.chunked(9).forEach { row -> + val colCount = 9 + val cellWidth = 5 + Text("╭" + "─".repeat(cellWidth * colCount) + "╮") + alphabet.keys.chunked(colCount).forEachIndexed { index, row -> + if (index > 0) { + Text("│" + " ".repeat(colCount) + "│") + } Row { + Text("│") row.forEach { letter -> + // cell width is leading & trailing space + WordleCharCell compound of 3 characters Text(" ") WordleCharCell(letter, alphabet[letter]!!) Text(" ") } + // pad empty space for partial rows to align left border + repeat(colCount - row.size) { + Text(" ".repeat(cellWidth)) + } + Text("│") } - Text(" ") } + Text("╰" + "─".repeat(cellWidth * colCount) + "╯") } } From c0f34ead8f84e1c93216336229202ea193675cc6 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 10 Jan 2022 11:46:17 +0100 Subject: [PATCH 16/19] [Mosaic] Update README with Mosaic alphabet --- raw/wordle-mosaic.png | Bin 10719 -> 19690 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/raw/wordle-mosaic.png b/raw/wordle-mosaic.png index 628c63dcc99fb9939de4679984b6e41080c857ff..85179ec0226278835cbb4561ea1494fd1711a9de 100644 GIT binary patch delta 16534 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 Z7_l912DxE!A`bW?E2SV=c<<@+{{zmGNNWH9 delta 7492 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 From bc30ee2668e55e466fd40c472f6b1a5a5464e949 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Wed, 12 Jan 2022 00:22:50 +0100 Subject: [PATCH 17/19] [Mosaic] Clean Mosaic main app and use full dictionary --- .../net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt index b31a1a9..a11459d 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt @@ -27,13 +27,19 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import net.opatry.game.wordle.State import net.opatry.game.wordle.WordleRules +import net.opatry.game.wordle.allDictionaries +import net.opatry.game.wordle.loadWords import net.opatry.game.wordle.ui.WordleViewModel import org.jline.terminal.TerminalBuilder suspend fun main() = runMosaic { // TODO check terminal is compatible (eg. IDEA is not!) + val words = allDictionaries + .find { it.language == "en" && it.wordSize == 5 } + ?.loadWords() + ?: error("Can't find default dictionary") var playing = true - val viewModel = WordleViewModel(WordleRules(listOf("Hello", "Great", "Tiles", "Tales"))) + val viewModel = WordleViewModel(WordleRules(words)) setContent { GameScreen(viewModel) From 09feb5e84a44511301bcd7ff7e79ea6e264d68c2 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Thu, 13 Jan 2022 13:11:30 +0100 Subject: [PATCH 18/19] [Mosaic] Add a PlatformUtil to implement terminal copy to clipboard feature --- gradle/libs.versions.toml | 1 + wordle-compose-mosaic/build.gradle.kts | 3 + .../net/opatry/game/wordle/PlatformUtil.kt | 68 +++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/PlatformUtil.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 35ef109..a8ac5f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ accompanist-insets = "com.google.accompanist:accompanist-insets:0.25.1" gson = "com.google.code.gson:gson:2.10.1" jline = "org.jline:jline:3.25.0" +turtle = "com.lordcodes.turtle:turtle:0.9.0" junit4 = "junit:junit:4.13.2" diff --git a/wordle-compose-mosaic/build.gradle.kts b/wordle-compose-mosaic/build.gradle.kts index dbd939f..60e4508 100644 --- a/wordle-compose-mosaic/build.gradle.kts +++ b/wordle-compose-mosaic/build.gradle.kts @@ -8,6 +8,9 @@ dependencies { implementation(libs.jline) { because("need to handle terminal keyboard input") } + implementation(libs.turtle) { + because("need to copy results to clipboard (using `pbcopy`, `xclip`, `clip` or equivalent)") + } implementation(project(":word-data")) implementation(project(":game-logic")) } diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/PlatformUtil.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/PlatformUtil.kt new file mode 100644 index 0000000..f8ef69c --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/PlatformUtil.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle + +import com.lordcodes.turtle.shellRun +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +enum class OSName { + UNKNOWN, + WINDOWS, + MAC, + LINUX, +} + +val OS: OSName + get() { + val os = System.getProperty("os.name").lowercase() + return when { + os.contains("mac") -> OSName.MAC + os.contains("win") -> OSName.WINDOWS + os.contains("nix") || os.contains("nux") || os.contains("aix") -> OSName.LINUX + else -> OSName.UNKNOWN + } + } + +suspend fun String.copyToClipboard(): Boolean { + // TODO check for command availability and find fallbacks if possible + val copyCommand = when (OS) { + OSName.MAC -> "pbcopy" + OSName.WINDOWS -> "clip" // in WSL2 there is clipcopy + OSName.LINUX -> "xclip" // there is also xsel --clipboard --input + else -> null + } + return if (copyCommand != null) { + withContext(Dispatchers.IO) { + try { + // XXXcopy <<< "str" + shellRun("/bin/sh", listOf("-c", "$copyCommand <<< \"${this@copyToClipboard}\"")) + true + } catch (e: Exception) { + false + } + } + } else { + false + } +} From dc8db177c9bd35e80df67c03e6a03d13a1ead834 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Thu, 13 Jan 2022 22:27:42 +0100 Subject: [PATCH 19/19] [Mosaic] Use underlying platform copy to clipboard mechanism on victory in Mosaic version --- .../opatry/game/wordle/mosaic/gameScreen.kt | 37 ++++++++++++------- .../opatry/game/wordle/ui/WordleViewModel.kt | 17 ++++++--- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt index 0b66023..a8f7adb 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt @@ -23,10 +23,14 @@ package net.opatry.game.wordle.mosaic import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.jakewharton.mosaic.ui.Color import com.jakewharton.mosaic.ui.Column import com.jakewharton.mosaic.ui.Row import com.jakewharton.mosaic.ui.Text +import com.jakewharton.mosaic.ui.TextStyle import net.opatry.game.wordle.State +import net.opatry.game.wordle.copyToClipboard import net.opatry.game.wordle.mosaic.component.Alphabet import net.opatry.game.wordle.mosaic.component.WordleGrid import net.opatry.game.wordle.ui.WordleViewModel @@ -46,31 +50,38 @@ fun GameScreen(viewModel: WordleViewModel) { Text("") + // There must be stable number of lines for nice UI state. + // All states should display 3 lines. when (val state = viewModel.state) { is State.Won -> { + LaunchedEffect(viewModel.state) { + viewModel.stateLabel.copyToClipboard() + } + Text("Wordle ${state.answers.size}/${state.maxTries}") - Text(viewModel.answer) + Text("Results copied to clipboard!") // FIXME depends on copyToClipboard success + Text(" 🔄 Play again? (y/N)?") } is State.Lost -> { Text("Wordle X/${state.maxTries}") - Text(viewModel.answer) + Row { + Text("The answer was ") + Text( + viewModel.answer, + color = Color.BrightWhite, + background = Color.Green, + style = TextStyle.Bold + ) + } + Text(" 🔄 Play again? (y/N)?") } is State.Playing -> { - Text(" ➡️ Enter a 5 letter english word") Text("") // TODO display error here if any or define a placeholder on top of grid + Text("") + Text(" ➡️ Enter a 5 letter english word") } } - -// viewModel.state.toClipboard() -// println("Results copied to clipboard!") - - // there must be stable number of lines for nice UI state - if (viewModel.state !is State.Playing) { - Text(" 🔄 Play again? (y/N)? ${viewModel.userInput}") - } else { - Text("") - } } } diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/WordleViewModel.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/WordleViewModel.kt index c1bfb32..e86dc5d 100644 --- a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/WordleViewModel.kt +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/WordleViewModel.kt @@ -49,12 +49,17 @@ private val State.message: String } } -private fun StringBuffer.appendClipboardAnswer(answer: Answer) { - answer.flags.map(AnswerFlag::toEmoji).forEach(::append) - append('\n') +private fun StringBuffer.appendAnswer(answer: Answer) { + append( + answer.flags.joinToString( + separator = " ", + postfix = "\n", + transform = AnswerFlag::toEmoji + ) + ).trimEnd() } -private fun State.toClipboard(): String { +private fun State.toResultString(): String { val buffer = StringBuffer() buffer.append( when (this) { @@ -63,7 +68,7 @@ private fun State.toClipboard(): String { else -> "" } ) - answers.forEach(buffer::appendClipboardAnswer) + answers.forEach(buffer::appendAnswer) return buffer.toString() } @@ -73,7 +78,7 @@ class WordleViewModel(private var rules: WordleRules) { private set var state by mutableStateOf(rules.state) val stateLabel: String - get() = rules.state.toClipboard() + get() = rules.state.toResultString() var victory by mutableStateOf(rules.state is State.Won) private set var answer by mutableStateOf("")