Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25e38c4bf8 | ||
|
|
6bf7522042 | ||
|
|
7942adeaf7 | ||
|
|
488f600f15 |
@@ -25,19 +25,12 @@ object MeowdokuAi : GameAi<MeowdokuState, MeowdokuMove> {
|
||||
require(legal.isNotEmpty()) { "No move available" }
|
||||
val places = legal.filterIsInstance<PlaceCat>().associateBy { it.square }
|
||||
|
||||
// The first cat still missing. A pencil cross in front of it is the
|
||||
// player's own note being wrong, so erase it; a hint spends one token
|
||||
// and reveals the square regardless.
|
||||
// The first cat still missing. A cross on its square is no obstacle:
|
||||
// crossed squares take attempts like anything else, and a correct one
|
||||
// replaces the cross.
|
||||
for (row in 0 until state.side) {
|
||||
val missing = state.cats.none { it.row == row }
|
||||
if (!missing) continue
|
||||
val square = Square(row, state.catCols[row])
|
||||
places[square]?.let { return it }
|
||||
if (square in state.marks) {
|
||||
if (TakeHint in legal && state.hintsLeft > 0) return TakeHint
|
||||
legal.filterIsInstance<TogglePencil>()
|
||||
.firstOrNull { it.square == square }
|
||||
?.let { return it }
|
||||
if (state.cats.none { it.row == row }) {
|
||||
places[Square(row, state.catCols[row])]?.let { return it }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// Meowdoku hides N cats in an N by N board: exactly one cat in every row, every
|
||||
// column and every colour, and no two cats sharing even a corner. The player
|
||||
// taps the squares they believe hold a cat; the board only ever accepts a
|
||||
// correct tap, so a cat that lands is a cat that was always going to be there.
|
||||
// A wrong tap costs a heart and crosses the square over, which is what makes
|
||||
// pencil marks a choice rather than a necessity.
|
||||
// column and every colour, and no two cats sharing even a corner. A tap crosses
|
||||
// a square over; a second tap on that square asks the board whether a cat
|
||||
// lives there. A right answer lands the cat, a wrong one costs a heart and
|
||||
// keeps the cross — so the crossing over is both the player's note and the
|
||||
// first half of every guess, and the hearts are what stop it being a lottery.
|
||||
|
||||
/** One square of the board. */
|
||||
@Serializable
|
||||
@@ -83,23 +83,25 @@ data class MeowdokuState(
|
||||
sealed interface MeowdokuMove
|
||||
|
||||
/**
|
||||
* Taps a square expecting a cat.
|
||||
* The second tap of a double tap: the cat attempt.
|
||||
*
|
||||
* One move rather than two, because that is what the player is doing: the board
|
||||
* decides whether the tap found a cat or cost a heart. A square that is already
|
||||
* crossed over has to be un-crossed first — the tap that made the cross is
|
||||
* exactly the tap this would repeat.
|
||||
* decides whether the tap found a cat or cost a heart. The square may carry the
|
||||
* cross its own first tap just drew — that cross is part of the double tap,
|
||||
* not a decision against it — and a correct attempt replaces it.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("cat")
|
||||
data class PlaceCat(val square: Square) : MeowdokuMove
|
||||
|
||||
/**
|
||||
* Draws or erases a pencil cross on a square with no cat on it.
|
||||
* Draws or erases a cross on a square with no cat on it.
|
||||
*
|
||||
* Free, and it cannot be undone by the board: crosses are the player's notes,
|
||||
* not the puzzle's. A cross on a square that actually holds a cat costs nothing
|
||||
* to erase, which is why placing a cat never consults the marks.
|
||||
* This is the first tap of a tap pair and every cell of a drag stroke, so it is
|
||||
* the note-taking half of the scheme: free, unrewarded, and the board cannot
|
||||
* lose a heart over it. A cross drawn here can still be tapped through with a
|
||||
* cat attempt — the note is the player's, and only they get to change their
|
||||
* mind about it.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("pencil")
|
||||
|
||||
@@ -69,10 +69,14 @@ object MeowdokuRules : GameRules<MeowdokuState, MeowdokuMove> {
|
||||
/**
|
||||
* Every tap the board would take.
|
||||
*
|
||||
* Cats go on squares that are neither cat nor cross; pencils work any
|
||||
* square without a cat on it, adding or erasing a cross; a hint is offered
|
||||
* while there are hints left and cats still missing. The list is written
|
||||
* row by row, which is also the order the hint engine uses.
|
||||
* The scheme is the original's: the first tap crosses a square over and
|
||||
* opens a short window, and a second tap in that window is the cat attempt
|
||||
* — so an attempt is offered on every square without a cat, crossed or
|
||||
* not. The cross the attempt replaces is exactly the cross the first tap
|
||||
* drew. Cats, once placed, are never tapped again: they are not a guess
|
||||
* you take back.
|
||||
*
|
||||
* The list is written row by row, which is also the order the hint uses.
|
||||
*/
|
||||
override fun legalMoves(state: MeowdokuState, seat: Int): List<MeowdokuMove> {
|
||||
if (seat != SOLO_SEAT || isFinished(state)) return emptyList()
|
||||
@@ -82,7 +86,7 @@ object MeowdokuRules : GameRules<MeowdokuState, MeowdokuMove> {
|
||||
for (col in 0 until state.side) {
|
||||
val square = Square(row, col)
|
||||
if (square in state.cats) continue
|
||||
if (square !in state.marks) cats += PlaceCat(square)
|
||||
cats += PlaceCat(square)
|
||||
pencils += TogglePencil(square)
|
||||
}
|
||||
}
|
||||
@@ -105,17 +109,18 @@ object MeowdokuRules : GameRules<MeowdokuState, MeowdokuMove> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The tap that is the whole game: found a cat, or lost a heart.
|
||||
* The second tap of a double tap: found a cat, or lost a heart.
|
||||
*
|
||||
* The square was already crossed? Then the tap is not offered at all — see
|
||||
* [legalMoves] — so a player who pencilled over the truth has to notice and
|
||||
* erase before tapping. That is the one way a board can be lost that the
|
||||
* board itself did not do.
|
||||
* A correct tap clears the cross the first tap left behind — the cat
|
||||
* replaces it. A wrong one leaves the cross where it is and spends a
|
||||
* heart, because a wrong square crossed twice is the same mistake twice,
|
||||
* and the board remembers it either way.
|
||||
*/
|
||||
private fun tap(state: MeowdokuState, square: Square): MeowdokuState {
|
||||
if (state.isCatSquare(square)) {
|
||||
val next = state.copy(
|
||||
cats = state.cats + square,
|
||||
marks = state.marks - square,
|
||||
lastSquare = square,
|
||||
lastWrong = false,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.prolibertate.games.score
|
||||
|
||||
/** The short list of names offered when a column is being named. */
|
||||
object RecentNames {
|
||||
const val LIMIT = 10
|
||||
|
||||
/**
|
||||
* [name] moved to the front of [names], trimmed, with any earlier copy
|
||||
* (compared ignoring case) dropped and the list cut to [LIMIT]. A blank name
|
||||
* leaves the list as it was.
|
||||
*/
|
||||
fun with(names: List<String>, name: String): List<String> {
|
||||
val typed = name.trim()
|
||||
if (typed.isEmpty()) return names
|
||||
return (listOf(typed) + names.filterNot { it.equals(typed, ignoreCase = true) }).take(LIMIT)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ class ScorekeeperRepository(private val context: Context) {
|
||||
|
||||
private object Keys {
|
||||
val SHEET = stringPreferencesKey("sheet")
|
||||
val RECENT_NAMES = stringPreferencesKey("recent_names")
|
||||
}
|
||||
|
||||
// Unknown keys are ignored so a sheet written by a newer build can still be
|
||||
@@ -45,6 +46,24 @@ class ScorekeeperRepository(private val context: Context) {
|
||||
?: ScoreSheet()
|
||||
}
|
||||
|
||||
/** The names last used on a sheet, most recent first, for the rename dialog. */
|
||||
val recentNames: Flow<List<String>> = context.scorekeeperDataStore.data.map { prefs ->
|
||||
prefs[Keys.RECENT_NAMES]
|
||||
?.let { stored ->
|
||||
runCatching { json.decodeFromString<List<String>>(stored) }.getOrNull()
|
||||
}
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun rememberName(name: String) {
|
||||
context.scorekeeperDataStore.edit { prefs ->
|
||||
val current = prefs[Keys.RECENT_NAMES]
|
||||
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
|
||||
.orEmpty()
|
||||
prefs[Keys.RECENT_NAMES] = json.encodeToString(RecentNames.with(current, name))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(sheet: ScoreSheet) {
|
||||
context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) }
|
||||
}
|
||||
|
||||
@@ -114,6 +114,8 @@ fun ScorekeeperScreen(
|
||||
local = repository.sheet.first()
|
||||
loaded = true
|
||||
}
|
||||
val recentNames by repository.recentNames.collectAsState(initial = emptyList())
|
||||
val nameUsed: (String) -> Unit = { name -> scope.launch { repository.rememberName(name) } }
|
||||
|
||||
val shared by session.state.collectAsState()
|
||||
val role = shared.role
|
||||
@@ -220,7 +222,7 @@ fun ScorekeeperScreen(
|
||||
|
||||
role == Role.GUEST -> Column(modifier = modifier) {
|
||||
ConnectionBanner(shared = shared, onSync = { session.sync() })
|
||||
Scoring(modifier = Modifier.weight(1f), sheet = sheet, onOp = change)
|
||||
Scoring(Modifier.weight(1f), sheet, change, recentNames, nameUsed)
|
||||
}
|
||||
|
||||
joining && role == Role.NONE ->
|
||||
@@ -232,7 +234,7 @@ fun ScorekeeperScreen(
|
||||
onJoin = { joining = true },
|
||||
)
|
||||
|
||||
else -> Scoring(modifier = modifier, sheet = sheet, onOp = change)
|
||||
else -> Scoring(modifier, sheet, change, recentNames, nameUsed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,16 +358,28 @@ private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, o
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun Scoring(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
|
||||
private fun Scoring(
|
||||
modifier: Modifier,
|
||||
sheet: ScoreSheet,
|
||||
onOp: (SheetOp) -> Unit,
|
||||
recentNames: List<String>,
|
||||
onNameUsed: (String) -> Unit,
|
||||
) {
|
||||
if (sheet.mode == ScoringMode.TALLY) {
|
||||
TallySheet(modifier = modifier, sheet = sheet, onOp = onOp)
|
||||
TallySheet(modifier, sheet, onOp, recentNames, onNameUsed)
|
||||
} else {
|
||||
Sheet(modifier = modifier, sheet = sheet, onOp = onOp)
|
||||
Sheet(modifier, sheet, onOp, recentNames, onNameUsed)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
|
||||
private fun Sheet(
|
||||
modifier: Modifier,
|
||||
sheet: ScoreSheet,
|
||||
onOp: (SheetOp) -> Unit,
|
||||
recentNames: List<String>,
|
||||
onNameUsed: (String) -> Unit,
|
||||
) {
|
||||
// One scroll state shared by the head, the rows and the totals, so all three
|
||||
// move together: a column and the name over it must never come apart. Every
|
||||
// row is built the same way and is therefore exactly the same width, which
|
||||
@@ -587,6 +601,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
||||
RenameDialog(
|
||||
sheet = sheet,
|
||||
player = renamed,
|
||||
recentNames = recentNames,
|
||||
onNameUsed = onNameUsed,
|
||||
onName = { onOp(RenamePlayer(renamed.id, it)) },
|
||||
onRemove = {
|
||||
onOp(RemovePlayer(renamed.id))
|
||||
@@ -607,7 +623,13 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
||||
* sheet, so tapping one in the list takes it back off.
|
||||
*/
|
||||
@Composable
|
||||
private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
|
||||
private fun TallySheet(
|
||||
modifier: Modifier,
|
||||
sheet: ScoreSheet,
|
||||
onOp: (SheetOp) -> Unit,
|
||||
recentNames: List<String>,
|
||||
onNameUsed: (String) -> Unit,
|
||||
) {
|
||||
var renaming by remember { mutableStateOf(-1) }
|
||||
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -676,6 +698,8 @@ private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) ->
|
||||
RenameDialog(
|
||||
sheet = sheet,
|
||||
player = renamed,
|
||||
recentNames = recentNames,
|
||||
onNameUsed = onNameUsed,
|
||||
onName = { onOp(RenamePlayer(renamed.id, it)) },
|
||||
onRemove = {
|
||||
onOp(RemovePlayer(renamed.id))
|
||||
@@ -1064,16 +1088,27 @@ private fun ActionBar(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun RenameDialog(
|
||||
sheet: ScoreSheet,
|
||||
player: ScorePlayer,
|
||||
recentNames: List<String>,
|
||||
onNameUsed: (String) -> Unit,
|
||||
onName: (String) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Closing the dialog is what files the name away, so a name is remembered
|
||||
// once rather than once per keystroke.
|
||||
val finish = {
|
||||
onNameUsed(player.name)
|
||||
onDismiss()
|
||||
}
|
||||
val taken = sheet.players.filter { it.id != player.id }.map { it.name.trim().lowercase() }
|
||||
val suggestions = recentNames.filter { it.trim().lowercase() !in taken }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
onDismissRequest = finish,
|
||||
title = { Text("Name this column") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -1084,6 +1119,21 @@ private fun RenameDialog(
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (suggestions.isNotEmpty()) {
|
||||
Text("Recent names", style = MaterialTheme.typography.labelMedium)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
suggestions.forEach { name ->
|
||||
FilterChip(
|
||||
selected = name.equals(player.name.trim(), ignoreCase = true),
|
||||
onClick = { onName(name) },
|
||||
label = { Text(name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = onRemove,
|
||||
// The last two columns stay: a sheet needs somebody on it.
|
||||
@@ -1091,7 +1141,7 @@ private fun RenameDialog(
|
||||
) { Text("Remove this player and their score") }
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||
confirmButton = { TextButton(onClick = finish) { Text("Done") } },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
@@ -12,6 +11,8 @@ import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
@@ -23,10 +24,13 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -41,9 +45,20 @@ import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.theme.CardRed
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val TITLE = "Meowdoku"
|
||||
|
||||
/**
|
||||
* How long the second tap of a double tap may take to arrive.
|
||||
*
|
||||
* 350 ms, the number the original game ships: long enough that a deliberate
|
||||
* one-two still reads as one question, short enough that nobody pairs two
|
||||
* taps they meant for two separate squares.
|
||||
*/
|
||||
private const val DOUBLE_TAP_WINDOW_MILLIS = 350L
|
||||
|
||||
/**
|
||||
* The region colours, one per cat the board can hold.
|
||||
*
|
||||
@@ -66,16 +81,14 @@ private val RegionColours = listOf(
|
||||
Color(0xFFE3E7C1), // lime
|
||||
)
|
||||
|
||||
/** What a tap does. The original game has a pencil tool too, and this is it. */
|
||||
private enum class TapMode { CATS, PENCIL }
|
||||
|
||||
/**
|
||||
* The puzzle board.
|
||||
* The puzzle board, tapped the way the original's is.
|
||||
*
|
||||
* Cells are laid out by weight inside a square frame, which keeps the grid
|
||||
* exactly as wide as it is tall on any screen, and scales the glyphs with the
|
||||
* cell rather than the font setting: a cat is half a square no matter how big
|
||||
* the square is.
|
||||
* One tap crosses a square over; a second tap on the same square within
|
||||
* [DOUBLE_TAP_WINDOW_MILLIS] asks the board whether a cat lives there. Drag
|
||||
* strokes paint or erase crosses along the line the finger takes, which is
|
||||
* how a row gets crossed out in one gesture rather than eight taps. Cats are
|
||||
* permanent: tapping one does nothing, and a stroke skips over it.
|
||||
*/
|
||||
@Composable
|
||||
fun MeowdokuScreen(
|
||||
@@ -87,7 +100,6 @@ fun MeowdokuScreen(
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
var mode by remember { mutableStateOf(TapMode.CATS) }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
@@ -118,8 +130,8 @@ fun MeowdokuScreen(
|
||||
current.solved -> "Found every cat" +
|
||||
if (current.wrongCats == 0) ", not one tap wasted." else ", with ${current.wrongCats} wrong taps."
|
||||
current.lost -> "Out of hearts, with ${current.side - current.catsFound} cats still hiding."
|
||||
mode == TapMode.CATS -> "${current.catsFound} of ${current.side} cats found. Tap where a cat lives."
|
||||
else -> "Tap to cross a square over, or to erase a cross."
|
||||
else -> "${current.catsFound} of ${current.side} cats found. " +
|
||||
"Tap to cross a square, tap it again for a cat."
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
@@ -131,37 +143,20 @@ fun MeowdokuScreen(
|
||||
|
||||
Board(
|
||||
state = current,
|
||||
places = places,
|
||||
pencils = pencils,
|
||||
onTap = { square ->
|
||||
when {
|
||||
mode == TapMode.CATS && square in places ->
|
||||
controller.submit(PlaceCat(square))
|
||||
|
||||
mode == TapMode.PENCIL && square in pencils ->
|
||||
controller.submit(TogglePencil(square))
|
||||
}
|
||||
},
|
||||
// Moves are refused rather than hidden: an attempt or a note
|
||||
// the board would reject (a tap on a cat, mostly) has to die
|
||||
// here, in the gesture, where the player can be doing nothing
|
||||
// else the same instant.
|
||||
onNote = { square -> if (square in pencils) controller.submit(TogglePencil(square)) },
|
||||
onAttempt = { square -> if (square in places) controller.submit(PlaceCat(square)) },
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (TakeHint in legal) {
|
||||
FilterChip(
|
||||
selected = mode == TapMode.CATS,
|
||||
onClick = { mode = TapMode.CATS },
|
||||
label = { Text("Cats") },
|
||||
selected = false,
|
||||
onClick = { controller.submit(TakeHint) },
|
||||
label = { Text("Hint") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = mode == TapMode.PENCIL,
|
||||
onClick = { mode = TapMode.PENCIL },
|
||||
label = { Text("Pencil") },
|
||||
)
|
||||
if (TakeHint in legal) {
|
||||
FilterChip(
|
||||
selected = false,
|
||||
onClick = { controller.submit(TakeHint) },
|
||||
label = { Text("Hint") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (finished) {
|
||||
@@ -181,30 +176,110 @@ fun MeowdokuScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The board as one gesture surface.
|
||||
*
|
||||
* Cells are drawn by weight inside a square frame, but input is not per-cell:
|
||||
* a stroke is one continuous gesture, so the pointer works in board
|
||||
* coordinates and maps positions to squares itself. The cell list renders
|
||||
* underneath, and the live state is read through [rememberUpdatedState]
|
||||
* because a gesture outlives the recompositions it lands in.
|
||||
*/
|
||||
@Composable
|
||||
private fun Board(
|
||||
state: MeowdokuState,
|
||||
places: Set<Square>,
|
||||
pencils: Set<Square>,
|
||||
onTap: (Square) -> Unit,
|
||||
onNote: (Square) -> Unit,
|
||||
onAttempt: (Square) -> Unit,
|
||||
) {
|
||||
val side = state.side
|
||||
val current by rememberUpdatedState(state)
|
||||
val note by rememberUpdatedState(onNote)
|
||||
val attempt by rememberUpdatedState(onAttempt)
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
// One cell's edge, so a cat is always about half a square whatever
|
||||
// board was dealt.
|
||||
val cell = maxWidth / state.side
|
||||
Column(modifier = Modifier.aspectRatio(1f)) {
|
||||
for (row in 0 until state.side) {
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
for (col in 0 until state.side) {
|
||||
val square = Square(row, col)
|
||||
CellView(
|
||||
square = square,
|
||||
state = state,
|
||||
tappable = square in places || square in pencils,
|
||||
glyphSize = (cell.value * 0.55f).sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
onTap = { onTap(square) },
|
||||
)
|
||||
val cell = maxWidth / side
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.pointerInput(side) {
|
||||
val cellPx = size.width.toFloat() / side
|
||||
var lastTap: Square? = null
|
||||
var lastTapAt = 0L
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val board = current
|
||||
val start = squareAt(down.position, cellPx, side)
|
||||
?: return@awaitEachGesture
|
||||
if (start in board.cats) return@awaitEachGesture
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (start == lastTap && now - lastTapAt < DOUBLE_TAP_WINDOW_MILLIS) {
|
||||
// The second tap of a pair: the cat attempt, and
|
||||
// no note — the cross it lands on was drawn by
|
||||
// this pair's first tap, and the board replaces it.
|
||||
lastTap = null
|
||||
attempt(start)
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
change?.consume()
|
||||
} while (change != null && change.pressed)
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
// The first tap: a note, and the stroke that may follow
|
||||
// paints the same note everywhere it goes.
|
||||
lastTap = start
|
||||
lastTapAt = now
|
||||
val erasing = start in board.marks
|
||||
note(start)
|
||||
val marks = board.marks.toMutableSet()
|
||||
marks.flip(start)
|
||||
var previous = start
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
change.consume()
|
||||
val here = squareAt(change.position, cellPx, side) ?: continue
|
||||
if (here == previous) continue
|
||||
for (square in path(previous, here)) {
|
||||
if (square in board.cats) continue
|
||||
// `marks` is the stroke's own mirror: the base
|
||||
// board plus every note this stroke has
|
||||
// already submitted. The state flow lags a
|
||||
// frame behind submissions, and a stroke that
|
||||
// crosses a square twice must leave it at the
|
||||
// stroke's target, not flip it back.
|
||||
//
|
||||
// Erasing means "flip everything still
|
||||
// crossed"; painting means "flip everything
|
||||
// still blank" — which is the same test read
|
||||
// two ways: flip while marked == erasing.
|
||||
if ((square in marks) == erasing) {
|
||||
note(square)
|
||||
marks.flip(square)
|
||||
}
|
||||
}
|
||||
previous = here
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.aspectRatio(1f)) {
|
||||
for (row in 0 until side) {
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
for (col in 0 until side) {
|
||||
CellView(
|
||||
square = Square(row, col),
|
||||
state = current,
|
||||
glyphSize = (cell.value * 0.55f).sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,14 +287,46 @@ private fun Board(
|
||||
}
|
||||
}
|
||||
|
||||
/** Which square a pointer position is over, or null if it has left the board. */
|
||||
private fun squareAt(position: Offset, cellPx: Float, side: Int): Square? {
|
||||
val col = (position.x / cellPx).toInt()
|
||||
val row = (position.y / cellPx).toInt()
|
||||
if (row !in 0 until side || col !in 0 until side) return null
|
||||
return Square(row, col)
|
||||
}
|
||||
|
||||
/** Add the square, or drop it if it was already there — the stroke's own toggle. */
|
||||
private fun MutableSet<Square>.flip(square: Square) {
|
||||
if (!add(square)) remove(square)
|
||||
}
|
||||
|
||||
/**
|
||||
* The squares between two consecutive pointer reads, endpoints not repeated.
|
||||
*
|
||||
* A fast stroke publishes fewer positions than it crosses cells, so the line
|
||||
* between two reads is walked in whole-cell steps. [path] returns at most one
|
||||
* square per cell crossed, in order, which is what lets a stroke cross a row
|
||||
* in one drag without missing the squares between two events.
|
||||
*/
|
||||
private fun path(from: Square, to: Square): List<Square> {
|
||||
val dr = to.row - from.row
|
||||
val dc = to.col - from.col
|
||||
val steps = maxOf(abs(dr), abs(dc))
|
||||
if (steps <= 1) return listOf(to)
|
||||
return (1..steps).map { i ->
|
||||
Square(
|
||||
from.row + (dr * i.toFloat() / steps).roundToInt(),
|
||||
from.col + (dc * i.toFloat() / steps).roundToInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CellView(
|
||||
square: Square,
|
||||
state: MeowdokuState,
|
||||
tappable: Boolean,
|
||||
glyphSize: TextUnit,
|
||||
modifier: Modifier,
|
||||
onTap: () -> Unit,
|
||||
) {
|
||||
val isCat = square in state.cats
|
||||
val isMark = square in state.marks
|
||||
@@ -231,8 +338,7 @@ private fun CellView(
|
||||
.background(RegionColours[state.regionOf(square) % RegionColours.size])
|
||||
.then(
|
||||
if (justWrong) Modifier.border(3.dp, CardRed) else Modifier
|
||||
)
|
||||
.clickable(enabled = tappable) { onTap() },
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
|
||||
@@ -198,17 +198,37 @@ class MeowdokuRulesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tapped square that is crossed over is not offered`() {
|
||||
fun `a crossed square still takes a cat attempt`() {
|
||||
// The double tap is one gesture pair: the first tap crosses the square
|
||||
// and the second asks it. The cross the pair drew cannot veto the
|
||||
// question that follows it.
|
||||
val state = dealt(6)
|
||||
val wrongRow = 1
|
||||
val wrongCol = if (state.catCols[wrongRow] == 0) 1 else 0
|
||||
val marked = MeowdokuRules.applyMove(state, 0, PlaceCat(Square(wrongRow, wrongCol)))
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
MeowdokuRules.applyMove(marked, 0, PlaceCat(Square(wrongRow, wrongCol)))
|
||||
}
|
||||
// A pencil erases the cross and opens the tap again.
|
||||
val erased = MeowdokuRules.applyMove(marked, 0, TogglePencil(Square(wrongRow, wrongCol)))
|
||||
assertFalse(Square(wrongRow, wrongCol) in erased.marks)
|
||||
val truth = Square(0, state.catCols[0])
|
||||
val crossed = MeowdokuRules.applyMove(state, 0, TogglePencil(truth))
|
||||
assertTrue(truth in crossed.marks)
|
||||
assertTrue(
|
||||
"the attempt is offered over its own cross",
|
||||
PlaceCat(truth) in MeowdokuRules.legalMoves(crossed, 0),
|
||||
)
|
||||
|
||||
val placed = MeowdokuRules.applyMove(crossed, 0, PlaceCat(truth))
|
||||
assertTrue(truth in placed.cats)
|
||||
assertFalse("the cat replaces the cross", truth in placed.marks)
|
||||
assertEquals(3, placed.heartsLeft)
|
||||
assertEquals(0, placed.wrongCats)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wrong pair keeps its cross and spends a heart`() {
|
||||
val state = dealt(6)
|
||||
val wrongCol = if (state.catCols[1] == 0) 1 else 0
|
||||
val wrong = Square(1, wrongCol)
|
||||
val crossed = MeowdokuRules.applyMove(state, 0, TogglePencil(wrong))
|
||||
val judged = MeowdokuRules.applyMove(crossed, 0, PlaceCat(wrong))
|
||||
assertTrue(wrong in judged.marks)
|
||||
assertFalse(wrong in judged.cats)
|
||||
assertEquals(2, judged.heartsLeft)
|
||||
assertEquals(1, judged.wrongCats)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.prolibertate.games.score
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.assertEquals
|
||||
|
||||
class RecentNamesTest {
|
||||
@Test
|
||||
fun newestComesFirst() {
|
||||
assertEquals(listOf("Bo", "Al"), RecentNames.with(listOf("Al"), "Bo"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatMovesToFrontIgnoringCase() {
|
||||
assertEquals(listOf("al", "Bo"), RecentNames.with(listOf("Bo", "Al"), " al "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankChangesNothing() {
|
||||
assertEquals(listOf("Al"), RecentNames.with(listOf("Al"), " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsOnlyTen() {
|
||||
val names = (1..10).map { "n$it" }
|
||||
val result = RecentNames.with(names, "new")
|
||||
assertEquals(10, result.size)
|
||||
assertEquals("new", result.first())
|
||||
assertEquals("n9", result.last())
|
||||
}
|
||||
}
|
||||
+24
-13
@@ -19,22 +19,33 @@ and it sees only the colours and the rules — never the cats it was grown from.
|
||||
|
||||
## Playing it
|
||||
|
||||
**Tap a square where a cat lives.** A correct tap lands the cat; a wrong one
|
||||
costs a heart and crosses the square over, because being wrong here is
|
||||
information and the board keeps it for you. Finding all N cats wins; losing the
|
||||
hearts ends the board with the rest still hidden.
|
||||
Controls are the original's, and they carry the game's whole psychology:
|
||||
|
||||
The **Pencil** toggle is for the marks you make yourself. They are free, they
|
||||
cost nothing to erase, and the board does not judge them. The one trap they
|
||||
carry: a square crossed over is a square you have decided against, so tapping a
|
||||
cat onto it is refused until the pencil erases the note. The original game
|
||||
draws crosses for you on a wrong tap for the same reason — a wrong square
|
||||
crossed twice is the same mistake twice — and here the same thing happens
|
||||
whether the cross came from the heart or from the pencil.
|
||||
- **One tap crosses a square over.** Free, instant, erasable — the note you
|
||||
make while thinking.
|
||||
- **A second tap on the same square within 350 ms is the cat attempt.** The
|
||||
board judges it against the answer it was dealt: correct, the cross is
|
||||
replaced by a cat; wrong, the cross stays and a heart goes. This is why a
|
||||
guess feels like a decision: your own note has to survive one more tap
|
||||
before it becomes a bet.
|
||||
- **Drag to cross a line.** The stroke paints the same note every square it
|
||||
enters: start on a blank square and it crosses the row or column you drag
|
||||
along; start on a crossed square and the stroke erases them instead. Cats
|
||||
are skipped — placed cats are permanent.
|
||||
|
||||
A square you pencilled over can still be tapped for a cat, and the cross is
|
||||
forgiven: the attempt replaces it if it is right. The one thing no gesture
|
||||
undoes is a cat once placed.
|
||||
|
||||
The original ships a fifth gesture — a **draft** scratchpad mode, a tool you
|
||||
buy, where crosses and cats are laid down without judgement and applied all at
|
||||
once — and it is left out here with the rest of the shop: a scratchpad whose
|
||||
notes cost nothing is what tapping already is.
|
||||
|
||||
A **Hint** spends a token and shows the cat in the first row that still lacks
|
||||
one. The original sells these as tools; here they are simply counted, three per
|
||||
board by default, which keeps the hint a decision rather than a reflex.
|
||||
one, replacing any cross drawn over it. The original sells these as tools; here
|
||||
they are simply counted, three per board by default, which keeps the hint a
|
||||
decision rather than a reflex.
|
||||
|
||||
## The heart
|
||||
|
||||
|
||||
Reference in New Issue
Block a user