Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementing the Stopwatch #15

Merged
merged 17 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,26 @@ kotlin {
implementation(compose.foundation)
implementation(compose.material)
implementation(compose.ui)

@OptIn(ExperimentalComposeLibrary::class)
implementation(compose.components.resources)

implementation(libs.mvikotlin)
implementation(libs.mvikotlin.main)
implementation(libs.mvikotlin.timetravel)
implementation(libs.mvikotlin.coroutines)
implementation(libs.decompose)
implementation(libs.decompose.extensions.compose)
implementation(libs.serialization.json)
implementation(libs.essenty.lifecycle.coroutines)
}

commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.coroutines.test)
}

jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.coroutines.swing)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,31 @@ package com.arkivanov.minesweeper.game

import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.Value
import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope
import com.arkivanov.minesweeper.asValue
import com.arkivanov.mvikotlin.core.instancekeeper.getStore
import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.time.Duration.Companion.seconds

@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultGameComponent(
componentContext: ComponentContext,
storeFactory: StoreFactory,
settings: GameSettings,
mainCoroutineContext: CoroutineContext
) : GameComponent, ComponentContext by componentContext {

private val scope = coroutineScope(context = mainCoroutineContext + SupervisorJob())

private val store =
instanceKeeper.getStore {
storeFactory.gameStore(
Expand All @@ -24,6 +39,19 @@ internal class DefaultGameComponent(

init {
stateKeeper.register(key = KEY_SAVED_STATE, strategy = GameState.serializer()) { store.state }
scope.launch {
store.stateFlow
.map { it.gameStatus == GameStatus.STARTED }
.distinctUntilChanged()
.collectLatest { isStarted ->
if (isStarted) {
while (true) {
delay(1.seconds)
store.accept(Intent.TickTimer)
}
}
}
}
}

override fun onCellTouchedPrimary(x: Int, y: Int) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ internal fun GameContent(component: GameComponent, modifier: Modifier = Modifier
val gridHeight by derivedStateOf { state.height }
val grid by derivedStateOf { state.grid }
val remainingMines by derivedStateOf { state.remainingMines }
val timer by derivedStateOf { state.timer }

CompositionLocalProvider(LocalGameIcons provides gameIcons()) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Expand All @@ -79,7 +80,7 @@ internal fun GameContent(component: GameComponent, modifier: Modifier = Modifier
Counter(
value = remainingMines,
modifier = Modifier.weight(1f).semantics {
this.contentDescription = "Counter of remaining bombs"
this.contentDescription = "Counter of remaining bombs, bombs left: $remainingMines"
this.role = Role.Image
},
)
Expand All @@ -91,8 +92,13 @@ internal fun GameContent(component: GameComponent, modifier: Modifier = Modifier
onClick = component::onRestartClicked,
)

// TODO: Reserved for implementing the Stopwatch
Counter(value = 0, modifier = Modifier.weight(1f))
Counter(
value = timer,
modifier = Modifier.weight(1f).semantics {
this.contentDescription = "Timer is on $timer"
this.role = Role.Image
},
)
}

Spacer(modifier = Modifier.height(16.dp))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ internal data class GameState(
val maxMines: Int = grid.values.count { it.value.isMine },
val gameStatus: GameStatus = GameStatus.INITIALIZED,
val pressMode: PressMode = PressMode.NONE,
val timer: Int = 0,
) {
init {
require(grid.size == width * height) { "Grid size must be equal to width * height" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ internal sealed interface Intent {
data class PressCells(val x: Int, val y: Int) : Intent
data class ReleaseCells(val x: Int, val y: Int) : Intent
data class ToggleFlag(val x: Int, val y: Int) : Intent
data object TickTimer : Intent
data object Restart : Intent
}

Expand Down Expand Up @@ -43,6 +44,7 @@ private fun GameState.reduce(intent: Intent): GameState =
is Intent.PressCells -> pressCellsIntent(location = intent.x by intent.y)
is Intent.ReleaseCells -> releaseCellsIntent(location = intent.x by intent.y)
is Intent.ToggleFlag -> toggleFlagIntent(location = intent.x by intent.y)
is Intent.TickTimer -> tick()
is Intent.Restart -> newGameState(width = width, height = height, maxMines = maxMines)
}.finishIfNeeded()

Expand Down Expand Up @@ -211,3 +213,6 @@ private fun GameState.toggleFlagIntent(location: Location): GameState {

return copy(grid = grid + (location to cell.copy(status = status.copy(isFlagged = !status.isFlagged))))
}

private fun GameState.tick(): GameState =
if (timer < 999) copy(timer = timer + 1) else this
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.arkivanov.minesweeper.game.GameSettings
import com.arkivanov.minesweeper.settings.DefaultEditSettingsComponent
import com.arkivanov.minesweeper.settings.EditSettingsComponent
import com.arkivanov.mvikotlin.core.store.StoreFactory
import kotlinx.coroutines.Dispatchers

internal class DefaultRootComponent(
componentContext: ComponentContext,
Expand Down Expand Up @@ -60,7 +61,12 @@ internal fun DefaultRootComponent(componentContext: ComponentContext, storeFacto
DefaultRootComponent(
componentContext = componentContext,
gameComponentFactory = { ctx, settings ->
DefaultGameComponent(componentContext = ctx, storeFactory = storeFactory, settings = settings)
DefaultGameComponent(
componentContext = ctx,
storeFactory = storeFactory,
settings = settings,
mainCoroutineContext = Dispatchers.Main.immediate
)
},
editSettingsComponentFactory = ::DefaultEditSettingsComponent,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.arkivanov.minesweeper.game

import com.arkivanov.decompose.DefaultComponentContext
import com.arkivanov.essenty.lifecycle.LifecycleRegistry
import com.arkivanov.essenty.lifecycle.resume
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.seconds

@Suppress("TestFunctionName")
class DefaultGameComponentTest {
private val lifecycle = LifecycleRegistry()
private val coroutineScheduler = TestCoroutineScheduler()
private val gameComponent = DefaultGameComponent(
componentContext = DefaultComponentContext(lifecycle = lifecycle),
storeFactory = DefaultStoreFactory(),
settings = GameSettings(width = 20, height = 20, maxMines = 398),
mainCoroutineContext = StandardTestDispatcher(scheduler = coroutineScheduler)
)

b0r1ngx marked this conversation as resolved.
Show resolved Hide resolved
private val state: GameState
get() = gameComponent.state.value

@BeforeTest
fun before() {
lifecycle.resume()
}

@Test
fun GIVEN_created_WHEN_cell_clicked_THEN_status_STARTED() {
clickCellPrimary(x = 0, y = 0)

assertEquals(GameStatus.STARTED, state.gameStatus)
}

@Test
fun GIVEN_created_WHEN_cell_clicked_THEN_timer_shows_0() {
clickCellPrimary(x = 0, y = 0)

assertEquals(0, state.timer)
}

@Test
fun GIVEN_game_started_WHEN_one_second_passed_THEN_timer_shows_1() {
clickCellPrimary(x = 0, y = 0)

coroutineScheduler.advanceTimeBy(1.seconds)
coroutineScheduler.runCurrent()

assertEquals(1, state.timer)
}

@Test
fun GIVEN_game_started_WHEN_click_on_mine_cell_THEN_timer_STOPS() {
clickCellPrimary(x = 0, y = 0)

coroutineScheduler.advanceTimeBy(10.seconds)
coroutineScheduler.runCurrent()

clickCellPrimary(x = 10, y = 10)
b0r1ngx marked this conversation as resolved.
Show resolved Hide resolved

coroutineScheduler.advanceTimeBy(10.seconds)
coroutineScheduler.runCurrent()

assertEquals(10, state.timer)
}

private fun clickCellPrimary(x: Int, y: Int) {
gameComponent.onCellTouchedPrimary(x = x, y = y)
gameComponent.onCellReleased(x = y, y = y)
}
}
6 changes: 6 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ kotlin = "1.9.22"
decompose = "3.0.0-alpha05"
mvikotlin = "4.0.0-alpha02"
serialization = "1.6.2"
essenty = "2.0.0-alpha02"
coroutines = "1.8.0"

[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
Expand All @@ -33,7 +35,11 @@ decompose-extensions-compose = { group = "com.arkivanov.decompose", name = "exte
mvikotlin = { group = "com.arkivanov.mvikotlin", name = "mvikotlin", version.ref = "mvikotlin" }
mvikotlin-main = { group = "com.arkivanov.mvikotlin", name = "mvikotlin-main", version.ref = "mvikotlin" }
mvikotlin-timetravel = { group = "com.arkivanov.mvikotlin", name = "mvikotlin-timetravel", version.ref = "mvikotlin" }
mvikotlin-coroutines = { group = "com.arkivanov.mvikotlin", name = "mvikotlin-extensions-coroutines", version.ref = "mvikotlin" }
serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
essenty-lifecycle-coroutines = { group = "com.arkivanov.essenty", name = "lifecycle-coroutines", version.ref = "essenty" }
coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "coroutines" }
coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
Expand Down