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
class DisabledHapticFeedbackInteractor : IHapticFeedbackInteractor {
override fun toggleFlagFeedback() { }
override fun longPressFeedback() { }
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.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
fun doubleClick(index: Int): ActionFeedback = getArea(index).run {
return if (isCovered) {
runActionOn(gameControl.onCovered.doubleClick)
} else {
runActionOn(gameControl.onOpen.doubleClick)
}
}
/**
* 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 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
}
}

View file

@ -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() { }
}

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.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,81 +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)
}
levelFacade.doubleClick(index).run {
refreshIndex(index, this)
}
}
if (preferencesRepository.useFlagAssistant() && !levelFacade.hasAnyMineExploded()) {
levelFacade.runFlagAssistant().forEach {
Handler().post {
refreshIndex(it.id)
}
}
}
updateGameState()
analyticsManager.sentEvent(Analytics.PressArea(index))
hapticFeedbackInteractor.longPressFeedback()
}
fun onSingleClick(index: Int) {
var openAnyArea = false
if (levelFacade.turnOffAllHighlighted()) {
refreshAll()
val feedback = levelFacade.singleClick(index)
refreshIndex(index, feedback.multipleChanges)
onFeedbackAnalytics(feedback)
onPostAction()
}
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) {
private fun onPostAction() {
if (preferencesRepository.useFlagAssistant() && !levelFacade.hasAnyMineExploded()) {
levelFacade.runFlagAssistant().forEach {
Handler().post {
@ -302,14 +248,19 @@ class GameViewModel @ViewModelInject constructor(
}
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)

View file

@ -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",

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() {
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)
}
}