diff --git a/android/gradle.properties b/android/gradle.properties index bc2d95e8567abcfd41c26ebeb95fced48f43e773..818e87b23b224ced309ae5c147e5ed827826e237 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,5 +1,5 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true -app.versionName=0.0.1 -app.versionCode=1 +app.versionName=0.0.2 +app.versionCode=2 diff --git a/assets/icons/button_back.png b/assets/icons/button_back.png deleted file mode 100644 index 2a802ff2d2bc0c90488a1c319288be9f28cdea5e..0000000000000000000000000000000000000000 Binary files a/assets/icons/button_back.png and /dev/null differ diff --git a/assets/icons/button_restart.png b/assets/icons/button_restart.png new file mode 100644 index 0000000000000000000000000000000000000000..389eabc630dcb6d973aae1b616e1783dbc414ca6 Binary files /dev/null and b/assets/icons/button_restart.png differ diff --git a/assets/icons/button_start.png b/assets/icons/button_start.png deleted file mode 100644 index 0a3b74a3b98f45de590a7f290b810daba7120912..0000000000000000000000000000000000000000 Binary files a/assets/icons/button_start.png and /dev/null differ diff --git a/assets/icons/difficulty_easy.png b/assets/icons/difficulty_easy.png deleted file mode 100644 index 66f1476dadfda62b03378a040f10737d9edbab1c..0000000000000000000000000000000000000000 Binary files a/assets/icons/difficulty_easy.png and /dev/null differ diff --git a/assets/icons/difficulty_hard.png b/assets/icons/difficulty_hard.png deleted file mode 100644 index 2c1aef8bb260d6e5241a0856c11cfae129c9d24e..0000000000000000000000000000000000000000 Binary files a/assets/icons/difficulty_hard.png and /dev/null differ diff --git a/assets/icons/difficulty_medium.png b/assets/icons/difficulty_medium.png deleted file mode 100644 index 146d9b9ca714b9c82e9a5188e88e5cc8736b893e..0000000000000000000000000000000000000000 Binary files a/assets/icons/difficulty_medium.png and /dev/null differ diff --git a/assets/icons/empty.png b/assets/icons/empty.png new file mode 100644 index 0000000000000000000000000000000000000000..28f81cf18e57d73b9cce53e1ae7a0525f3bdedbc Binary files /dev/null and b/assets/icons/empty.png differ diff --git a/assets/empty.png b/assets/skins/default_tile.png similarity index 100% rename from assets/empty.png rename to assets/skins/default_tile.png diff --git a/assets/skins/default_tile_black.png b/assets/skins/default_tile_black.png index c9147334912e10a656a748dacd9e5ee8a51210bb..5fed42df2e38f0385dfb1bc1fe4f2c1c020d8bec 100644 Binary files a/assets/skins/default_tile_black.png and b/assets/skins/default_tile_black.png differ diff --git a/assets/skins/default_tile_empty.png b/assets/skins/default_tile_empty.png new file mode 100644 index 0000000000000000000000000000000000000000..586de0573e7f77d97c16942f603b8ee6d9c7d7bc Binary files /dev/null and b/assets/skins/default_tile_empty.png differ diff --git a/assets/skins/default_tile_white.png b/assets/skins/default_tile_white.png index 7ac8407cc621b5931503d80be48443d44fead163..afd8939dc1e839364210e534ed908e6e0bc1a6e1 100644 Binary files a/assets/skins/default_tile_white.png and b/assets/skins/default_tile_white.png differ diff --git a/fastlane/metadata/android/en-US/changelogs/2.txt b/fastlane/metadata/android/en-US/changelogs/2.txt index fe56ebf91e506ee59b7d03cf8b12af27f3218d98..d636f6b3db88af84786df7618f6cc7f821f81a22 100644 --- a/fastlane/metadata/android/en-US/changelogs/2.txt +++ b/fastlane/metadata/android/en-US/changelogs/2.txt @@ -1 +1 @@ -Improve CI/CD, add jabber notification on create tag +Add minimal gameplay diff --git a/fastlane/metadata/android/en-US/changelogs/3.txt b/fastlane/metadata/android/en-US/changelogs/3.txt deleted file mode 100644 index c4767c31cd60be21ad424c3e0b8357a7a045e229..0000000000000000000000000000000000000000 --- a/fastlane/metadata/android/en-US/changelogs/3.txt +++ /dev/null @@ -1 +0,0 @@ -Add minimal playable game. diff --git a/fastlane/metadata/android/en-US/changelogs/4.txt b/fastlane/metadata/android/en-US/changelogs/4.txt deleted file mode 100644 index 60ec69f6163967c4cd86a7c5123de298689182f8..0000000000000000000000000000000000000000 --- a/fastlane/metadata/android/en-US/changelogs/4.txt +++ /dev/null @@ -1 +0,0 @@ -Finalize minimal gameplay. diff --git a/fastlane/metadata/android/en-US/changelogs/5.txt b/fastlane/metadata/android/en-US/changelogs/5.txt deleted file mode 100644 index 8b03873cfea4fcffcd65ec9c0133ec0fe39820a3..0000000000000000000000000000000000000000 --- a/fastlane/metadata/android/en-US/changelogs/5.txt +++ /dev/null @@ -1 +0,0 @@ -Remove flag on cell when explored. diff --git a/fastlane/metadata/android/en-US/changelogs/6.txt b/fastlane/metadata/android/en-US/changelogs/6.txt deleted file mode 100644 index 9ff6c06121da32c2c920f8bc13b6304cda653493..0000000000000000000000000000000000000000 --- a/fastlane/metadata/android/en-US/changelogs/6.txt +++ /dev/null @@ -1 +0,0 @@ -Ensure first cell is not mined (postone put mines on board) diff --git a/fastlane/metadata/android/fr-FR/changelogs/2.txt b/fastlane/metadata/android/fr-FR/changelogs/2.txt index cdf380ac4f91ab3c0d9d07ac7af06ee46adfa7a2..7e81cee46211e6c898312e24098e852386290b34 100644 --- a/fastlane/metadata/android/fr-FR/changelogs/2.txt +++ b/fastlane/metadata/android/fr-FR/changelogs/2.txt @@ -1 +1 @@ -Amélioration de la chaîne CI/CD, envoi d'un message à la création de version +Ajout du jeu minimal diff --git a/fastlane/metadata/android/fr-FR/changelogs/3.txt b/fastlane/metadata/android/fr-FR/changelogs/3.txt deleted file mode 100644 index 75d529883f8fa154d4ea9bd7307daab48f0680d0..0000000000000000000000000000000000000000 --- a/fastlane/metadata/android/fr-FR/changelogs/3.txt +++ /dev/null @@ -1 +0,0 @@ -Ajout du jeu minimal, jouable. diff --git a/fastlane/metadata/android/fr-FR/changelogs/4.txt b/fastlane/metadata/android/fr-FR/changelogs/4.txt deleted file mode 100644 index 6f741b5ea2b0522dbf8d43262f726a9d86e4702d..0000000000000000000000000000000000000000 --- a/fastlane/metadata/android/fr-FR/changelogs/4.txt +++ /dev/null @@ -1 +0,0 @@ -Finalisation du jeu minimal. diff --git a/fastlane/metadata/android/fr-FR/changelogs/5.txt b/fastlane/metadata/android/fr-FR/changelogs/5.txt deleted file mode 100644 index 0b46e4f89d48042f40111a1cb1678e65a7ce22f6..0000000000000000000000000000000000000000 --- a/fastlane/metadata/android/fr-FR/changelogs/5.txt +++ /dev/null @@ -1 +0,0 @@ -Enlève le marquage sur les cellules parcourues. diff --git a/fastlane/metadata/android/fr-FR/changelogs/6.txt b/fastlane/metadata/android/fr-FR/changelogs/6.txt deleted file mode 100644 index 0c8a46bf50f92898aae7011440789c5eb94437d5..0000000000000000000000000000000000000000 --- a/fastlane/metadata/android/fr-FR/changelogs/6.txt +++ /dev/null @@ -1 +0,0 @@ -Impossibilité de tomber sur une mine au premier coup (reporte la génération de la grille) diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt index 170a1a633d68331f0ae329b7d3af80123aac1ce5..3369f597ba7389af90d58962de34112f9eb8b8f4 100644 --- a/fastlane/metadata/android/fr-FR/full_description.txt +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -1 +1 @@ -Jeu du démineur simple +Jeu du reversi simple diff --git a/fastlane/metadata/android/fr-FR/short_description.txt b/fastlane/metadata/android/fr-FR/short_description.txt index 170a1a633d68331f0ae329b7d3af80123aac1ce5..3369f597ba7389af90d58962de34112f9eb8b8f4 100644 --- a/fastlane/metadata/android/fr-FR/short_description.txt +++ b/fastlane/metadata/android/fr-FR/short_description.txt @@ -1 +1 @@ -Jeu du démineur simple +Jeu du reversi simple diff --git a/fastlane/metadata/android/fr-FR/title.txt b/fastlane/metadata/android/fr-FR/title.txt index 170a1a633d68331f0ae329b7d3af80123aac1ce5..3369f597ba7389af90d58962de34112f9eb8b8f4 100644 --- a/fastlane/metadata/android/fr-FR/title.txt +++ b/fastlane/metadata/android/fr-FR/title.txt @@ -1 +1 @@ -Jeu du démineur simple +Jeu du reversi simple diff --git a/icons/build_application_icons.sh b/icons/build_application_icons.sh index 9a5488a87fc7e4376fde76429e03c066830ccfcb..7909dd523da1283627fabba793bdc7b458aa0d51 100755 --- a/icons/build_application_icons.sh +++ b/icons/build_application_icons.sh @@ -46,6 +46,7 @@ function build_asset_image() { function build_icons_for_skin() { SKIN_CODE="$1" + build_icon_for_skin ${SKIN_CODE} tile_empty build_icon_for_skin ${SKIN_CODE} tile_black build_icon_for_skin ${SKIN_CODE} tile_white } @@ -58,13 +59,9 @@ function build_icon_for_skin() { } # Game icons -build_asset_image ${CURRENT_DIR}/button_back.svg ${BASE_DIR}/assets/icons/button_back.png -build_asset_image ${CURRENT_DIR}/button_start.svg ${BASE_DIR}/assets/icons/button_start.png -build_asset_image ${CURRENT_DIR}/difficulty_easy.svg ${BASE_DIR}/assets/icons/difficulty_easy.png -build_asset_image ${CURRENT_DIR}/difficulty_medium.svg ${BASE_DIR}/assets/icons/difficulty_medium.png -build_asset_image ${CURRENT_DIR}/difficulty_hard.svg ${BASE_DIR}/assets/icons/difficulty_hard.png +build_asset_image ${CURRENT_DIR}/button_restart.svg ${BASE_DIR}/assets/icons/button_restart.png +build_asset_image ${CURRENT_DIR}/empty.svg ${BASE_DIR}/assets/icons/empty.png build_asset_image ${CURRENT_DIR}/game_win.svg ${BASE_DIR}/assets/icons/game_win.png -build_asset_image ${CURRENT_DIR}/empty.svg ${BASE_DIR}/assets/empty.png # Skins build_icons_for_skin "default" diff --git a/icons/button_back.svg b/icons/button_back.svg deleted file mode 100644 index 2622a578dba53ce582afabfc587c2a85a1fb6eaa..0000000000000000000000000000000000000000 --- a/icons/button_back.svg +++ /dev/null @@ -1,2 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<svg enable-background="new 0 0 100 100" version="1.1" viewBox="0 0 93.665 93.676" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x=".44662" y=".89101" width="92.772" height="91.894" ry="11.689" fill="#e41578" stroke="#fff" stroke-width=".238"/><path d="m59.387 71.362c1.1248 1.1302 4.0012 1.1302 4.0012 0v-45.921c0-1.1316-2.8832-1.1316-4.0121 0l-37.693 20.918c-1.1289 1.1248-1.1479 2.9551-0.02171 4.084z" fill="#fefeff" stroke="#930e4e" stroke-linecap="round" stroke-linejoin="round" stroke-width="8.257"/><path d="m57.857 68.048c0.96243 0.96706 3.4236 0.96706 3.4236 0v-39.292c0-0.96825-2.467-0.96825-3.4329 0l-32.252 17.898c-0.96594 0.96243-0.9822 2.5285-0.01858 3.4945z" fill="#fefeff" stroke="#feffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4.314"/></svg> diff --git a/icons/button_restart.svg b/icons/button_restart.svg new file mode 100644 index 0000000000000000000000000000000000000000..1d18f3b4122b5df597f3be46abfd0770affb3ca6 --- /dev/null +++ b/icons/button_restart.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg enable-background="new 0 0 100 100" version="1.1" viewBox="0 0 93.665 93.676" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x=".44662" y=".89101" width="92.772" height="91.894" ry="11.689" fill="#49a1ee" stroke="#fff" stroke-width=".238"/><switch transform="matrix(.69484 0 0 .69484 12.09 12.096)" fill="#fefeff" stroke="#105ca1" stroke-width="5.7567"><foreignObject width="1" height="1" requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/"/><g fill="#fefeff" stroke="#105ca1" stroke-width="5.7567"><g fill="#fefeff" stroke="#105ca1" stroke-width="5.7567"><path d="m43.3 91.8-18.3 5.5c-2.5 0.7-5.1-0.7-5.9-3.1-0.7-2.5 0.7-5.1 3.1-5.9l6.8-2c-12.4-7.3-20.8-20.8-20.8-36.3 0-17.3 10.6-32.2 25.6-38.6 3.1-1.3 6.6 1 6.6 4.3 0 1.9-1.1 3.6-2.8 4.3-11.8 5-20 16.5-20 30 0 12.2 6.8 22.9 16.8 28.4l-2.4-8.1c-0.7-2.5 0.7-5.1 3.1-5.9 2.5-0.7 5.1 0.7 5.9 3.1l5.5 18.3c0.7 2.6-0.7 5.2-3.2 6z"/><path d="m77.7 11.7-6.8 2c12.5 7.3 20.9 20.8 20.9 36.3 0 17.3-10.6 32.2-25.6 38.6-3.1 1.3-6.6-1-6.6-4.3 0-1.9 1.1-3.6 2.8-4.3 11.8-5 20-16.5 20-30 0-12.2-6.8-22.9-16.8-28.4l2.4 8.1c0.7 2.5-0.7 5.1-3.1 5.9-2.5 0.7-5.1-0.7-5.9-3.1l-5.5-18.3c-0.7-2.5 0.7-5.1 3.2-5.9l18.3-5.6c2.5-0.7 5.1 0.7 5.9 3.1 0.7 2.5-0.7 5.2-3.2 5.9z"/></g></g></switch></svg> diff --git a/icons/button_start.svg b/icons/button_start.svg deleted file mode 100644 index e9d49d2172b9a0305db82779971e3c1e12f34a70..0000000000000000000000000000000000000000 --- a/icons/button_start.svg +++ /dev/null @@ -1,2 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<svg enable-background="new 0 0 100 100" version="1.1" viewBox="0 0 93.665 93.676" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x=".44662" y=".89101" width="92.772" height="91.894" ry="11.689" fill="#49a1ee" stroke="#fff" stroke-width=".238"/><path d="m34.852 25.44c-1.1248-1.1302-4.0012-1.1302-4.0012 0v45.921c0 1.1316 2.8832 1.1316 4.0121 0l37.693-20.918c1.1289-1.1248 1.1479-2.9551 0.02171-4.084z" fill="#fefeff" stroke="#105ca1" stroke-linecap="round" stroke-linejoin="round" stroke-width="8.257"/><path d="m36.382 28.754c-0.96243-0.96706-3.4236-0.96706-3.4236 0v39.292c0 0.96825 2.467 0.96825 3.4329 0l32.252-17.898c0.96594-0.96243 0.9822-2.5285 0.01858-3.4945z" fill="#fefeff" stroke="#feffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4.314"/></svg> diff --git a/icons/difficulty_easy.svg b/icons/difficulty_easy.svg deleted file mode 100644 index da0e21f6c94972d3e3f19d21cd44d4f712e3174d..0000000000000000000000000000000000000000 --- a/icons/difficulty_easy.svg +++ /dev/null @@ -1,2 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<svg enable-background="new 0 0 100 100" version="1.1" viewBox="0 0 102 102" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="100" height="100" ry="0" fill="#41ff6a" stroke="#000" stroke-width="2"/><path d="m23.408 53.415 20.8 13.7c0.2 0.1 0.3 0.2 0.5 0.3 1.3 0.5 2.8-0.1 3.3-1.3 0.5-1.3-0.1-2.8-1.3-3.3l-23.2-9.6c-0.2 0-0.3 0.2-0.1 0.2zm23.7 11.6c0.1 0.8-0.5 1.4-1.3 1.5s-1.4-0.5-1.5-1.3 0.5-1.4 1.3-1.5c0.8 0 1.5 0.5 1.5 1.3z"/><path d="m41.708 36.515c0.1 0.4 0.6 0.7 1 0.7 0.9-0.1 1.9-0.1 2.8-0.1 1.5 0 2.9 0.1 4.3 0.3 0.6 0.1 1.2-0.4 1.2-1v-6.9c0-0.6-0.5-1-1-1-3.2 0.1-6.4 0.7-9.3 1.6-0.5 0.2-0.8 0.7-0.6 1.3z"/><path d="m52.508 37.815c2.1 0.5 4.2 1.3 6.2 2.2 0.5 0.3 1.2 0 1.4-0.6l2.7-8.2c0.2-0.5-0.1-1.1-0.6-1.3-3-0.9-6.1-1.5-9.3-1.6-0.6 0-1 0.4-1 1v7.5c-0.1 0.5 0.2 0.9 0.6 1z"/><path d="m81.508 46.115-8.4 6.1c-0.4 0.3-0.5 0.8-0.3 1.3 0.9 1.6 1.6 3.2 2.2 4.9 0.2 0.5 0.7 0.8 1.2 0.6l9.8-3.2c0.5-0.2 0.8-0.7 0.6-1.3-1-2.9-2.3-5.7-3.8-8.2-0.2-0.4-0.9-0.6-1.3-0.2z"/><path d="m32.908 40.015c2.3-1.1 4.7-2 7.2-2.5 0.6-0.1 1-0.7 0.8-1.3l-1.5-4.7c-0.2-0.5-0.8-0.8-1.3-0.6-2.9 1.2-5.7 2.7-8.2 4.6-0.4 0.3-0.5 0.9-0.2 1.4l2 2.8c0.2 0.4 0.8 0.5 1.2 0.3z"/><path d="m63.408 31.615-2.8 8.6c-0.1 0.4 0 0.9 0.4 1.1 1.7 1 3.4 2.2 4.9 3.6 0.4 0.4 1.1 0.3 1.5-0.1l5.7-7.9c0.3-0.4 0.2-1.1-0.2-1.4-2.5-1.9-5.3-3.4-8.2-4.6-0.5-0.2-1.1 0.1-1.3 0.7z"/><path d="m67.908 46.815c1.3 1.4 2.5 2.8 3.5 4.4 0.3 0.5 0.9 0.6 1.4 0.3l8.3-6c0.4-0.3 0.5-0.9 0.2-1.4-1.8-2.5-3.9-4.8-6.2-6.8-0.4-0.4-1.1-0.3-1.5 0.2l-5.9 8.1c-0.2 0.3-0.2 0.8 0.2 1.2z"/><path d="m24.408 46.315c1.8-2 3.9-3.7 6.2-5.1 0.5-0.3 0.6-1 0.3-1.4l-1.8-2.4c-0.3-0.5-1-0.6-1.5-0.2-2.3 2-4.4 4.3-6.2 6.8-0.3 0.4-0.2 1.1 0.2 1.4l1.4 1c0.5 0.3 1 0.3 1.4-0.1z"/><path d="m13.808 54.815 6 2c0.5 0.2 1-0.1 1.2-0.6 1.1-2.6 2.2-4.3 3.9-6.4 0.4-0.4 0.3-1.1-0.2-1.4l-5.9-4.2c-0.5-0.3-1.1-0.2-1.4 0.3-1.6 2.5-3.2 6.1-4.1 9-0.3 0.6 0 1.2 0.5 1.3z"/><path d="m75.808 60.915c0.4 1.7 0.7 3.5 0.9 5.3 0 0.5 0.5 0.9 1 0.9h10.2c0.6 0 1-0.5 1-1-0.1-3.1-0.5-6-1.3-8.9-0.1-0.6-0.7-0.9-1.3-0.7l-9.8 3.2c-0.6 0.2-0.9 0.7-0.7 1.2z"/><path d="m15.808 67.115c0.1-3.2 0.7-6.3 1.7-9.2 0.2-0.5-0.1-1.1-0.6-1.3l-0.4-0.1c-0.5-0.2-1.1 0.1-1.3 0.7-0.8 3.2-1.3 6.5-1.3 9.9z"/></svg> diff --git a/icons/difficulty_hard.svg b/icons/difficulty_hard.svg deleted file mode 100644 index 254346afe6f2b8c3cf79ee079881802f863caee9..0000000000000000000000000000000000000000 --- a/icons/difficulty_hard.svg +++ /dev/null @@ -1,2 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<svg enable-background="new 0 0 100 100" version="1.1" viewBox="0 0 102 102" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="100" height="100" ry="0" fill="#d31158" stroke="#000" stroke-width="2"/><path d="m69.154 59.254-24.4 5.1c-0.2 0-0.4 0.1-0.5 0.1-1.3 0.6-1.9 2-1.4 3.3 0.6 1.3 2 1.9 3.3 1.4l23-9.7c0.2 0 0.1-0.2 0-0.2zm-25 8.7c-0.6-0.5-0.6-1.4-0.1-2s1.4-0.6 2-0.1 0.6 1.4 0.1 2c-0.5 0.5-1.4 0.6-2 0.1z"/><path d="m41.054 38.354c0.1 0.4 0.6 0.7 1 0.7 0.9-0.1 1.9-0.1 2.8-0.1 1.5 0 2.9 0.1 4.3 0.3 0.6 0.1 1.2-0.4 1.2-1v-6.9c0-0.6-0.5-1-1-1-3.2 0.1-6.4 0.7-9.3 1.6-0.5 0.2-0.8 0.7-0.6 1.3z"/><path d="m51.854 39.754c2.1 0.5 4.2 1.3 6.2 2.2 0.5 0.3 1.2 0 1.4-0.6l2.7-8.2c0.2-0.5-0.1-1.1-0.6-1.3-3-0.9-6.1-1.5-9.3-1.6-0.6 0-1 0.4-1 1v7.5c-0.2 0.5 0.1 0.9 0.6 1z"/><path d="m82.754 46.554-12.2 8.9c-0.4 0.3-0.5 0.8-0.3 1.3 0.9 1.6 1.2 2.6 1.8 4.3 0.2 0.5 0.7 0.8 1.2 0.6l14.4-4.7c0.5-0.2 0.8-0.7 0.6-1.3-1-2.9-2.6-6.4-4.1-8.9-0.3-0.5-1-0.6-1.4-0.2z"/><path d="m32.154 41.854c2.3-1.1 4.7-2 7.2-2.5 0.6-0.1 1-0.7 0.8-1.3l-1.5-4.7c-0.2-0.5-0.8-0.8-1.3-0.6-2.9 1.2-5.7 2.7-8.2 4.6-0.4 0.3-0.5 0.9-0.2 1.4l2 2.8c0.3 0.4 0.8 0.5 1.2 0.3z"/><path d="m62.754 33.454-2.8 8.6c-0.1 0.4 0 0.9 0.4 1.1 1.7 1 3.4 2.2 4.9 3.6 0.4 0.4 1.1 0.3 1.5-0.1l5.7-7.9c0.3-0.4 0.2-1.1-0.2-1.4-2.5-1.9-5.3-3.4-8.2-4.6-0.5-0.1-1.1 0.2-1.3 0.7z"/><path d="m67.154 48.654c1.3 1.4 2.5 2.8 3.5 4.4 0.3 0.5 0.9 0.6 1.4 0.3l8.3-6c0.4-0.3 0.5-0.9 0.2-1.4-1.8-2.5-3.9-4.8-6.2-6.8-0.4-0.4-1.1-0.3-1.5 0.2l-5.9 8.1c-0.2 0.3-0.1 0.9 0.2 1.2z"/><path d="m23.654 48.154c1.8-2 3.9-3.7 6.2-5.1 0.5-0.3 0.6-1 0.3-1.4l-1.7-2.4c-0.3-0.5-1-0.6-1.5-0.2-2.3 2-4.4 4.3-6.2 6.8-0.3 0.4-0.2 1.1 0.2 1.4l1.4 1c0.4 0.4 1 0.3 1.3-0.1z"/><path d="m15.954 57.654 0.5 0.2c0.5 0.2 1-0.1 1.2-0.6 1.1-2.6 2.5-5 4.2-7.1 0.4-0.4 0.3-1.1-0.2-1.4l-1.2-0.8c-0.3-0.4-1-0.2-1.3 0.3-1.6 2.5-2.9 5.3-3.8 8.2-0.2 0.5 0.1 1.1 0.6 1.2z"/><path d="m75.054 62.754c0.4 1.7 0.7 3.5 0.9 5.3 0 0.5 0.5 0.9 1 0.9h10.2c0.6 0 1-0.5 1-1-0.1-3.1-0.5-6-1.3-8.9-0.1-0.6-0.7-0.9-1.3-0.7l-9.8 3.2c-0.5 0.2-0.8 0.7-0.7 1.2z"/><path d="m15.054 68.954c0.1-3.2 0.7-6.3 1.7-9.2 0.2-0.5-0.1-1.1-0.6-1.3l-0.3-0.1c-0.5-0.2-1.1 0.1-1.3 0.7-0.8 3.2-1.3 6.5-1.3 9.9z"/></svg> diff --git a/icons/difficulty_medium.svg b/icons/difficulty_medium.svg deleted file mode 100644 index a6a8c5565ce3937fdb73613f7f53e14f1c888630..0000000000000000000000000000000000000000 --- a/icons/difficulty_medium.svg +++ /dev/null @@ -1,2 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<svg enable-background="new 0 0 100 100" version="1.1" viewBox="0 0 102 102" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="100" height="100" ry="0" fill="#eeb517" stroke="#000" stroke-width="2"/><path d="m53.928 43.009-11 21.7c-0.1 0.2-0.1 0.3-0.2 0.5-0.4 1.4 0.4 2.7 1.7 3.2 1.4 0.4 2.7-0.4 3.2-1.7l6.6-23.7c0-0.1-0.2-0.2-0.3 0zm-8.5 24.3c-0.8 0.1-1.5-0.4-1.6-1.1-0.1-0.8 0.4-1.5 1.1-1.6 0.8-0.1 1.5 0.4 1.6 1.1 0.1 0.8-0.4 1.5-1.1 1.6z"/><path d="m41.128 37.409c0.1 0.4 0.6 0.7 1 0.7 0.9-0.1 1.9-0.1 2.8-0.1 1.5 0 2.9 0.1 4.3 0.3 0.6 0.1 1.2-0.4 1.2-1v-6.9c0-0.6-0.5-1-1-1-3.2 0.1-6.4 0.7-9.3 1.6-0.5 0.2-0.8 0.7-0.6 1.3z"/><path d="m51.928 41.209c2.1 0.5 3.5 1 5.5 2 0.5 0.3 1.2 0 1.4-0.6l4.2-12.8c0.2-0.5-0.1-1.1-0.6-1.3-3-0.9-6.9-1.5-10.1-1.7-0.6 0-1 0.4-1 1v12.4c-0.1 0.5 0.2 0.9 0.6 1z"/><path d="m80.928 47.009-8.4 6.1c-0.4 0.3-0.5 0.8-0.3 1.3 0.9 1.6 1.6 3.2 2.2 4.9 0.2 0.5 0.7 0.8 1.2 0.6l9.8-3.2c0.5-0.2 0.8-0.7 0.6-1.3-1-2.9-2.3-5.7-3.8-8.2-0.2-0.4-0.9-0.5-1.3-0.2z"/><path d="m32.328 40.909c2.3-1.1 4.7-2 7.2-2.5 0.6-0.1 1-0.7 0.8-1.3l-1.5-4.7c-0.2-0.5-0.8-0.8-1.3-0.6-2.9 1.2-5.7 2.7-8.2 4.6-0.4 0.3-0.5 0.9-0.2 1.4l2 2.8c0.2 0.4 0.8 0.5 1.2 0.3z"/><path d="m62.828 32.509-2.8 8.6c-0.1 0.4 0 0.9 0.4 1.1 1.7 1 3.4 2.2 4.9 3.6 0.4 0.4 1.1 0.3 1.5-0.1l5.7-7.9c0.3-0.4 0.2-1.1-0.2-1.4-2.5-1.9-5.3-3.4-8.2-4.6-0.5-0.1-1.1 0.2-1.3 0.7z"/><path d="m67.328 47.709c1.3 1.4 2.5 2.8 3.5 4.4 0.3 0.5 0.9 0.6 1.4 0.3l8.3-6c0.4-0.3 0.5-0.9 0.2-1.4-1.8-2.5-3.9-4.8-6.2-6.8-0.4-0.4-1.1-0.3-1.5 0.2l-5.8 8.1c-0.3 0.3-0.3 0.9 0.1 1.2z"/><path d="m23.828 47.209c1.8-2 3.9-3.7 6.2-5.1 0.5-0.3 0.6-1 0.3-1.4l-1.8-2.4c-0.3-0.5-1-0.6-1.5-0.2-2.3 2-4.4 4.3-6.2 6.8-0.3 0.4-0.2 1.1 0.2 1.4l1.4 1c0.5 0.4 1 0.3 1.4-0.1z"/><path d="m16.128 56.709 0.5 0.2c0.5 0.2 1-0.1 1.2-0.6 1.1-2.6 2.5-5 4.2-7.1 0.4-0.4 0.3-1.1-0.2-1.4l-1.2-0.8c-0.5-0.3-1.1-0.2-1.4 0.3-1.6 2.5-2.9 5.3-3.8 8.2-0.1 0.5 0.2 1.1 0.7 1.2z"/><path d="m75.228 61.809c0.4 1.7 0.7 3.5 0.9 5.3 0 0.5 0.5 0.9 1 0.9h10.2c0.6 0 1-0.5 1-1-0.1-3.1-0.5-6-1.3-8.9-0.1-0.6-0.7-0.9-1.3-0.7l-9.8 3.2c-0.6 0.2-0.8 0.7-0.7 1.2z"/><path d="m15.228 68.009c0.1-3.2 0.7-6.3 1.7-9.2 0.2-0.5-0.1-1.1-0.6-1.3l-0.3-0.1c-0.5-0.2-1.1 0.1-1.3 0.7-0.8 3.2-1.3 6.5-1.3 9.9z"/></svg> diff --git a/icons/empty.svg b/icons/empty.svg index 6b7cca72d5b5fe5d116270f4c6d4aa081402fbee..d988c4937ec1d00ce581b412ff4a859a0fd1862d 100644 --- a/icons/empty.svg +++ b/icons/empty.svg @@ -1,2 +1,2 @@ <?xml version="1.0" encoding="UTF-8"?> -<svg enable-background="new 0 0 100 100" version="1.1" viewBox="0 0 100 100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" ry="2" fill="none"/></svg> +<svg enable-background="new 0 0 100 100" version="1.1" viewBox="0 0 93.665 93.676" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"/> diff --git a/icons/skins/default/tile_black.svg b/icons/skins/default/tile_black.svg index 76875fdd51faa445efb93d3e4f71067695c895d9..9bc87447b02e8879399d554e0badab996c28c541 100644 --- a/icons/skins/default/tile_black.svg +++ b/icons/skins/default/tile_black.svg @@ -1,2 +1,2 @@ <?xml version="1.0" encoding="UTF-8"?> -<svg enable-background="new 0 0 100 100" version="1.1" viewBox="0 0 100 100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" ry="2" fill="none"/><circle cx="50" cy="50" r="42.259" stroke="#919191" stroke-linecap="round" stroke-linejoin="round" stroke-width="6.37"/></svg> +<svg enable-background="new 0 0 100 100" version="1.1" viewBox="0 0 100 100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" ry="2" fill="none"/><circle cx="50" cy="50" r="42.259" stroke="#313131" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"/></svg> diff --git a/icons/skins/default/tile_empty.svg b/icons/skins/default/tile_empty.svg new file mode 100644 index 0000000000000000000000000000000000000000..6b7cca72d5b5fe5d116270f4c6d4aa081402fbee --- /dev/null +++ b/icons/skins/default/tile_empty.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg enable-background="new 0 0 100 100" version="1.1" viewBox="0 0 100 100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" ry="2" fill="none"/></svg> diff --git a/icons/skins/default/tile_white.svg b/icons/skins/default/tile_white.svg index a41d136e9cfa3c17ee63e1064e63890420512299..e26fc614bb65a4814727f6280495ad602807c934 100644 --- a/icons/skins/default/tile_white.svg +++ b/icons/skins/default/tile_white.svg @@ -1,2 +1,2 @@ <?xml version="1.0" encoding="UTF-8"?> -<svg enable-background="new 0 0 100 100" version="1.1" viewBox="0 0 100 100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" ry="2" fill="none"/><circle cx="50" cy="50" r="42.259" fill="#fff" stroke="#919191" stroke-linecap="round" stroke-linejoin="round" stroke-width="6.37"/></svg> +<svg enable-background="new 0 0 100 100" version="1.1" viewBox="0 0 100 100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" ry="2" fill="none"/><circle cx="50" cy="50" r="42.259" fill="#fff" stroke="#ececec" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"/></svg> diff --git a/lib/game_board.dart b/lib/game_board.dart new file mode 100644 index 0000000000000000000000000000000000000000..1591fbae261f9f857822bd263f4b9b7a376717fa --- /dev/null +++ b/lib/game_board.dart @@ -0,0 +1,255 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +enum PieceType { + empty, + black, + white, +} + +/// This method flips a black piece to a white one, and vice versa. I'm still +/// unsure about having it as a global function, but don't know where else to +/// put it. +PieceType getOpponent(PieceType player) => + (player == PieceType.black) ? PieceType.white : PieceType.black; + +/// A position on the reversi board. Just an [x] and [y] coordinate pair. +class Position { + final int x; + final int y; + + const Position(this.x, this.y); +} + +/// An immutable representation of a reversi game's board. +class GameBoard { + static final int height = 8; + static final int width = 8; + final List<List<PieceType>> rows; + + // Because calculating out all the available moves for a player can be + // expensive, they're cached here. + final _availableMoveCache = <PieceType, List<Position>>{}; + + /// Default constructor, which creates a board with pieces in starting + /// position. + GameBoard() : rows = _emptyBoard; + + /// Copy constructor. + GameBoard.fromGameBoard(GameBoard other) + : rows = List.generate(height, (i) => List.from(other.rows[i])); + + /// Retrieves the type of piece at a location on the game board. + PieceType getPieceAtLocation(int x, int y) { + assert(x >= 0 && x < width); + assert(y >= 0 && y < height); + return rows[y][x]; + } + + /// Gets the total number of pieces of a particular type. + int getPieceCount(PieceType pieceType) { + return rows.fold( + 0, + (s, e) => s + e.where((e) => e == pieceType).length, + ); + } + + /// Calculates the list of available moves on this board for a player. These + /// moves are calculated for the first call and cached for any subsequent + /// ones. + List<Position> getMovesForPlayer(PieceType player) { + if (player == PieceType.empty) { + return []; + } + + if (_availableMoveCache.containsKey(player)) { + return _availableMoveCache[player]!; + } + + final legalMoves = <Position>[]; + for (var x = 0; x < width; x++) { + for (var y = 0; y < width; y++) { + if (isLegalMove(x, y, player)) { + legalMoves.add(Position(x, y)); + } + } + } + + _availableMoveCache[player] = legalMoves; + return legalMoves; + } + + /// Returns a new GameBoard instance representing the state this one would + /// have after [player] puts a piece at [x],[y]. This method does not check if + /// the move was legal, and will blindly trust its input. + GameBoard updateForMove(int x, int y, PieceType player) { + assert(player != PieceType.empty); + final newBoard = GameBoard.fromGameBoard(this); + + if (!isLegalMove(x, y, player)) { + return newBoard; + } + + newBoard.rows[y][x] = player; + + for (var dx = -1; dx <= 1; dx++) { + for (var dy = -1; dy <= 1; dy++) { + if (dx == 0 && dy == 0) continue; + newBoard._traversePath(x, y, dx, dy, player, true); + } + } + + return newBoard; + } + + /// Returns true if it would be a legal move for [player] to put a piece down + /// at [x],[y]. + bool isLegalMove(int x, int y, PieceType player) { + assert(player != PieceType.empty); + assert(x >= 0 && x < width); + assert(y >= 0 && y < height); + + // It's occupied, yo. No can do. + if (rows[y][x] != PieceType.empty) return false; + + // Try each of the eight cardinal directions, looking for a row of opposing + // pieces to flip. + for (var dx = -1; dx <= 1; dx++) { + for (var dy = -1; dy <= 1; dy++) { + if (dx == 0 && dy == 0) continue; + if (_traversePath(x, y, dx, dy, player, false)) return true; + } + } + + // No flippable opponent pieces were found in any directions. This is not a + // legal move. + return false; + } + + // This method walks the board in one of eight cardinal directions (determined + // by the [dx] and [dy] parameters) beginning at [x],[y], and attempts to + // determine if a move at [x],[y] by [player] would result in pieces getting + // flipped. If so, the method returns true, otherwise false. If [flip] is set + // to true, the pieces are flipped in place to their new colors before the + // method returns. + bool _traversePath( + int x, int y, int dx, int dy, PieceType player, bool flip) { + var foundOpponent = false; + var curX = x + dx; + var curY = y + dy; + + while (curX >= 0 && curX < width && curY >= 0 && curY < height) { + if (rows[curY][curX] == PieceType.empty) { + // This path led to an empty spot rather than a legal move. + return false; + } else if (rows[curY][curX] == getOpponent(player)) { + // Update flag and keep going, hoping to hit one of player's pieces. + foundOpponent = true; + } else if (foundOpponent) { + // Found opposing pieces and then one of player's afterward. This is + // a legal move. + if (flip) { + // Backtrack, flipping pieces to player's color. + while (curX != x || curY != y) { + curX -= dx; + curY -= dy; + rows[curY][curX] = player; + } + } + return true; + } else { + // Found one of player's pieces, but no opposing pieces. + return false; + } + + curX += dx; + curY += dy; + } + + return false; + } +} + +const _emptyBoard = [ + [ + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + ], + [ + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + ], + [ + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + ], + [ + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.black, + PieceType.white, + PieceType.empty, + PieceType.empty, + PieceType.empty, + ], + [ + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.white, + PieceType.black, + PieceType.empty, + PieceType.empty, + PieceType.empty, + ], + [ + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + ], + [ + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + ], + [ + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + PieceType.empty, + ], +]; diff --git a/lib/game_board_scorer.dart b/lib/game_board_scorer.dart new file mode 100644 index 0000000000000000000000000000000000000000..ac314aa27145fc1e1a058e478025bdfab36bae62 --- /dev/null +++ b/lib/game_board_scorer.dart @@ -0,0 +1,64 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'game_board.dart'; + +class GameBoardScorer { + // Values for each position on the board. + static const _positionValues = [ + [10000, -1000, 100, 100, 100, 100, -1000, 10000], + [-1000, -1000, 1, 1, 1, 1, -1000, -1000], + [100, 1, 50, 50, 50, 50, 1, 100], + [100, 1, 50, 1, 1, 50, 1, 100], + [100, 1, 50, 1, 1, 50, 1, 100], + [100, 1, 50, 50, 50, 50, 1, 100], + [-1000, -1000, 1, 1, 1, 1, -1000, -1000], + [10000, -1000, 100, 100, 100, 100, -1000, 10000], + ]; + + /// Maximum and minimum values for scores, which are used in the minimax + /// algorithm in [MoveFinder]. + static const maxScore = 1000 * 1000 * 1000; + static const minScore = -1 * maxScore; + + final GameBoard board; + + GameBoardScorer(this.board); + + /// Returns the score of the board, as determined by what pieces are in place, + /// and how valuable their locations are. This is a very simple scoring + /// heuristic, but it's surprisingly effective. + int getScore(PieceType player) { + assert(player != PieceType.empty); + var opponent = getOpponent(player); + var score = 0; + + if (board.getMovesForPlayer(PieceType.black).isEmpty && + board.getMovesForPlayer(PieceType.white).isEmpty) { + // Game is over. + var playerCount = board.getPieceCount(player); + var opponentCount = board.getPieceCount(getOpponent(player)); + + if (playerCount > opponentCount) { + return maxScore; + } else if (playerCount < opponentCount) { + return minScore; + } else { + return 0; + } + } + + for (var y = 0; y < GameBoard.height; y++) { + for (var x = 0; x < GameBoard.width; x++) { + if (board.getPieceAtLocation(x, y) == player) { + score += _positionValues[y][x]; + } else if (board.getPieceAtLocation(x, y) == opponent) { + score -= _positionValues[y][x]; + } + } + } + + return score; + } +} diff --git a/lib/game_model.dart b/lib/game_model.dart new file mode 100644 index 0000000000000000000000000000000000000000..bb95262e3c46353a1d12ad96fb6e91fcc4255c3b --- /dev/null +++ b/lib/game_model.dart @@ -0,0 +1,58 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'game_board.dart'; + +/// A model representing the state of a game of reversi. It's a board and the +/// player who's next to go, essentially. +class GameModel { + late final GameBoard board; + final PieceType player; + final String _skin = 'default'; + + GameModel({ + required this.board, + this.player = PieceType.black, + }); + + String get skin => _skin; + + int get blackScore => board.getPieceCount(PieceType.black); + + int get whiteScore => board.getPieceCount(PieceType.white); + + bool get gameIsOver => (board.getMovesForPlayer(player).isEmpty); + + String get gameResultString { + if (blackScore > whiteScore) { + return 'Black wins.'; + } else if (whiteScore > blackScore) { + return 'White wins.'; + } else { + return 'Tie.'; + } + } + + /// Attempts to create a new instance of GameModel using the coordinates + /// provided as the current player's move. If successful, a new GameModel is + /// returned. If unsuccessful, null is returned. + GameModel updateForMove(int x, int y) { + if (!board.isLegalMove(x, y, player)) { + return null!; + } + + final newBoard = board.updateForMove(x, y, player); + PieceType nextPlayer; + + if (newBoard.getMovesForPlayer(getOpponent(player)).isNotEmpty) { + nextPlayer = getOpponent(player); + } else if (newBoard.getMovesForPlayer(player).isNotEmpty) { + nextPlayer = player; + } else { + nextPlayer = PieceType.empty; + } + + return GameModel(board: newBoard, player: nextPlayer); + } +} diff --git a/lib/main.dart b/lib/main.dart index dd1afff2ab481d34fc88bd2e0751f294b17f42ed..75b66dec53ff27807ac716bfa2717d328bffcbad 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,34 +1,324 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. -import 'provider/data.dart'; -import 'screens/home.dart'; +import 'dart:async'; +import 'package:async/async.dart'; +import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation; +import 'package:flutter/widgets.dart'; + +import 'game_board.dart'; +import 'game_model.dart'; +import 'move_finder.dart'; +import 'styling.dart'; +import 'thinking_indicator.dart'; + +/// Main function for the app. Turns off the system overlays and locks portrait +/// orientation for a more game-like UI, and then runs the [Widget] tree. void main() { WidgetsFlutterBinding.ensureInitialized(); - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]) - .then((value) => runApp(MyApp())); + + SystemChrome.setEnabledSystemUIOverlays([]); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + runApp(FlutterFlipApp()); } -class MyApp extends StatelessWidget { +/// The App class. Unlike many Flutter apps, this one does not use Material +/// widgets, so there's no [MaterialApp] or [Theme] objects. +class FlutterFlipApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return WidgetsApp( + color: Color(0xffffffff), // Mandatory background color. + onGenerateRoute: (settings) { + return PageRouteBuilder<dynamic>( + settings: settings, + pageBuilder: (context, animation, secondaryAnimation) => GameScreen(), + ); + }, + ); + } +} + +/// The [GameScreen] Widget represents the entire game +/// display, from scores to board state and everything in between. +class GameScreen extends StatefulWidget { + @override + State createState() => _GameScreenState(); +} + +/// State class for [GameScreen]. +/// +/// The game is modeled as a [Stream] of immutable instances of [GameModel]. +/// Each move by the player or CPU results in a new [GameModel], which is +/// sent downstream. [GameScreen] uses a [StreamBuilder] wired up to that stream +/// of models to build out its [Widget] tree. +class _GameScreenState extends State<GameScreen> { + final StreamController<GameModel> _userMovesController = + StreamController<GameModel>(); + final StreamController<GameModel> _restartController = + StreamController<GameModel>(); + Stream<GameModel>? _modelStream; + + _GameScreenState() { + // Below is the combination of streams that controls the flow of the game. + // There are two streams of models produced by player interaction (either by + // restarting the game, which produces a brand new game model and sends it + // downstream, or tapping on one of the board locations to play a piece, and + // which creates a new board model with the result of the move and sends it + // downstream. The StreamGroup combines these into a single stream, then + // does a little trick with asyncExpand. + // + // The function used in asyncExpand checks to see if it's the CPU's turn + // (white), and if so creates a [MoveFinder] to look for the best move. It + // awaits the calculation, and then creates a new [GameModel] with the + // result of that move and sends it downstream by yielding it. If it's still + // the CPU's turn after making that move (which can happen in reversi), this + // is repeated. + // + // The final stream of models that exits the asyncExpand call is a + // combination of "new game" models, models with the results of player + // moves, and models with the results of CPU moves. These are fed into the + // StreamBuilder in [build], and used to create the widgets that comprise + // the game's display. + _modelStream = StreamGroup.merge([ + _userMovesController.stream, + _restartController.stream, + ]).asyncExpand((model) async* { + yield model; + + var newModel = model; + + while (newModel.player == PieceType.white) { + final finder = MoveFinder(newModel.board); + final move = await finder.findNextMove(newModel.player, 5); + if (move != null) { + newModel = newModel.updateForMove(move.x, move.y); + yield newModel; + } + } + }); + } + + // Thou shalt tidy up thy stream controllers. + @override + void dispose() { + _userMovesController.close(); + _restartController.close(); + super.dispose(); + } + + /// The build method mostly just sets up the StreamBuilder and leaves the + /// details to _buildWidgets. @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (BuildContext context) => Data(), - child: Consumer<Data>(builder: (context, data, child) { - return MaterialApp( - debugShowCheckedModeBanner: false, - theme: ThemeData( - primaryColor: Colors.blue, - visualDensity: VisualDensity.adaptivePlatformDensity, + return StreamBuilder<GameModel>( + stream: _modelStream, + builder: (context, snapshot) { + return _buildWidgets( + context, + snapshot.hasData ? snapshot.data! : GameModel(board: GameBoard()), + ); + }, + ); + } + + // Called when the user taps on the game's board display. If it's the player's + // turn, this method will attempt to make the move, creating a new GameModel + // in the process. + void _attemptUserMove(GameModel model, int x, int y) { + if (model.player == PieceType.black && + model.board.isLegalMove(x, y, model.player)) { + _userMovesController.add(model.updateForMove(x, y)); + } + } + + Widget _buildScoreBox(PieceType player, GameModel model) { + var assetImageCode = player == PieceType.black ? 'black' : 'white'; + String assetImageName = 'assets/skins/' + model.skin + '_tile_' + assetImageCode + '.png'; + + var scoreText = player == PieceType.black + ? '${model.blackScore}' + : '${model.whiteScore}'; + + return Container( + padding: const EdgeInsets.symmetric( + vertical: 3.0, + horizontal: 30.0, + ), + decoration: (model.player == player) + ? Styling.activePlayerIndicator + : Styling.inactivePlayerIndicator, + child: Row( + children: <Widget>[ + SizedBox( + width: 30.0, + height: 30.0, + child: Image( + image: AssetImage(assetImageName), + fit: BoxFit.fill, + ), ), - home: Home(), - routes: { - Home.id: (context) => Home(), - }, + SizedBox( + width: 10.0, + ), + Text( + scoreText, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 50.0, + color: Color(0xff000000), + ), + ), + ], + ), + ); + } + + Widget _buildScoreBoxes(GameModel model) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Spacer(flex: 1), + _buildScoreBox(PieceType.black, model), + Spacer(flex: 4), + _buildScoreBox(PieceType.white, model), + Spacer(flex: 1), + ], + ); + } + + Table _buildGameBoardDisplay(BuildContext context, GameModel model) { + final rows = <TableRow>[]; + + for (var y = 0; y < GameBoard.height; y++) { + final cells = <Column>[]; + + for (var x = 0; x < GameBoard.width; x++) { + PieceType pieceType = model.board.getPieceAtLocation(x, y); + String? assetImageCode = Styling.assetImageCodes[pieceType]; + String assetImageName = 'assets/skins/' + model.skin + '_tile_' + (assetImageCode != null ? assetImageCode : 'empty') + '.png'; + + Column cell = Column( + children: [ + Container( + decoration: Styling.boardCellDecoration, + child: SizedBox( + child: GestureDetector( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + transitionBuilder: (Widget child, Animation<double> animation) { + return ScaleTransition(child: child, scale: animation); + }, + child: Image( + image: AssetImage(assetImageName), + fit: BoxFit.fill, + key: ValueKey<int>(pieceType == PieceType.empty ? 0 : (pieceType == PieceType.black ? 1 : 2)), + ), + ), + onTap: () { + _attemptUserMove(model, x, y); + }, + ), + ), + ) + ] ); - }), + + cells.add(cell); + } + + rows.add(TableRow(children: cells)); + } + + return Table( + defaultColumnWidth: IntrinsicColumnWidth(), + children: rows + ); + } + + Widget _buildThinkingIndicator(GameModel model) { + return ThinkingIndicator( + color: Styling.thinkingColor, + height: Styling.thinkingSize, + visible: model.player == PieceType.white, + ); + } + + Widget _buildRestartGameWidget() { + return Container( + padding: const EdgeInsets.symmetric( + vertical: 5.0, + horizontal: 15.0, + ), + child: Image( + image: AssetImage('assets/icons/button_restart.png'), + fit: BoxFit.fill, + ), + ); + } + + Widget _buildEndGameWidget(GameModel model) { + Image decorationImage = Image( + image: AssetImage( + model.gameResultString == 'black' + ? 'assets/icons/game_win.png' + : 'assets/icons/empty.png' + ), + fit: BoxFit.fill, + ); + + return Container( + margin: EdgeInsets.all(2), + padding: EdgeInsets.all(2), + + child: Table( + defaultColumnWidth: IntrinsicColumnWidth(), + children: [ + TableRow( + children: [ + Column(children: [ decorationImage ]), + Column(children: [ + GestureDetector( + onTap: () { + _restartController.add( + GameModel(board: GameBoard()), + ); + }, + child: _buildRestartGameWidget(), + ) + ]), + Column(children: [ decorationImage ]), + ], + ), + ] + ) + ); + } + + // Builds out the Widget tree using the most recent GameModel from the stream. + Widget _buildWidgets(BuildContext context, GameModel model) { + return Container( + padding: EdgeInsets.only(top: 30.0, left: 15.0, right: 15.0), + decoration: Styling.mainWidgetDecoration, + child: SafeArea( + child: Column( + children: [ + _buildScoreBoxes(model), + SizedBox(height: 10), + _buildGameBoardDisplay(context, model), + SizedBox(height: 10), + _buildThinkingIndicator(model), + SizedBox(height: 30), + if (model.gameIsOver) _buildEndGameWidget(model), + ], + ), + ), ); } } diff --git a/lib/move_finder.dart b/lib/move_finder.dart new file mode 100644 index 0000000000000000000000000000000000000000..32a37f96bbdf7aeb4188fb01390f6786f1f504ad --- /dev/null +++ b/lib/move_finder.dart @@ -0,0 +1,116 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'game_board.dart'; +import 'game_board_scorer.dart'; + +class MoveSearchArgs { + MoveSearchArgs( + {required this.board, required this.player, required this.numPlies}); + + final GameBoard board; + final PieceType player; + final int numPlies; +} + +/// A move and its score. Used by the minimax algorithm. +class ScoredMove { + final int score; + final Position move; + + const ScoredMove(this.score, this.move); +} + +// The [compute] function requires a top-level method as its first argument. +// This is that method for [MoveFinder]. +Position? _findNextMove(MoveSearchArgs args) { + final bestMove = _performSearchPly( + args.board, args.player, args.player, args.numPlies - 1); + return bestMove?.move; +} + +// This is a recursive implementation of minimax, an algorithm so old it has +// its own Wikipedia page: https://wikipedia.org/wiki/Minimax. +ScoredMove? _performSearchPly( + GameBoard board, + PieceType scoringPlayer, + PieceType player, + int pliesRemaining, +) { + final availableMoves = board.getMovesForPlayer(player); + + if (availableMoves.isEmpty) { + return null; + } + + var score = (scoringPlayer == player) + ? GameBoardScorer.minScore + : GameBoardScorer.maxScore; + ScoredMove? bestMove; + + for (var i = 0; i < availableMoves.length; i++) { + final newBoard = + board.updateForMove(availableMoves[i].x, availableMoves[i].y, player); + if (pliesRemaining > 0 && + newBoard.getMovesForPlayer(getOpponent(player)).isNotEmpty) { + // Opponent has next turn. + score = _performSearchPly( + newBoard, + scoringPlayer, + getOpponent(player), + pliesRemaining - 1, + )?.score ?? + 0; + } else if (pliesRemaining > 0 && + newBoard.getMovesForPlayer(player).isNotEmpty) { + // Opponent has no moves; player gets another turn. + score = _performSearchPly( + newBoard, + scoringPlayer, + player, + pliesRemaining - 1, + )?.score ?? + 0; + } else { + // Game is over or the search has reached maximum depth. + score = GameBoardScorer(newBoard).getScore(scoringPlayer); + } + + if (bestMove == null || + (score > bestMove.score && scoringPlayer == player) || + (score < bestMove.score && scoringPlayer != player)) { + bestMove = + ScoredMove(score, Position(availableMoves[i].x, availableMoves[i].y)); + } + } + + return bestMove; +} + +/// The [MoveFinder] class exists to provide its [findNextMove] method, which +/// uses the minimax algorithm to look for the best available move on +/// [initialBoard] a [GameBoardScorer] to provide the heuristic. +class MoveFinder { + final GameBoard initialBoard; + + MoveFinder(this.initialBoard); + + /// Searches the tree of possible moves on [initialBoard] to a depth of + /// [numPlies], looking for the best possible move for [player]. Because the + /// actual work is done in an isolate, a [Future] is used as the return value. + Future<Position?> findNextMove(PieceType player, int numPlies) { + return compute( + _findNextMove, + MoveSearchArgs( + board: initialBoard, + player: player, + numPlies: numPlies, + ), + ); + } +} diff --git a/lib/provider/data.dart b/lib/provider/data.dart deleted file mode 100644 index ee24df3f0b90f712741afee85f4cc35ed563f70b..0000000000000000000000000000000000000000 --- a/lib/provider/data.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/foundation.dart'; - -class Data extends ChangeNotifier { - - // Configuration available values - List _availableDifficultyLevels = ['easy', 'medium', 'hard']; - - List get availableDifficultyLevels => _availableDifficultyLevels; - - // Application default configuration - String _level = 'medium'; - String _size = '8x8'; - String _skin = 'default'; - - // Game data - bool _gameRunning = false; - int _sizeVertical = null; - int _sizeHorizontal = null; - List _cells = []; - - String get level => _level; - void updateLevel(String level) { - _level = level; - notifyListeners(); - } - - String get size => _size; - int get sizeVertical => _sizeVertical; - int get sizeHorizontal => _sizeHorizontal; - void updateSize(String size) { - _size = size; - _sizeHorizontal = int.parse(_size.split('x')[0]); - _sizeVertical = int.parse(_size.split('x')[1]); - notifyListeners(); - } - - String get skin => _skin; - void updateSkin(String skin) { - _skin = skin; - notifyListeners(); - } - - getParameterValue(String parameterCode) { - switch(parameterCode) { - case 'difficulty': { return _level; } - break; - } - } - - List getParameterAvailableValues(String parameterCode) { - switch(parameterCode) { - case 'difficulty': { return _availableDifficultyLevels; } - break; - } - } - - setParameterValue(String parameterCode, String parameterValue) { - switch(parameterCode) { - case 'difficulty': { updateLevel(parameterValue); } - break; - } - } - - List get cells => _cells; - void updateCells(List cells) { - _cells = cells; - notifyListeners(); - } - - bool get gameRunning => _gameRunning; - void updateGameRunning(bool gameRunning) { - _gameRunning = gameRunning; - notifyListeners(); - } -} diff --git a/lib/screens/home.dart b/lib/screens/home.dart deleted file mode 100644 index ba85d3740ca9ae317594ceb95e22d1d37f7034d0..0000000000000000000000000000000000000000 --- a/lib/screens/home.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../provider/data.dart'; - -class Home extends StatelessWidget { - static const String id = 'home'; - - @override - Widget build(BuildContext context) { - Data myProvider = Provider.of<Data>(context); - - return Scaffold( - appBar: AppBar( - title: new Text('Reversi'), - - actions: [ - ], - - leading: IconButton( - icon: Image.asset('assets/icons/application.png'), - onPressed: () { }, - ), - ), - body: SafeArea( - child: Center( - child: Text('🎮') - ), - ) - ); - } -} diff --git a/lib/styling.dart b/lib/styling.dart new file mode 100644 index 0000000000000000000000000000000000000000..1ece8b8bc8111cbdc6b6ca15c32b94bf5cad9199 --- /dev/null +++ b/lib/styling.dart @@ -0,0 +1,76 @@ +import 'package:flutter/widgets.dart'; + +import 'game_board.dart'; + +abstract class Styling { + // **** GRADIENTS AND COLORS **** + + static const Map<PieceType, String> assetImageCodes = { + PieceType.black: 'black', + PieceType.white: 'white', + PieceType.empty: 'empty', + }; + + static const BorderSide boardCellBorderSide = BorderSide( + color: Color(0xff108010), + width: 1.0, + ); + + static const BoxDecoration boardCellDecoration = BoxDecoration( + color: Color(0xff50bb50), + border: Border( + bottom: boardCellBorderSide, + top: boardCellBorderSide, + left: boardCellBorderSide, + right: boardCellBorderSide, + ), + ); + + static const BoxDecoration mainWidgetDecoration = BoxDecoration( + color: Color(0xffffffff) + ); + + static const thinkingColor = Color(0xff2196f3); + + // **** ANIMATIONS **** + + static const Duration thinkingFadeDuration = Duration(milliseconds: 500); + + static const pieceFlipDuration = Duration(milliseconds: 300); + + // **** SIZES **** + + static const thinkingSize = 10.0; + + // **** BOXES **** + + static const BorderSide activePlayerIndicatorBorder = BorderSide( + color: Color(0xff2196f3), + width: 10.0, + ); + + static const BorderSide inactivePlayerIndicatorBorder = BorderSide( + color: Color(0x00000000), + width: 10.0, + ); + + static const activePlayerIndicator = BoxDecoration( + borderRadius: const BorderRadius.all(const Radius.circular(10.0)), + border: Border( + bottom: activePlayerIndicatorBorder, + top: activePlayerIndicatorBorder, + left: activePlayerIndicatorBorder, + right: activePlayerIndicatorBorder, + ), + ); + + static const inactivePlayerIndicator = BoxDecoration( + borderRadius: const BorderRadius.all(const Radius.circular(10.0)), + border: Border( + bottom: inactivePlayerIndicatorBorder, + top: inactivePlayerIndicatorBorder, + left: inactivePlayerIndicatorBorder, + right: inactivePlayerIndicatorBorder, + ), + ); +} diff --git a/lib/thinking_indicator.dart b/lib/thinking_indicator.dart new file mode 100644 index 0000000000000000000000000000000000000000..ad3478099d4b49a83c57a4bd09b69e75a0ad6c4b --- /dev/null +++ b/lib/thinking_indicator.dart @@ -0,0 +1,143 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'styling.dart'; + +/// This is a self-animated progress spinner, only instead of spinning it +/// moves five little circles in a horizontal arrangement. +class ThinkingIndicator extends ImplicitlyAnimatedWidget { + final Color color; + final double height; + final bool visible; + + ThinkingIndicator({ + this.color = const Color(0xffffffff), + this.height = 10.0, + this.visible = true, + Key? key, + }) : super( + duration: Styling.thinkingFadeDuration, + key: key, + ); + + @override + ImplicitlyAnimatedWidgetState createState() => _ThinkingIndicatorState(); +} + +class _ThinkingIndicatorState + extends AnimatedWidgetBaseState<ThinkingIndicator> { + Tween<double>? _opacityTween; + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: SizedBox( + height: widget.height, + child: Opacity( + opacity: _opacityTween!.evaluate(animation!), + child: _opacityTween!.evaluate(animation!) != 0 + ? _AnimatedCircles( + color: widget.color, + height: widget.height, + ) + : null, + ), + ), + ); + } + + @override + void forEachTween(visitor) { + _opacityTween = visitor( + _opacityTween, + widget.visible ? 1.0 : 0.0, + (dynamic value) => Tween<double>(begin: value as double), + ) as Tween<double>?; + } +} + +class _AnimatedCircles extends StatefulWidget { + final Color color; + final double height; + + const _AnimatedCircles({ + required this.color, + required this.height, + Key? key, + }) : super(key: key); + + @override + _AnimatedCirclesState createState() => _AnimatedCirclesState(); +} + +class _AnimatedCirclesState extends State<_AnimatedCircles> + with SingleTickerProviderStateMixin { + late Animation<double> _thinkingAnimation; + late AnimationController _thinkingController; + + @override + void initState() { + super.initState(); + _thinkingController = AnimationController( + duration: const Duration(milliseconds: 500), vsync: this) + ..addStatusListener((status) { + // This bit ensures that the animation reverses course rather than + // stopping. + if (status == AnimationStatus.completed) _thinkingController.reverse(); + if (status == AnimationStatus.dismissed) _thinkingController.forward(); + }); + _thinkingAnimation = Tween(begin: 0.0, end: widget.height).animate( + CurvedAnimation(parent: _thinkingController, curve: Curves.easeOut)); + _thinkingController.forward(); + } + + @override + void dispose() { + _thinkingController.dispose(); + super.dispose(); + } + + Widget _buildCircle() { + return Container( + width: widget.height, + height: widget.height, + decoration: BoxDecoration( + border: Border.all( + color: widget.color, + width: 2.0, + ), + borderRadius: BorderRadius.all(const Radius.circular(5.0)), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _thinkingAnimation, + builder: (context, child) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildCircle(), + SizedBox(width: _thinkingAnimation.value), + _buildCircle(), + SizedBox(width: _thinkingAnimation.value), + _buildCircle(), + SizedBox(width: _thinkingAnimation.value), + _buildCircle(), + SizedBox(width: _thinkingAnimation.value), + _buildCircle(), + ], + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index b6eca0d9a310f699c2be40fd6273acfa381332e9..9bed09620d00b8ade2770a54031e59727a291b7b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,7 +7,7 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.7.0" + version: "2.8.1" boolean_selector: dependency: transitive description: @@ -50,6 +50,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.2" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.2" flutter: dependency: "direct main" description: flutter @@ -60,6 +74,18 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.3" matcher: dependency: transitive description: @@ -88,6 +114,48 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.3" provider: dependency: "direct main" description: @@ -95,6 +163,48 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "5.0.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + shared_preferences_macos: + dependency: transitive + description: + name: shared_preferences_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" sky_engine: dependency: transitive description: flutter @@ -141,7 +251,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.4.1" + version: "0.4.2" typed_data: dependency: transitive description: @@ -156,6 +266,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.5" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" sdks: - dart: ">=2.12.0 <3.0.0" - flutter: ">=1.16.0" + dart: ">=2.13.0 <3.0.0" + flutter: ">=2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1bc5838b8d43223d6c349189125beed09328d221..51faa3b57525d31f800da7addc981764fc44ad55 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,15 +1,16 @@ -name: reversi +name: simpler_reversi description: A reversi game application. publish_to: 'none' version: 1.0.0+1 environment: - sdk: ">=2.7.0 <3.0.0" + sdk: ">=2.12.0 <3.0.0" dependencies: flutter: sdk: flutter provider: ^5.0.0 + shared_preferences: ^2.0.6 dev_dependencies: flutter_test: