Add custom control support

This commit is contained in:
Lucas Lima 2020-06-26 00:16:06 -03:00
parent abcf8d24bf
commit e91cbf285e
No known key found for this signature in database
GPG key ID: C5EEF4C30BFBF8D7
7 changed files with 155 additions and 162 deletions

View file

@ -3,7 +3,7 @@ package dev.lucasnlm.antimine.mocks
import dev.lucasnlm.antimine.common.level.utils.IHapticFeedbackInteractor import dev.lucasnlm.antimine.common.level.utils.IHapticFeedbackInteractor
class DisabledHapticFeedbackInteractor : IHapticFeedbackInteractor { class DisabledHapticFeedbackInteractor : IHapticFeedbackInteractor {
override fun toggleFlagFeedback() { } override fun longPressFeedback() { }
override fun explosionFeedback() { } override fun explosionFeedback() { }
} }

View file

@ -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.Mark
import dev.lucasnlm.antimine.common.level.models.Minefield import dev.lucasnlm.antimine.common.level.models.Minefield
import dev.lucasnlm.antimine.common.level.models.Score 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.math.floor
import kotlin.random.Random import kotlin.random.Random
@ -18,6 +21,7 @@ class LevelFacade {
private val startTime = System.currentTimeMillis() private val startTime = System.currentTimeMillis()
private var saveId = 0 private var saveId = 0
private var firstOpen: FirstOpen = FirstOpen.Unknown private var firstOpen: FirstOpen = FirstOpen.Unknown
private val gameControl: GameControl = GameControl.Standard
var hasMines = false var hasMines = false
private set private set
@ -66,8 +70,43 @@ class LevelFacade {
fun getArea(id: Int) = field.first { it.id == id } 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) { if (isCovered) {
mark = when (mark) { mark = when (mark) {
Mark.PurposefulNone, Mark.None -> Mark.Flag Mark.PurposefulNone, Mark.None -> Mark.Flag
@ -82,14 +121,6 @@ class LevelFacade {
mark = Mark.PurposefulNone 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) { fun plantMinesExcept(index: Int, includeSafeArea: Boolean = false) {
plantRandomMines(index, includeSafeArea) plantRandomMines(index, includeSafeArea)
putMinesTips() putMinesTips()
@ -136,9 +167,9 @@ class LevelFacade {
/** /**
* Run "Flood Fill algorithm" to open all empty neighbors of a target area. * 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 var changes = 0
target.run { run {
if (isCovered) { if (isCovered) {
changes += 1 changes += 1
isCovered = false isCovered = false
@ -152,7 +183,7 @@ class LevelFacade {
.also { .also {
changes += it.count() changes += it.count()
} }
.forEach { openField(it) } .forEach { it.openTile() }
} }
} }
} }
@ -161,16 +192,13 @@ class LevelFacade {
/** /**
* Disable all highlighted areas. * Disable all highlighted areas.
* * @return the number of changed tiles.
* @return true if any area was changed.
*/ */
fun turnOffAllHighlighted(): Boolean { fun turnOffAllHighlighted(): Int {
var changed: Boolean return field.filter { it.highlighted }.run {
field forEach { it.highlighted = false }
.filter { it.highlighted } count()
.also { changed = it.count() != 0 } }
.forEach { it.highlighted = false }
return changed
} }
private fun toggleHighlight(target: Area): Int { private fun toggleHighlight(target: Area): Int {
@ -185,34 +213,8 @@ class LevelFacade {
return changed 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 { private fun Area.highlight(): Int = run {
return when {
isCovered -> {
openField(getArea(index))
}
else -> 0
}
}
fun highlight(index: Int): Int = getArea(index).run {
return when { return when {
minesAround != 0 -> { minesAround != 0 -> {
toggleHighlight(this) toggleHighlight(this)
@ -221,41 +223,42 @@ class LevelFacade {
} }
} }
@Suppress("unused") fun singleClick(index: Int): ActionFeedback = getArea(index).run {
fun longPressOpenArea(index: Int): Sequence<Area> { return if (isCovered) {
val neighbors = getArea(index).findNeighbors().filter { runActionOn(gameControl.onCovered.singleClick)
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 }
} else { } else {
neighbors.forEach { area -> openField(area) } runActionOn(gameControl.onOpen.singleClick)
} }
return neighbors
} }
/** fun doubleClick(index: Int): ActionFeedback = getArea(index).run {
* Open all tiles neighbors of [index]. return if (isCovered) {
* If the number of flagged neighbors is less than the number of neighbors runActionOn(gameControl.onCovered.doubleClick)
* it won't open any of them to avoid miss clicks. } else {
*/ runActionOn(gameControl.onOpen.doubleClick)
fun openNeighbors(index: Int): Sequence<Area> { }
val neighbors = getArea(index).findNeighbors() }
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() } val flaggedCount = neighbors.count { it.mark.isFlag() }
return if (flaggedCount >= getArea(index).minesAround) { return if (flaggedCount >= minesAround) {
neighbors neighbors
.filter { .filter {
it.mark.isNone() && it.isCovered it.mark.isNone() && it.isCovered
}.also { }.also {
it.forEach { area -> openField(area) } it.forEach { area -> area.openTile() }
} }.count()
} else { } else {
sequenceOf() 0
} }
} }

View file

@ -8,7 +8,7 @@ import android.os.Vibrator
import dev.lucasnlm.antimine.core.preferences.IPreferencesRepository import dev.lucasnlm.antimine.core.preferences.IPreferencesRepository
interface IHapticFeedbackInteractor { interface IHapticFeedbackInteractor {
fun toggleFlagFeedback() fun longPressFeedback()
fun explosionFeedback() fun explosionFeedback()
} }
@ -18,7 +18,7 @@ class HapticFeedbackInteractor(
) : IHapticFeedbackInteractor { ) : IHapticFeedbackInteractor {
private val vibrator: Vibrator = context.getSystemService(VIBRATOR_SERVICE) as Vibrator private val vibrator: Vibrator = context.getSystemService(VIBRATOR_SERVICE) as Vibrator
override fun toggleFlagFeedback() { override fun longPressFeedback() {
if (preferencesRepository.useHapticFeedback()) { if (preferencesRepository.useHapticFeedback()) {
vibrateTo(70, 240) vibrateTo(70, 240)
vibrateTo(10, 100) vibrateTo(10, 100)
@ -44,7 +44,7 @@ class HapticFeedbackInteractor(
} }
class DisabledIHapticFeedbackInteractor : IHapticFeedbackInteractor { class DisabledIHapticFeedbackInteractor : IHapticFeedbackInteractor {
override fun toggleFlagFeedback() { } override fun longPressFeedback() { }
override fun explosionFeedback() { } override fun explosionFeedback() { }
} }

View file

@ -19,6 +19,8 @@ import dev.lucasnlm.antimine.common.level.utils.Clock
import dev.lucasnlm.antimine.common.level.utils.IHapticFeedbackInteractor import dev.lucasnlm.antimine.common.level.utils.IHapticFeedbackInteractor
import dev.lucasnlm.antimine.core.analytics.AnalyticsManager import dev.lucasnlm.antimine.core.analytics.AnalyticsManager
import dev.lucasnlm.antimine.core.analytics.models.Analytics 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 dev.lucasnlm.antimine.core.preferences.IPreferencesRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@ -40,7 +42,6 @@ class GameViewModel @ViewModelInject constructor(
private lateinit var levelFacade: LevelFacade private lateinit var levelFacade: LevelFacade
private var currentDifficulty: Difficulty = Difficulty.Standard private var currentDifficulty: Difficulty = Difficulty.Standard
private var initialized = false private var initialized = false
private var oldGame = false
val field = MutableLiveData<Sequence<Area>>() val field = MutableLiveData<Sequence<Area>>()
val fieldRefresh = MutableLiveData<Int>() val fieldRefresh = MutableLiveData<Int>()
@ -147,7 +148,6 @@ class GameViewModel @ViewModelInject constructor(
startNewGame() startNewGame()
}.also { }.also {
initialized = true initialized = true
oldGame = true
} }
} }
@ -163,7 +163,6 @@ class GameViewModel @ViewModelInject constructor(
startNewGame() startNewGame()
}.also { }.also {
initialized = true initialized = true
oldGame = true
} }
} }
@ -179,7 +178,6 @@ class GameViewModel @ViewModelInject constructor(
startNewGame() startNewGame()
}.also { }.also {
initialized = true initialized = true
oldGame = false
} }
} }
@ -218,44 +216,29 @@ class GameViewModel @ViewModelInject constructor(
} }
fun onLongClick(index: Int) { fun onLongClick(index: Int) {
val isHighlighted = levelFacade.isHighlighted(index) val feedback = levelFacade.longPress(index)
levelFacade.turnOffAllHighlighted() refreshIndex(index, feedback.multipleChanges)
refreshAll() onFeedbackAnalytics(feedback)
onPostAction()
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()
} }
fun onDoubleClickArea(index: Int) { fun onDoubleClickArea(index: Int) {
if (levelFacade.turnOffAllHighlighted()) { val feedback = levelFacade.doubleClick(index)
refreshAll() refreshIndex(index, feedback.multipleChanges)
} onFeedbackAnalytics(feedback)
onPostAction()
if (preferencesRepository.useDoubleClickToOpen()) { hapticFeedbackInteractor.longPressFeedback()
if (!levelFacade.hasMines) { }
levelFacade.plantMinesExcept(index, true)
}
levelFacade.doubleClick(index).run { fun onSingleClick(index: Int) {
refreshIndex(index, this) val feedback = levelFacade.singleClick(index)
} refreshIndex(index, feedback.multipleChanges)
} onFeedbackAnalytics(feedback)
onPostAction()
}
private fun onPostAction() {
if (preferencesRepository.useFlagAssistant() && !levelFacade.hasAnyMineExploded()) { if (preferencesRepository.useFlagAssistant() && !levelFacade.hasAnyMineExploded()) {
levelFacade.runFlagAssistant().forEach { levelFacade.runFlagAssistant().forEach {
Handler().post { Handler().post {
@ -265,51 +248,19 @@ class GameViewModel @ViewModelInject constructor(
} }
updateGameState() updateGameState()
analyticsManager.sentEvent(Analytics.PressArea(index))
} }
fun onSingleClick(index: Int) { private fun onFeedbackAnalytics(feedback: ActionFeedback) {
var openAnyArea = false when (feedback.action) {
Action.OpenTile -> { analyticsManager.sentEvent(Analytics.OpenTile(feedback.index)) }
if (levelFacade.turnOffAllHighlighted()) { Action.SwitchMark -> { analyticsManager.sentEvent(Analytics.SwitchMark(feedback.index)) }
refreshAll() Action.HighlightNeighbors -> { analyticsManager.sentEvent(Analytics.HighlightNeighbors(feedback.index)) }
} Action.OpenNeighbors -> { analyticsManager.sentEvent(Analytics.OpenNeighbors(feedback.index)) }
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 refreshMineCount() = mineCount.postValue(levelFacade.remainingMines()) private fun refreshMineCount() = mineCount.postValue(levelFacade.remainingMines())
fun isCurrentGame() = !oldGame
private fun updateGameState() { private fun updateGameState() {
when { when {
levelFacade.hasAnyMineExploded() -> { levelFacade.hasAnyMineExploded() -> {
@ -393,8 +344,8 @@ class GameViewModel @ViewModelInject constructor(
fun useAccessibilityMode() = preferencesRepository.useLargeAreas() fun useAccessibilityMode() = preferencesRepository.useLargeAreas()
private fun refreshIndex(targetIndex: Int, changes: Int = 1) { private fun refreshIndex(targetIndex: Int, multipleChanges: Boolean = false) {
if (!preferencesRepository.useAnimations() || changes > 1) { if (!preferencesRepository.useAnimations() || multipleChanges) {
field.postValue(levelFacade.field) field.postValue(levelFacade.field)
} else { } else {
fieldRefresh.postValue(targetIndex) fieldRefresh.postValue(targetIndex)

View file

@ -48,12 +48,13 @@ sealed class Analytics(
class ResumePreviousGame : Analytics("Resume previous game") 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) : class SwitchMark(index: Int) : Analytics("Switch Mark", mapOf("Index" to index.toString()))
Analytics("Long press to open multiple", 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( class GameOver(time: Long, score: Score) : Analytics(
"Game Over", "Game Over",

View file

@ -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
)

View file

@ -197,21 +197,21 @@ class LevelFacadeTest {
fun testFlagAssistant() { fun testFlagAssistant() {
levelFacadeOf(3, 3, 1, 200L).run { levelFacadeOf(3, 3, 1, 200L).run {
plantMinesExcept(3) plantMinesExcept(3)
field.filterNot { it.hasMine }.forEach { openField(it) } field.filterNot { it.hasMine }.forEach { openTile(it) }
runFlagAssistant() runFlagAssistant()
field.filter { it.hasMine }.map { it.mark.isFlag() }.forEach(::assertTrue) field.filter { it.hasMine }.map { it.mark.isFlag() }.forEach(::assertTrue)
} }
levelFacadeOf(3, 3, 2, 200L).run { levelFacadeOf(3, 3, 2, 200L).run {
plantMinesExcept(3) plantMinesExcept(3)
field.filterNot { it.hasMine }.forEach { openField(it) } field.filterNot { it.hasMine }.forEach { openTile(it) }
runFlagAssistant() runFlagAssistant()
field.filter { it.hasMine }.map { it.mark.isFlag() }.forEach(::assertTrue) field.filter { it.hasMine }.map { it.mark.isFlag() }.forEach(::assertTrue)
} }
levelFacadeOf(3, 3, 8, 200L).run { levelFacadeOf(3, 3, 8, 200L).run {
plantMinesExcept(3) plantMinesExcept(3)
field.filterNot { it.hasMine }.forEach { openField(it) } field.filterNot { it.hasMine }.forEach { openTile(it) }
runFlagAssistant() runFlagAssistant()
field.filter { it.hasMine }.map { it.mark.isFlag() }.forEach(::assertFalse) field.filter { it.hasMine }.map { it.mark.isFlag() }.forEach(::assertFalse)
} }
@ -419,7 +419,7 @@ class LevelFacadeTest {
plantMinesExcept(3) plantMinesExcept(3)
val mine = field.first { it.hasMine } val mine = field.first { it.hasMine }
assertEquals(findExplodedMine(), null) assertEquals(findExplodedMine(), null)
openField(mine) openTile(mine)
assertEquals(findExplodedMine(), mine) assertEquals(findExplodedMine(), mine)
} }
} }