Skip to content

Commit

Permalink
Merge pull request #8 from arkivanov/save-state-web
Browse files Browse the repository at this point in the history
State saving and restoration on web
  • Loading branch information
arkivanov authored Feb 5, 2024
2 parents 2585667 + 86e3dfb commit 3f4abea
Show file tree
Hide file tree
Showing 12 changed files with 182 additions and 119 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Tech stack:
- [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform) - declarative UI
- [Decompose](https://github.com/arkivanov/Decompose) - navigation and lifecycle
- [MVIKotlin](https://github.com/arkivanov/MVIKotlin) - state management
- [kotlinx-serialization](https://github.com/Kotlin/kotlinx.serialization) - state saving and restoration

Supported targets: Desktop (JVM) and Wasm Browser.

Expand Down
1 change: 1 addition & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ kotlin {
implementation(libs.mvikotlin.timetravel)
implementation(libs.decompose)
implementation(libs.decompose.extensions.compose)
implementation(libs.serialization.json)
}

commonTest.dependencies {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.arkivanov.minesweeper

import com.arkivanov.essenty.statekeeper.SerializableContainer
import kotlinx.serialization.json.Json

private val json =
Json {
allowStructuredMapKeys = true
}

internal fun SerializableContainer.encodeToString(): String =
json.encodeToString(SerializableContainer.serializer(), this)

internal fun String.decodeSerializableContainer(): SerializableContainer? =
try {
json.decodeFromString(SerializableContainer.serializer(), this)
} catch (e: Exception) {
null
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@ internal class DefaultGameComponent(
private val store =
instanceKeeper.getStore {
storeFactory.gameStore(
newGameState(
width = settings.width,
height = settings.height,
maxMines = settings.maxMines,
)
state = stateKeeper.consume(key = KEY_SAVED_STATE, strategy = GameState.serializer())
?: newGameState(width = settings.width, height = settings.height, maxMines = settings.maxMines),
)
}

override val state: Value<State> = store.asValue()
override val state: Value<GameState> = store.asValue()

init {
stateKeeper.register(key = KEY_SAVED_STATE, strategy = GameState.serializer()) { store.state }
}

override fun onCellTouchedPrimary(x: Int, y: Int) {
store.accept(Intent.PressCell(x = x, y = y))
Expand All @@ -44,4 +45,8 @@ internal class DefaultGameComponent(
override fun onRestartClicked() {
store.accept(Intent.Restart)
}

private companion object {
private const val KEY_SAVED_STATE = "saved_state"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import com.arkivanov.decompose.value.Value

internal interface GameComponent {

val state: Value<State>
val state: Value<GameState>

fun onCellTouchedPrimary(x: Int, y: Int)
fun onCellPressedSecondary(x: Int, y: Int)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,9 @@ internal fun GameContentPreview() {
}

internal class PreviewGameComponent : GameComponent {
override val state: Value<State> =
override val state: Value<GameState> =
MutableValue(
State(
GameState(
grid = buildMap {
var number = 1
var isFlagged = false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.arkivanov.minesweeper.game

import kotlinx.serialization.Serializable

@Serializable
internal data class GameState(
val grid: Grid,
val width: Int = grid.keys.maxOf { it.x } + 1,
val height: Int = grid.keys.maxOf { it.y } + 1,
val maxMines: Int = grid.values.count { it.value.isMine },
val gameStatus: GameStatus = GameStatus.INITIALIZED,
val pressMode: PressMode = PressMode.NONE,
) {
init {
require(grid.size == width * height) { "Grid size must be equal to width * height" }
}
}

internal enum class GameStatus {
INITIALIZED,
STARTED,
WIN,
FAILED,
}

internal enum class PressMode {
NONE,
SINGLE,
MULTIPLE,
}

internal val GameStatus.isOver: Boolean
get() =
when (this) {
GameStatus.INITIALIZED,
GameStatus.STARTED -> false

GameStatus.WIN,
GameStatus.FAILED -> true
}

internal typealias Grid = Map<Location, Cell>
internal typealias MutableGrid = MutableMap<Location, Cell>

@Serializable
internal data class Cell(
val value: CellValue = CellValue.None,
val status: CellStatus = CellStatus.Closed(),
)

@Serializable
sealed interface CellValue {

@Serializable
data object None : CellValue

@Serializable
data object Mine : CellValue

@Serializable
data class Number(val number: Int) : CellValue
}

@Serializable
sealed interface CellStatus {

@Serializable
data class Closed(
val isFlagged: Boolean = false,
val isPressed: Boolean = false,
) : CellStatus

@Serializable
data object Open : CellStatus
}

internal val CellValue.isNone: Boolean
get() = this is CellValue.None

internal val CellValue.isMine: Boolean
get() = this is CellValue.Mine

internal val CellValue.isNumber: Boolean
get() = asNumber() != null

internal fun CellValue.asNumber(): CellValue.Number? =
this as? CellValue.Number

internal val CellStatus.isClosed: Boolean
get() = this is CellStatus.Closed

internal val CellStatus.isOpen: Boolean
get() = this is CellStatus.Open

internal val CellStatus.isFlagged: Boolean
get() = (this as? CellStatus.Closed)?.isFlagged == true

internal fun Cell.open(): Cell =
copy(status = CellStatus.Open)
Loading

0 comments on commit 3f4abea

Please sign in to comment.