Add custom control support
This commit is contained in:
parent
abcf8d24bf
commit
e91cbf285e
7 changed files with 155 additions and 162 deletions
|
@ -3,7 +3,7 @@ package dev.lucasnlm.antimine.mocks
|
|||
import dev.lucasnlm.antimine.common.level.utils.IHapticFeedbackInteractor
|
||||
|
||||
class DisabledHapticFeedbackInteractor : IHapticFeedbackInteractor {
|
||||
override fun toggleFlagFeedback() { }
|
||||
override fun longPressFeedback() { }
|
||||
|
||||
override fun explosionFeedback() { }
|
||||
}
|
||||
|
|
|
@ -9,6 +9,9 @@ import dev.lucasnlm.antimine.common.level.models.Difficulty
|
|||
import dev.lucasnlm.antimine.common.level.models.Mark
|
||||
import dev.lucasnlm.antimine.common.level.models.Minefield
|
||||
import dev.lucasnlm.antimine.common.level.models.Score
|
||||
import dev.lucasnlm.antimine.core.control.Action
|
||||
import dev.lucasnlm.antimine.core.control.ActionFeedback
|
||||
import dev.lucasnlm.antimine.core.control.GameControl
|
||||
import kotlin.math.floor
|
||||
import kotlin.random.Random
|
||||
|
||||
|
@ -18,6 +21,7 @@ class LevelFacade {
|
|||
private val startTime = System.currentTimeMillis()
|
||||
private var saveId = 0
|
||||
private var firstOpen: FirstOpen = FirstOpen.Unknown
|
||||
private val gameControl: GameControl = GameControl.Standard
|
||||
|
||||
var hasMines = false
|
||||
private set
|
||||
|
@ -66,8 +70,43 @@ class LevelFacade {
|
|||
|
||||
fun getArea(id: Int) = field.first { it.id == id }
|
||||
|
||||
fun switchMarkAt(index: Int): Area =
|
||||
getArea(index).apply {
|
||||
/**
|
||||
* Run a game [action] on a given tile.
|
||||
* @return The number of changed tiles.
|
||||
*/
|
||||
private fun Area.runActionOn(action: Action?): ActionFeedback {
|
||||
val highlightedChanged = turnOffAllHighlighted()
|
||||
|
||||
val changed = when (action) {
|
||||
Action.OpenTile -> {
|
||||
if (!hasMines) {
|
||||
plantMinesExcept(id, true)
|
||||
}
|
||||
|
||||
if (mark.isNotNone()) {
|
||||
mark = Mark.PurposefulNone
|
||||
1
|
||||
} else {
|
||||
openTile()
|
||||
}
|
||||
}
|
||||
Action.SwitchMark -> {
|
||||
if (isCovered) switchMark()
|
||||
1
|
||||
}
|
||||
Action.HighlightNeighbors -> {
|
||||
if (minesAround != 0) highlight() else 0
|
||||
}
|
||||
Action.OpenNeighbors -> {
|
||||
if (!isCovered) { openNeighbors() } else { 0 }
|
||||
}
|
||||
else -> 0
|
||||
}
|
||||
|
||||
return ActionFeedback(action, id, (changed + highlightedChanged) > 1)
|
||||
}
|
||||
|
||||
fun Area.switchMark(): Area = apply {
|
||||
if (isCovered) {
|
||||
mark = when (mark) {
|
||||
Mark.PurposefulNone, Mark.None -> Mark.Flag
|
||||
|
@ -82,14 +121,6 @@ class LevelFacade {
|
|||
mark = Mark.PurposefulNone
|
||||
}
|
||||
|
||||
fun hasCoverOn(index: Int): Boolean = getArea(index).isCovered
|
||||
|
||||
fun hasMarkOn(index: Int): Boolean = getArea(index).mark.isNotNone()
|
||||
|
||||
fun hasNoneOn(index: Int): Boolean = getArea(index).mark.isNone()
|
||||
|
||||
fun isHighlighted(index: Int): Boolean = getArea(index).highlighted
|
||||
|
||||
fun plantMinesExcept(index: Int, includeSafeArea: Boolean = false) {
|
||||
plantRandomMines(index, includeSafeArea)
|
||||
putMinesTips()
|
||||
|
@ -136,9 +167,9 @@ class LevelFacade {
|
|||
/**
|
||||
* Run "Flood Fill algorithm" to open all empty neighbors of a target area.
|
||||
*/
|
||||
fun openField(target: Area): Int {
|
||||
fun Area.openTile(): Int {
|
||||
var changes = 0
|
||||
target.run {
|
||||
run {
|
||||
if (isCovered) {
|
||||
changes += 1
|
||||
isCovered = false
|
||||
|
@ -152,7 +183,7 @@ class LevelFacade {
|
|||
.also {
|
||||
changes += it.count()
|
||||
}
|
||||
.forEach { openField(it) }
|
||||
.forEach { it.openTile() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -161,16 +192,13 @@ class LevelFacade {
|
|||
|
||||
/**
|
||||
* Disable all highlighted areas.
|
||||
*
|
||||
* @return true if any area was changed.
|
||||
* @return the number of changed tiles.
|
||||
*/
|
||||
fun turnOffAllHighlighted(): Boolean {
|
||||
var changed: Boolean
|
||||
field
|
||||
.filter { it.highlighted }
|
||||
.also { changed = it.count() != 0 }
|
||||
.forEach { it.highlighted = false }
|
||||
return changed
|
||||
fun turnOffAllHighlighted(): Int {
|
||||
return field.filter { it.highlighted }.run {
|
||||
forEach { it.highlighted = false }
|
||||
count()
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleHighlight(target: Area): Int {
|
||||
|
@ -185,34 +213,8 @@ class LevelFacade {
|
|||
return changed
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a given area by its index.
|
||||
*
|
||||
* @param index the target index
|
||||
* @return true if multiple areas were open
|
||||
*/
|
||||
fun singleClick(index: Int): Int = getArea(index).run {
|
||||
return when {
|
||||
isCovered -> {
|
||||
openField(getArea(index))
|
||||
}
|
||||
minesAround != 0 -> {
|
||||
toggleHighlight(this)
|
||||
}
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
fun doubleClick(index: Int): Int = getArea(index).run {
|
||||
return when {
|
||||
isCovered -> {
|
||||
openField(getArea(index))
|
||||
}
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
fun highlight(index: Int): Int = getArea(index).run {
|
||||
private fun Area.highlight(): Int = run {
|
||||
return when {
|
||||
minesAround != 0 -> {
|
||||
toggleHighlight(this)
|
||||
|
@ -221,41 +223,42 @@ class LevelFacade {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun longPressOpenArea(index: Int): Sequence<Area> {
|
||||
val neighbors = getArea(index).findNeighbors().filter {
|
||||
it.mark.isNone() && it.isCovered
|
||||
}
|
||||
|
||||
val neighborsCount = neighbors.count()
|
||||
val minesCount = neighbors.count { it.hasMine }
|
||||
|
||||
if (neighborsCount == minesCount) {
|
||||
neighbors.forEach { area -> area.mark = Mark.Flag }
|
||||
fun singleClick(index: Int): ActionFeedback = getArea(index).run {
|
||||
return if (isCovered) {
|
||||
runActionOn(gameControl.onCovered.singleClick)
|
||||
} else {
|
||||
neighbors.forEach { area -> openField(area) }
|
||||
runActionOn(gameControl.onOpen.singleClick)
|
||||
}
|
||||
|
||||
return neighbors
|
||||
}
|
||||
|
||||
/**
|
||||
* Open all tiles neighbors of [index].
|
||||
* If the number of flagged neighbors is less than the number of neighbors
|
||||
* it won't open any of them to avoid miss clicks.
|
||||
*/
|
||||
fun openNeighbors(index: Int): Sequence<Area> {
|
||||
val neighbors = getArea(index).findNeighbors()
|
||||
fun doubleClick(index: Int): ActionFeedback = getArea(index).run {
|
||||
return if (isCovered) {
|
||||
runActionOn(gameControl.onCovered.doubleClick)
|
||||
} else {
|
||||
runActionOn(gameControl.onOpen.doubleClick)
|
||||
}
|
||||
}
|
||||
|
||||
fun longPress(index: Int): ActionFeedback = getArea(index).run {
|
||||
return if (isCovered) {
|
||||
runActionOn(gameControl.onCovered.longPress)
|
||||
} else {
|
||||
runActionOn(gameControl.onOpen.longPress)
|
||||
}
|
||||
}
|
||||
|
||||
fun Area.openNeighbors(): Int {
|
||||
val neighbors = findNeighbors()
|
||||
val flaggedCount = neighbors.count { it.mark.isFlag() }
|
||||
return if (flaggedCount >= getArea(index).minesAround) {
|
||||
return if (flaggedCount >= minesAround) {
|
||||
neighbors
|
||||
.filter {
|
||||
it.mark.isNone() && it.isCovered
|
||||
}.also {
|
||||
it.forEach { area -> openField(area) }
|
||||
}
|
||||
it.forEach { area -> area.openTile() }
|
||||
}.count()
|
||||
} else {
|
||||
sequenceOf()
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import android.os.Vibrator
|
|||
import dev.lucasnlm.antimine.core.preferences.IPreferencesRepository
|
||||
|
||||
interface IHapticFeedbackInteractor {
|
||||
fun toggleFlagFeedback()
|
||||
fun longPressFeedback()
|
||||
fun explosionFeedback()
|
||||
}
|
||||
|
||||
|
@ -18,7 +18,7 @@ class HapticFeedbackInteractor(
|
|||
) : IHapticFeedbackInteractor {
|
||||
private val vibrator: Vibrator = context.getSystemService(VIBRATOR_SERVICE) as Vibrator
|
||||
|
||||
override fun toggleFlagFeedback() {
|
||||
override fun longPressFeedback() {
|
||||
if (preferencesRepository.useHapticFeedback()) {
|
||||
vibrateTo(70, 240)
|
||||
vibrateTo(10, 100)
|
||||
|
@ -44,7 +44,7 @@ class HapticFeedbackInteractor(
|
|||
}
|
||||
|
||||
class DisabledIHapticFeedbackInteractor : IHapticFeedbackInteractor {
|
||||
override fun toggleFlagFeedback() { }
|
||||
override fun longPressFeedback() { }
|
||||
|
||||
override fun explosionFeedback() { }
|
||||
}
|
||||
|
|
|
@ -19,6 +19,8 @@ import dev.lucasnlm.antimine.common.level.utils.Clock
|
|||
import dev.lucasnlm.antimine.common.level.utils.IHapticFeedbackInteractor
|
||||
import dev.lucasnlm.antimine.core.analytics.AnalyticsManager
|
||||
import dev.lucasnlm.antimine.core.analytics.models.Analytics
|
||||
import dev.lucasnlm.antimine.core.control.Action
|
||||
import dev.lucasnlm.antimine.core.control.ActionFeedback
|
||||
import dev.lucasnlm.antimine.core.preferences.IPreferencesRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
|
@ -40,7 +42,6 @@ class GameViewModel @ViewModelInject constructor(
|
|||
private lateinit var levelFacade: LevelFacade
|
||||
private var currentDifficulty: Difficulty = Difficulty.Standard
|
||||
private var initialized = false
|
||||
private var oldGame = false
|
||||
|
||||
val field = MutableLiveData<Sequence<Area>>()
|
||||
val fieldRefresh = MutableLiveData<Int>()
|
||||
|
@ -147,7 +148,6 @@ class GameViewModel @ViewModelInject constructor(
|
|||
startNewGame()
|
||||
}.also {
|
||||
initialized = true
|
||||
oldGame = true
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -163,7 +163,6 @@ class GameViewModel @ViewModelInject constructor(
|
|||
startNewGame()
|
||||
}.also {
|
||||
initialized = true
|
||||
oldGame = true
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -179,7 +178,6 @@ class GameViewModel @ViewModelInject constructor(
|
|||
startNewGame()
|
||||
}.also {
|
||||
initialized = true
|
||||
oldGame = false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -218,44 +216,29 @@ class GameViewModel @ViewModelInject constructor(
|
|||
}
|
||||
|
||||
fun onLongClick(index: Int) {
|
||||
val isHighlighted = levelFacade.isHighlighted(index)
|
||||
levelFacade.turnOffAllHighlighted()
|
||||
refreshAll()
|
||||
|
||||
if (levelFacade.hasCoverOn(index)) {
|
||||
levelFacade.switchMarkAt(index).run {
|
||||
refreshIndex(id)
|
||||
hapticFeedbackInteractor.toggleFlagFeedback()
|
||||
}
|
||||
|
||||
analyticsManager.sentEvent(Analytics.LongPressArea(index))
|
||||
} else if (!preferencesRepository.useDoubleClickToOpen() || isHighlighted) {
|
||||
levelFacade.openNeighbors(index).forEach { refreshIndex(it.id) }
|
||||
analyticsManager.sentEvent(Analytics.LongPressMultipleArea(index))
|
||||
} else {
|
||||
levelFacade.highlight(index).run {
|
||||
refreshIndex(index, this)
|
||||
}
|
||||
}
|
||||
|
||||
updateGameState()
|
||||
val feedback = levelFacade.longPress(index)
|
||||
refreshIndex(index, feedback.multipleChanges)
|
||||
onFeedbackAnalytics(feedback)
|
||||
onPostAction()
|
||||
}
|
||||
|
||||
fun onDoubleClickArea(index: Int) {
|
||||
if (levelFacade.turnOffAllHighlighted()) {
|
||||
refreshAll()
|
||||
}
|
||||
val feedback = levelFacade.doubleClick(index)
|
||||
refreshIndex(index, feedback.multipleChanges)
|
||||
onFeedbackAnalytics(feedback)
|
||||
onPostAction()
|
||||
|
||||
if (preferencesRepository.useDoubleClickToOpen()) {
|
||||
if (!levelFacade.hasMines) {
|
||||
levelFacade.plantMinesExcept(index, true)
|
||||
}
|
||||
hapticFeedbackInteractor.longPressFeedback()
|
||||
}
|
||||
|
||||
levelFacade.doubleClick(index).run {
|
||||
refreshIndex(index, this)
|
||||
}
|
||||
}
|
||||
fun onSingleClick(index: Int) {
|
||||
val feedback = levelFacade.singleClick(index)
|
||||
refreshIndex(index, feedback.multipleChanges)
|
||||
onFeedbackAnalytics(feedback)
|
||||
onPostAction()
|
||||
}
|
||||
|
||||
private fun onPostAction() {
|
||||
if (preferencesRepository.useFlagAssistant() && !levelFacade.hasAnyMineExploded()) {
|
||||
levelFacade.runFlagAssistant().forEach {
|
||||
Handler().post {
|
||||
|
@ -265,51 +248,19 @@ class GameViewModel @ViewModelInject constructor(
|
|||
}
|
||||
|
||||
updateGameState()
|
||||
analyticsManager.sentEvent(Analytics.PressArea(index))
|
||||
}
|
||||
|
||||
fun onSingleClick(index: Int) {
|
||||
var openAnyArea = false
|
||||
|
||||
if (levelFacade.turnOffAllHighlighted()) {
|
||||
refreshAll()
|
||||
}
|
||||
|
||||
if (levelFacade.hasMarkOn(index)) {
|
||||
levelFacade.removeMark(index).run {
|
||||
refreshIndex(id)
|
||||
}
|
||||
hapticFeedbackInteractor.toggleFlagFeedback()
|
||||
} else if (!preferencesRepository.useDoubleClickToOpen() || !levelFacade.hasMines) {
|
||||
if (!levelFacade.hasMines) {
|
||||
levelFacade.plantMinesExcept(index, true)
|
||||
}
|
||||
|
||||
levelFacade.singleClick(index).run {
|
||||
refreshIndex(index, this)
|
||||
}
|
||||
|
||||
openAnyArea = true
|
||||
}
|
||||
|
||||
if (openAnyArea) {
|
||||
if (preferencesRepository.useFlagAssistant() && !levelFacade.hasAnyMineExploded()) {
|
||||
levelFacade.runFlagAssistant().forEach {
|
||||
Handler().post {
|
||||
refreshIndex(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateGameState()
|
||||
analyticsManager.sentEvent(Analytics.PressArea(index))
|
||||
private fun onFeedbackAnalytics(feedback: ActionFeedback) {
|
||||
when (feedback.action) {
|
||||
Action.OpenTile -> { analyticsManager.sentEvent(Analytics.OpenTile(feedback.index)) }
|
||||
Action.SwitchMark -> { analyticsManager.sentEvent(Analytics.SwitchMark(feedback.index)) }
|
||||
Action.HighlightNeighbors -> { analyticsManager.sentEvent(Analytics.HighlightNeighbors(feedback.index)) }
|
||||
Action.OpenNeighbors -> { analyticsManager.sentEvent(Analytics.OpenNeighbors(feedback.index)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshMineCount() = mineCount.postValue(levelFacade.remainingMines())
|
||||
|
||||
fun isCurrentGame() = !oldGame
|
||||
|
||||
private fun updateGameState() {
|
||||
when {
|
||||
levelFacade.hasAnyMineExploded() -> {
|
||||
|
@ -393,8 +344,8 @@ class GameViewModel @ViewModelInject constructor(
|
|||
|
||||
fun useAccessibilityMode() = preferencesRepository.useLargeAreas()
|
||||
|
||||
private fun refreshIndex(targetIndex: Int, changes: Int = 1) {
|
||||
if (!preferencesRepository.useAnimations() || changes > 1) {
|
||||
private fun refreshIndex(targetIndex: Int, multipleChanges: Boolean = false) {
|
||||
if (!preferencesRepository.useAnimations() || multipleChanges) {
|
||||
field.postValue(levelFacade.field)
|
||||
} else {
|
||||
fieldRefresh.postValue(targetIndex)
|
||||
|
|
|
@ -48,12 +48,13 @@ sealed class Analytics(
|
|||
|
||||
class ResumePreviousGame : Analytics("Resume previous game")
|
||||
|
||||
class LongPressArea(index: Int) : Analytics("Long press area", mapOf("Index" to index.toString()))
|
||||
class OpenTile(index: Int) : Analytics("Open Tile", mapOf("Index" to index.toString()))
|
||||
|
||||
class LongPressMultipleArea(index: Int) :
|
||||
Analytics("Long press to open multiple", mapOf("Index" to index.toString()))
|
||||
class SwitchMark(index: Int) : Analytics("Switch Mark", mapOf("Index" to index.toString()))
|
||||
|
||||
class PressArea(index: Int) : Analytics("Press area", mapOf("Index" to index.toString()))
|
||||
class HighlightNeighbors(index: Int) : Analytics("Highlight Neighbors", mapOf("Index" to index.toString()))
|
||||
|
||||
class OpenNeighbors(index: Int) : Analytics("Open Neighbors", mapOf("Index" to index.toString()))
|
||||
|
||||
class GameOver(time: Long, score: Score) : Analytics(
|
||||
"Game Over",
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
package dev.lucasnlm.antimine.core.control
|
||||
|
||||
enum class Action {
|
||||
OpenTile,
|
||||
SwitchMark,
|
||||
HighlightNeighbors,
|
||||
OpenNeighbors,
|
||||
}
|
||||
|
||||
data class Actions(
|
||||
val singleClick: Action?,
|
||||
val doubleClick: Action?,
|
||||
val longPress: Action?
|
||||
)
|
||||
|
||||
sealed class GameControl(
|
||||
val onCovered: Actions,
|
||||
val onOpen: Actions
|
||||
) {
|
||||
object Standard : GameControl(
|
||||
onCovered = Actions(
|
||||
singleClick = Action.OpenTile,
|
||||
longPress = Action.SwitchMark,
|
||||
doubleClick = null
|
||||
),
|
||||
onOpen = Actions(
|
||||
singleClick = Action.HighlightNeighbors,
|
||||
longPress = Action.OpenNeighbors,
|
||||
doubleClick = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
data class ActionFeedback(
|
||||
val action: Action?,
|
||||
val index: Int,
|
||||
val multipleChanges: Boolean
|
||||
)
|
|
@ -197,21 +197,21 @@ class LevelFacadeTest {
|
|||
fun testFlagAssistant() {
|
||||
levelFacadeOf(3, 3, 1, 200L).run {
|
||||
plantMinesExcept(3)
|
||||
field.filterNot { it.hasMine }.forEach { openField(it) }
|
||||
field.filterNot { it.hasMine }.forEach { openTile(it) }
|
||||
runFlagAssistant()
|
||||
field.filter { it.hasMine }.map { it.mark.isFlag() }.forEach(::assertTrue)
|
||||
}
|
||||
|
||||
levelFacadeOf(3, 3, 2, 200L).run {
|
||||
plantMinesExcept(3)
|
||||
field.filterNot { it.hasMine }.forEach { openField(it) }
|
||||
field.filterNot { it.hasMine }.forEach { openTile(it) }
|
||||
runFlagAssistant()
|
||||
field.filter { it.hasMine }.map { it.mark.isFlag() }.forEach(::assertTrue)
|
||||
}
|
||||
|
||||
levelFacadeOf(3, 3, 8, 200L).run {
|
||||
plantMinesExcept(3)
|
||||
field.filterNot { it.hasMine }.forEach { openField(it) }
|
||||
field.filterNot { it.hasMine }.forEach { openTile(it) }
|
||||
runFlagAssistant()
|
||||
field.filter { it.hasMine }.map { it.mark.isFlag() }.forEach(::assertFalse)
|
||||
}
|
||||
|
@ -419,7 +419,7 @@ class LevelFacadeTest {
|
|||
plantMinesExcept(3)
|
||||
val mine = field.first { it.hasMine }
|
||||
assertEquals(findExplodedMine(), null)
|
||||
openField(mine)
|
||||
openTile(mine)
|
||||
assertEquals(findExplodedMine(), mine)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue