@@ -54,7 +54,7 @@ jobs:
|
|||||||
cache: gradle
|
cache: gradle
|
||||||
|
|
||||||
- name: Set up Android SDK
|
- name: Set up Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v4
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
run: ./gradlew assembleDebug --no-daemon
|
run: ./gradlew assembleDebug --no-daemon
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
cache: gradle
|
cache: gradle
|
||||||
|
|
||||||
- name: Set up Android SDK
|
- name: Set up Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v4
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
run: ./gradlew assembleDebug --no-daemon --stacktrace
|
run: ./gradlew assembleDebug --no-daemon --stacktrace
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ production package instead.
|
|||||||
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull
|
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull
|
||||||
requests into `dev` or `main`. Its APK artifact is a dev build, versioned
|
requests into `dev` or `main`. Its APK artifact is a dev build, versioned
|
||||||
`<series>.0-dev` — the series being whatever `plgVersionSeries` says in
|
`<series>.0-dev` — the series being whatever `plgVersionSeries` says in
|
||||||
`gradle.properties`, currently **1.1**.
|
`gradle.properties`, currently **1.3**.
|
||||||
- `.github/workflows/release.yml` — on push to `main`, builds the APK and
|
- `.github/workflows/release.yml` — on push to `main`, builds the APK and
|
||||||
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
|
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
|
||||||
reads that tag, which is why the APK's `versionCode` is the same run number.
|
reads that tag, which is why the APK's `versionCode` is the same run number.
|
||||||
@@ -201,7 +201,7 @@ A version is **`<series>.<run number>`**. The series is set by hand, once, in
|
|||||||
`gradle.properties`:
|
`gradle.properties`:
|
||||||
|
|
||||||
```properties
|
```properties
|
||||||
plgVersionSeries=1.1
|
plgVersionSeries=1.3
|
||||||
```
|
```
|
||||||
|
|
||||||
Both workflows read that same property when they name a release tag, and the
|
Both workflows read that same property when they name a release tag, and the
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<!-- Network Service Discovery needs multicast to see hosts on the LAN. -->
|
<!-- Network Service Discovery needs multicast to see hosts on the LAN. -->
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
<!-- Holding a Wi-Fi lock keeps the radio out of power save while a game is
|
||||||
|
on, so a phone locked between turns does not drop out of it. The lock
|
||||||
|
is a wake lock as far as the system is concerned, hence the permission;
|
||||||
|
nothing here ever holds the screen or the CPU awake. -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<!-- Lets the app hand a downloaded APK to the system installer. -->
|
<!-- Lets the app hand a downloaded APK to the system installer. -->
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ data class KaiserState(
|
|||||||
val lastTrickWinner: Int?,
|
val lastTrickWinner: Int?,
|
||||||
/** Points taken this hand, per team. */
|
/** Points taken this hand, per team. */
|
||||||
val handPoints: List<Int>,
|
val handPoints: List<Int>,
|
||||||
|
/**
|
||||||
|
* Tricks taken this hand, per seat. Kaiser scores in points rather than
|
||||||
|
* tricks, so nothing in the rules needs this — it is what the table draws
|
||||||
|
* in front of each player, and a player who has taken four tricks and no
|
||||||
|
* counters has still taken four tricks. Defaulted so a client on an older
|
||||||
|
* build can still read a state from the wire.
|
||||||
|
*/
|
||||||
|
val tricksWon: List<Int> = List(4) { 0 },
|
||||||
val scores: List<Int>,
|
val scores: List<Int>,
|
||||||
val log: List<String>,
|
val log: List<String>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ object KaiserRules : GameRules<KaiserState, KaiserMove> {
|
|||||||
leader = (dealer + 1) % 4,
|
leader = (dealer + 1) % 4,
|
||||||
lastTrickWinner = null,
|
lastTrickWinner = null,
|
||||||
handPoints = listOf(0, 0),
|
handPoints = listOf(0, 0),
|
||||||
|
tricksWon = List(4) { 0 },
|
||||||
scores = scores,
|
scores = scores,
|
||||||
log = log,
|
log = log,
|
||||||
)
|
)
|
||||||
@@ -224,10 +225,14 @@ object KaiserRules : GameRules<KaiserState, KaiserMove> {
|
|||||||
val handPoints = played.handPoints.toMutableList()
|
val handPoints = played.handPoints.toMutableList()
|
||||||
handPoints[teamOf(winner)] = handPoints[teamOf(winner)] + points
|
handPoints[teamOf(winner)] = handPoints[teamOf(winner)] + points
|
||||||
|
|
||||||
|
val tricksWon = played.tricksWon.toMutableList()
|
||||||
|
tricksWon[winner] = tricksWon[winner] + 1
|
||||||
|
|
||||||
val afterTrick = played.copy(
|
val afterTrick = played.copy(
|
||||||
trick = emptyList(),
|
trick = emptyList(),
|
||||||
completedTrick = trick,
|
completedTrick = trick,
|
||||||
handPoints = handPoints,
|
handPoints = handPoints,
|
||||||
|
tricksWon = tricksWon,
|
||||||
leader = winner,
|
leader = winner,
|
||||||
turn = winner,
|
turn = winner,
|
||||||
lastTrickWinner = winner,
|
lastTrickWinner = winner,
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package org.prolibertate.games.net
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.nsd.NsdManager
|
import android.net.nsd.NsdManager
|
||||||
import android.net.nsd.NsdServiceInfo
|
import android.net.nsd.NsdServiceInfo
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -22,7 +26,9 @@ import java.util.Collections
|
|||||||
*
|
*
|
||||||
* NSD is used rather than a UDP broadcast ping because it works across the
|
* NSD is used rather than a UDP broadcast ping because it works across the
|
||||||
* Wi-Fi isolation settings most home routers ship with, and because Android
|
* Wi-Fi isolation settings most home routers ship with, and because Android
|
||||||
* gives us the resolver for free.
|
* gives us the resolver for free. It is not, however, trusted to be enough:
|
||||||
|
* see [endpoint] and [LocalNetwork] for what a phone's own hotspot does to
|
||||||
|
* both discovery and routing, and for the way round it.
|
||||||
*/
|
*/
|
||||||
class LanTransport(private val context: Context) : Transport {
|
class LanTransport(private val context: Context) : Transport {
|
||||||
|
|
||||||
@@ -31,20 +37,64 @@ class LanTransport(private val context: Context) : Transport {
|
|||||||
private val nsdManager: NsdManager
|
private val nsdManager: NsdManager
|
||||||
get() = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
get() = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||||
|
|
||||||
|
private val wifiManager: WifiManager
|
||||||
|
get() = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||||
|
private val multicast = MulticastGuard(context)
|
||||||
|
|
||||||
private var serverSocket: ServerSocket? = null
|
private var serverSocket: ServerSocket? = null
|
||||||
private var registrationListener: NsdManager.RegistrationListener? = null
|
private var registrationListener: NsdManager.RegistrationListener? = null
|
||||||
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
||||||
|
private var wifiLock: WifiManager.WifiLock? = null
|
||||||
|
|
||||||
|
private val _endpoint = MutableStateFlow<HostEndpoint?>(null)
|
||||||
|
|
||||||
|
/** Where this device is listening while hosting, so it can be read out. */
|
||||||
|
val endpoint: StateFlow<HostEndpoint?> = _endpoint.asStateFlow()
|
||||||
|
|
||||||
override fun isAvailable(): Boolean = true
|
override fun isAvailable(): Boolean = true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the Wi-Fi radio properly awake for as long as there is a game on.
|
||||||
|
*
|
||||||
|
* Android puts the radio into power save when the screen goes off, which is
|
||||||
|
* what a locked phone is, and a link left to it drops out from under a game
|
||||||
|
* that is still being played. FULL_HIGH_PERF is the mode that says otherwise
|
||||||
|
* — the deprecation notice points at LOW_LATENCY, which is the wrong one
|
||||||
|
* here: that one only applies while the screen is on and the app is in
|
||||||
|
* front, which is precisely the case that was never broken.
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun holdRadio() {
|
||||||
|
if (wifiLock?.isHeld == true) return
|
||||||
|
val lock = wifiLock ?: runCatching {
|
||||||
|
wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, WIFI_LOCK_TAG)
|
||||||
|
.also { it.setReferenceCounted(false) }
|
||||||
|
}.getOrNull() ?: return
|
||||||
|
wifiLock = lock
|
||||||
|
runCatching { lock.acquire() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseRadio() {
|
||||||
|
wifiLock?.let { lock -> runCatching { if (lock.isHeld) lock.release() } }
|
||||||
|
wifiLock = null
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Hosting
|
// Hosting
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
override fun host(displayName: String, scope: CoroutineScope): Flow<Connection> = callbackFlow {
|
override fun host(displayName: String, scope: CoroutineScope): Flow<Connection> = callbackFlow {
|
||||||
// Port 0 lets the OS pick a free port, which is then advertised.
|
holdRadio()
|
||||||
val server = ServerSocket(0)
|
|
||||||
|
// The known port when it can be had, so that joining by hand needs an
|
||||||
|
// address and nothing else; anything free if something already has it.
|
||||||
|
val server = runCatching { ServerSocket(DEFAULT_HOST_PORT) }
|
||||||
|
.getOrElse { ServerSocket(0) }
|
||||||
serverSocket = server
|
serverSocket = server
|
||||||
|
_endpoint.value = HostEndpoint(localIpv4Addresses(), server.localPort)
|
||||||
|
|
||||||
|
// Advertising is mDNS, and mDNS is multicast.
|
||||||
|
multicast.acquire()
|
||||||
|
|
||||||
val serviceInfo = NsdServiceInfo().apply {
|
val serviceInfo = NsdServiceInfo().apply {
|
||||||
serviceName = "$SERVICE_NAME @ $displayName"
|
serviceName = "$SERVICE_NAME @ $displayName"
|
||||||
@@ -66,6 +116,7 @@ class LanTransport(private val context: Context) : Transport {
|
|||||||
while (isActive && !server.isClosed) {
|
while (isActive && !server.isClosed) {
|
||||||
val socket = runCatching { server.accept() }.getOrNull() ?: break
|
val socket = runCatching { server.accept() }.getOrNull() ?: break
|
||||||
socket.tcpNoDelay = true
|
socket.tcpNoDelay = true
|
||||||
|
socket.keepAlive = true
|
||||||
val connection = StreamConnection(
|
val connection = StreamConnection(
|
||||||
peerId = socket.inetAddress?.hostAddress ?: "unknown",
|
peerId = socket.inetAddress?.hostAddress ?: "unknown",
|
||||||
kind = TransportKind.LAN,
|
kind = TransportKind.LAN,
|
||||||
@@ -82,6 +133,7 @@ class LanTransport(private val context: Context) : Transport {
|
|||||||
acceptJob.cancel()
|
acceptJob.cancel()
|
||||||
stopAdvertising()
|
stopAdvertising()
|
||||||
runCatching { server.close() }
|
runCatching { server.close() }
|
||||||
|
_endpoint.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +142,8 @@ class LanTransport(private val context: Context) : Transport {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
override fun discover(scope: CoroutineScope): Flow<List<DiscoveredHost>> = callbackFlow {
|
override fun discover(scope: CoroutineScope): Flow<List<DiscoveredHost>> = callbackFlow {
|
||||||
|
holdRadio()
|
||||||
|
|
||||||
val found = Collections.synchronizedMap(linkedMapOf<String, DiscoveredHost>())
|
val found = Collections.synchronizedMap(linkedMapOf<String, DiscoveredHost>())
|
||||||
|
|
||||||
fun publish() {
|
fun publish() {
|
||||||
@@ -163,6 +217,10 @@ class LanTransport(private val context: Context) : Transport {
|
|||||||
}
|
}
|
||||||
discoveryListener = listener
|
discoveryListener = listener
|
||||||
|
|
||||||
|
// Listening for mDNS replies needs the multicast filter held open just
|
||||||
|
// as much as answering does.
|
||||||
|
multicast.acquire()
|
||||||
|
|
||||||
publish()
|
publish()
|
||||||
runCatching {
|
runCatching {
|
||||||
nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
|
nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
|
||||||
@@ -177,9 +235,16 @@ class LanTransport(private val context: Context) : Transport {
|
|||||||
|
|
||||||
override suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection =
|
override suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
holdRadio()
|
||||||
val socket = Socket()
|
val socket = Socket()
|
||||||
|
// Before connecting, and this is the fix for hotspot play: a
|
||||||
|
// hotspot has no internet behind it, so Android leaves mobile data
|
||||||
|
// as the default network and an unbound socket goes out over
|
||||||
|
// cellular looking for a 192.168 address.
|
||||||
|
bindForTarget(context, socket, host.address)
|
||||||
socket.connect(InetSocketAddress(host.address, host.port), CONNECT_TIMEOUT_MS)
|
socket.connect(InetSocketAddress(host.address, host.port), CONNECT_TIMEOUT_MS)
|
||||||
socket.tcpNoDelay = true
|
socket.tcpNoDelay = true
|
||||||
|
socket.keepAlive = true
|
||||||
StreamConnection(
|
StreamConnection(
|
||||||
peerId = host.id,
|
peerId = host.id,
|
||||||
kind = TransportKind.LAN,
|
kind = TransportKind.LAN,
|
||||||
@@ -205,11 +270,15 @@ class LanTransport(private val context: Context) : Transport {
|
|||||||
override fun stop() {
|
override fun stop() {
|
||||||
stopAdvertising()
|
stopAdvertising()
|
||||||
stopDiscovery()
|
stopDiscovery()
|
||||||
|
multicast.release()
|
||||||
runCatching { serverSocket?.close() }
|
runCatching { serverSocket?.close() }
|
||||||
serverSocket = null
|
serverSocket = null
|
||||||
|
releaseRadio()
|
||||||
|
_endpoint.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val CONNECT_TIMEOUT_MS = 8_000
|
const val CONNECT_TIMEOUT_MS = 8_000
|
||||||
|
const val WIFI_LOCK_TAG = "ProLibertateGames:lan"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.prolibertate.games.net
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -10,6 +11,7 @@ import kotlinx.coroutines.launch
|
|||||||
import org.prolibertate.games.game.engine.PlayerKind
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
import org.prolibertate.games.game.engine.PlayerSlot
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pre-game lobby.
|
* The pre-game lobby.
|
||||||
@@ -34,6 +36,8 @@ class LobbyController(
|
|||||||
val hostName: String = "",
|
val hostName: String = "",
|
||||||
val seats: List<PlayerSlot> = emptyList(),
|
val seats: List<PlayerSlot> = emptyList(),
|
||||||
val discovered: List<DiscoveredHost> = emptyList(),
|
val discovered: List<DiscoveredHost> = emptyList(),
|
||||||
|
/** Where this device is listening while hosting, for reading out. */
|
||||||
|
val endpoint: HostEndpoint? = null,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
val started: TableConfig? = null,
|
val started: TableConfig? = null,
|
||||||
)
|
)
|
||||||
@@ -48,8 +52,27 @@ class LobbyController(
|
|||||||
/** Peer id -> live connection, for every seated remote player. */
|
/** Peer id -> live connection, for every seated remote player. */
|
||||||
private val connections = linkedMapOf<String, Connection>()
|
private val connections = linkedMapOf<String, Connection>()
|
||||||
private var clientLink: Connection? = null
|
private var clientLink: Connection? = null
|
||||||
|
private var clientJob: Job? = null
|
||||||
private val jobs = mutableListOf<Job>()
|
private val jobs = mutableListOf<Job>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this device joined, so it can find its way back.
|
||||||
|
*
|
||||||
|
* The host goes on listening on the same port for as long as it is hosting,
|
||||||
|
* which is the whole match, so a guest that has lost its link can dial the
|
||||||
|
* address it already knows rather than go round discovery again.
|
||||||
|
*/
|
||||||
|
private var joinedHost: DiscoveredHost? = null
|
||||||
|
private var joinedName: String = ""
|
||||||
|
private var joinedPeerId: String = ""
|
||||||
|
private val reconnecting = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table currently being played, if any, so a replaced link can be given
|
||||||
|
* to it. Set by the match screen for as long as it is on screen.
|
||||||
|
*/
|
||||||
|
var rebinder: LinkRebinder? = null
|
||||||
|
|
||||||
val hostConnections: List<Connection> get() = connections.values.toList()
|
val hostConnections: List<Connection> get() = connections.values.toList()
|
||||||
val clientConnection: Connection? get() = clientLink
|
val clientConnection: Connection? get() = clientLink
|
||||||
|
|
||||||
@@ -91,6 +114,15 @@ class LobbyController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discovery is not guaranteed — a phone sharing its own hotspot cannot
|
||||||
|
// be relied on to advertise over the tethering interface — so where the
|
||||||
|
// host is listening is put on its screen to be read out.
|
||||||
|
jobs += scope.launch {
|
||||||
|
lan.endpoint.collect { endpoint ->
|
||||||
|
_state.value = _state.value.copy(endpoint = endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onClientConnected(connection: Connection) {
|
private fun onClientConnected(connection: Connection) {
|
||||||
@@ -118,6 +150,34 @@ class LobbyController(
|
|||||||
private suspend fun seatPlayer(connection: Connection, hello: Hello) {
|
private suspend fun seatPlayer(connection: Connection, hello: Hello) {
|
||||||
val current = _state.value
|
val current = _state.value
|
||||||
val seats = current.seats.toMutableList()
|
val seats = current.seats.toMutableList()
|
||||||
|
|
||||||
|
// A device the table already knows is not somebody new asking for a
|
||||||
|
// seat: it is a player whose link went away — a phone locked long
|
||||||
|
// enough for the radio to drop it is the usual way — coming back to the
|
||||||
|
// one it already has. Seating it again would find no free seat and turn
|
||||||
|
// it away from its own game, so it is put back where it was.
|
||||||
|
val returning = seats.indexOfFirst { it.deviceId == hello.peerId }
|
||||||
|
if (returning >= 0) {
|
||||||
|
val stale = seats[returning].peerId?.let { connections.remove(it) }
|
||||||
|
seats[returning] = seats[returning].copy(
|
||||||
|
name = hello.displayName,
|
||||||
|
kind = PlayerKind.HUMAN_REMOTE,
|
||||||
|
peerId = connection.peerId,
|
||||||
|
)
|
||||||
|
connections[connection.peerId] = connection
|
||||||
|
if (stale != null && stale !== connection) runCatching { stale.close() }
|
||||||
|
_state.value = current.copy(
|
||||||
|
seats = seats,
|
||||||
|
message = "${hello.displayName} is back.",
|
||||||
|
)
|
||||||
|
connection.send(Welcome(current.hostName, current.gameId.orEmpty(), accepted = true))
|
||||||
|
broadcastLobby()
|
||||||
|
// A match already under way keeps the seat and the position it is
|
||||||
|
// sitting in. Only the link is new.
|
||||||
|
rebinder?.rebindPeer(seats[returning].seat, connection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val index = seats.indexOfFirst { it.kind == PlayerKind.AI }
|
val index = seats.indexOfFirst { it.kind == PlayerKind.AI }
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
connection.send(
|
connection.send(
|
||||||
@@ -205,48 +265,155 @@ class LobbyController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins a host at an address somebody typed in.
|
||||||
|
*
|
||||||
|
* The way in when discovery cannot work. Two phones on one phone's hotspot
|
||||||
|
* is exactly that case: mDNS over a tethering interface is unreliable in
|
||||||
|
* both directions, and no amount of listening harder fixes it, but the
|
||||||
|
* address is right there on the host's screen and a TCP connection to it
|
||||||
|
* works perfectly well once the socket is pointed at the right network.
|
||||||
|
*/
|
||||||
|
fun joinAt(typed: String, displayName: String, peerId: String) {
|
||||||
|
val parsed = parseManualAddress(typed)
|
||||||
|
if (parsed == null) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
message = "That doesn't look like an address. Try 192.168.43.1",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
join(
|
||||||
|
host = DiscoveredHost(
|
||||||
|
id = "lan:${parsed.address}:${parsed.port}",
|
||||||
|
name = parsed.address,
|
||||||
|
kind = TransportKind.LAN,
|
||||||
|
address = parsed.address,
|
||||||
|
port = parsed.port,
|
||||||
|
),
|
||||||
|
displayName = displayName,
|
||||||
|
peerId = peerId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun join(host: DiscoveredHost, displayName: String, peerId: String) {
|
fun join(host: DiscoveredHost, displayName: String, peerId: String) {
|
||||||
jobs += scope.launch {
|
jobs += scope.launch {
|
||||||
_state.value = _state.value.copy(message = "Connecting to ${host.name}…")
|
_state.value = _state.value.copy(message = "Connecting to ${host.name}…")
|
||||||
val transport = transports.firstOrNull { it.kind == host.kind } ?: return@launch
|
joinedHost = host
|
||||||
val connection = runCatching { transport.join(host, scope) }.getOrElse { error ->
|
joinedName = displayName
|
||||||
_state.value = _state.value.copy(message = "Couldn't connect: ${error.message}")
|
joinedPeerId = peerId
|
||||||
return@launch
|
val connection = dial(host) ?: return@launch
|
||||||
}
|
attachClientLink(connection)
|
||||||
clientLink = connection
|
}
|
||||||
|
}
|
||||||
|
|
||||||
launch {
|
private suspend fun dial(host: DiscoveredHost): Connection? {
|
||||||
connection.incoming.collect { message ->
|
val transport = transports.firstOrNull { it.kind == host.kind } ?: return null
|
||||||
when (message) {
|
return runCatching { transport.join(host, scope) }.getOrElse { error ->
|
||||||
is Welcome -> _state.value = if (message.accepted) {
|
_state.value = _state.value.copy(message = "Couldn't connect: ${error.message}")
|
||||||
_state.value.copy(
|
null
|
||||||
connected = true,
|
}
|
||||||
gameId = message.gameId,
|
}
|
||||||
hostName = message.hostName,
|
|
||||||
message = "Joined ${message.hostName}. Waiting for the host to start…",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
_state.value.copy(message = message.reason ?: "The host declined.")
|
|
||||||
}
|
|
||||||
|
|
||||||
is LobbyUpdate -> _state.value = _state.value.copy(
|
/**
|
||||||
seats = message.seats,
|
* Starts reading [connection] as this device's way to the host, and
|
||||||
|
* introduces itself on it.
|
||||||
|
*
|
||||||
|
* Any previous reader is stopped first: collecting a connection's messages
|
||||||
|
* never ends on its own, so a link that has been replaced would otherwise
|
||||||
|
* leave its collector behind writing into the same lobby state.
|
||||||
|
*/
|
||||||
|
private suspend fun attachClientLink(connection: Connection) {
|
||||||
|
clientJob?.cancel()
|
||||||
|
clientLink = connection
|
||||||
|
clientJob = scope.launch {
|
||||||
|
connection.incoming.collect { message ->
|
||||||
|
when (message) {
|
||||||
|
is Welcome -> _state.value = if (message.accepted) {
|
||||||
|
_state.value.copy(
|
||||||
|
connected = true,
|
||||||
gameId = message.gameId,
|
gameId = message.gameId,
|
||||||
optionsJson = message.optionsJson,
|
|
||||||
hostName = message.hostName,
|
hostName = message.hostName,
|
||||||
|
message = "Joined ${message.hostName}. Waiting for the host to start…",
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
is StartGame -> _state.value = _state.value.copy(started = message.config)
|
_state.value.copy(message = message.reason ?: "The host declined.")
|
||||||
|
|
||||||
is Bye -> _state.value =
|
|
||||||
_state.value.copy(connected = false, message = "The host left.")
|
|
||||||
|
|
||||||
else -> Unit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is LobbyUpdate -> _state.value = _state.value.copy(
|
||||||
|
seats = message.seats,
|
||||||
|
gameId = message.gameId,
|
||||||
|
optionsJson = message.optionsJson,
|
||||||
|
hostName = message.hostName,
|
||||||
|
)
|
||||||
|
|
||||||
|
is StartGame -> _state.value = _state.value.copy(started = message.config)
|
||||||
|
|
||||||
|
is Bye -> {
|
||||||
|
_state.value =
|
||||||
|
_state.value.copy(connected = false, message = "The host left.")
|
||||||
|
// A link that has gone is worth replacing while there is
|
||||||
|
// still a table to come back to. In its own job, because
|
||||||
|
// taking the new link into use stops this one.
|
||||||
|
if (rebinder != null) jobs += scope.launch { reconnect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
connection.send(Hello(peerId = peerId, displayName = displayName))
|
connection.send(Hello(peerId = joinedPeerId, displayName = joinedName))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client: makes sure there is still a way to the host, and asks for the
|
||||||
|
* table again.
|
||||||
|
*
|
||||||
|
* Called when the app comes back to the front. A phone that was locked when
|
||||||
|
* its turn came round has missed whatever the host pushed in the meantime —
|
||||||
|
* a client draws nothing but what it was sent — and may have lost the link
|
||||||
|
* entirely while the radio was down, in which case there is no point asking
|
||||||
|
* politely and it dials again instead.
|
||||||
|
*
|
||||||
|
* Costs nothing when nothing is wrong: a live link is sent one [Resync],
|
||||||
|
* and the answer is the state it already has.
|
||||||
|
*/
|
||||||
|
fun restoreLink() {
|
||||||
|
if (joinedHost == null) return
|
||||||
|
jobs += scope.launch {
|
||||||
|
val live = clientLink
|
||||||
|
if (live != null && live.isOpen) live.send(Resync) else reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dials the host again and hands the new link to whatever is being played.
|
||||||
|
*
|
||||||
|
* One attempt at a time: the link can be found to be gone twice over — the
|
||||||
|
* reader announcing it and the screen coming back both arrive at the same
|
||||||
|
* conclusion — and two dials would leave the host holding two links for one
|
||||||
|
* seat, having closed the one actually in use.
|
||||||
|
*/
|
||||||
|
private suspend fun reconnect() {
|
||||||
|
val host = joinedHost ?: return
|
||||||
|
if (!reconnecting.compareAndSet(false, true)) return
|
||||||
|
try {
|
||||||
|
repeat(RECONNECT_ATTEMPTS) { attempt ->
|
||||||
|
if (attempt > 0) delay(RECONNECT_BACKOFF_MILLIS * attempt)
|
||||||
|
if (clientLink?.isOpen == true) return
|
||||||
|
val connection = dial(host)
|
||||||
|
if (connection != null) {
|
||||||
|
// Hello first and rebind second, in that order: the host has
|
||||||
|
// to have put this device back in its seat before it can
|
||||||
|
// answer the table's resync with anything.
|
||||||
|
attachClientLink(connection)
|
||||||
|
rebinder?.rebindHostLink(connection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(message = "Couldn't get back to ${host.name}.")
|
||||||
|
} finally {
|
||||||
|
reconnecting.set(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,11 +424,28 @@ class LobbyController(
|
|||||||
fun stop() {
|
fun stop() {
|
||||||
jobs.forEach { it.cancel() }
|
jobs.forEach { it.cancel() }
|
||||||
jobs.clear()
|
jobs.clear()
|
||||||
|
clientJob?.cancel()
|
||||||
|
clientJob = null
|
||||||
connections.values.forEach { runCatching { it.close() } }
|
connections.values.forEach { runCatching { it.close() } }
|
||||||
connections.clear()
|
connections.clear()
|
||||||
runCatching { clientLink?.close() }
|
runCatching { clientLink?.close() }
|
||||||
clientLink = null
|
clientLink = null
|
||||||
|
// Nothing left to get back to, and nothing left to hand a link to.
|
||||||
|
joinedHost = null
|
||||||
|
joinedName = ""
|
||||||
|
joinedPeerId = ""
|
||||||
|
rebinder = null
|
||||||
transports.forEach { runCatching { it.stop() } }
|
transports.forEach { runCatching { it.stop() } }
|
||||||
_state.value = State()
|
_state.value = State()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* Enough to ride out a radio that has not finished waking up, and few
|
||||||
|
* enough that a host which is genuinely gone is reported as gone rather
|
||||||
|
* than dialled at forever.
|
||||||
|
*/
|
||||||
|
const val RECONNECT_ATTEMPTS = 4
|
||||||
|
const val RECONNECT_BACKOFF_MILLIS = 750L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package org.prolibertate.games.net
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finding the network the other phone is actually on.
|
||||||
|
*
|
||||||
|
* A phone's hotspot has no internet behind it, and Android takes that
|
||||||
|
* literally: the Wi-Fi network fails its captive-portal check, stays
|
||||||
|
* unvalidated, and **mobile data remains the default network**. Every socket
|
||||||
|
* this app opens without saying otherwise is therefore routed out over
|
||||||
|
* cellular, where 192.168.x.x means nothing at all. That is the whole of the
|
||||||
|
* "two phones on one phone's hotspot cannot see each other" bug: nothing is
|
||||||
|
* wrong with the Wi-Fi, the packets are simply being posted to the wrong
|
||||||
|
* network.
|
||||||
|
*
|
||||||
|
* The cure is to name the network explicitly rather than accept the default —
|
||||||
|
* which is why everything here deliberately looks for a Wi-Fi network *without*
|
||||||
|
* asking whether it has internet. Asking is what breaks it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The Wi-Fi network this device is on, internet or no internet. */
|
||||||
|
fun wifiNetwork(context: Context): Network? {
|
||||||
|
val manager = context.applicationContext
|
||||||
|
.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
?: return null
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
return manager.allNetworks.firstOrNull { network ->
|
||||||
|
val capabilities = manager.getNetworkCapabilities(network)
|
||||||
|
capabilities != null &&
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points this socket at the network [target] is actually on, before it
|
||||||
|
* connects.
|
||||||
|
*
|
||||||
|
* Two different problems wearing the same hat, depending on which end of the
|
||||||
|
* hotspot this phone is.
|
||||||
|
*
|
||||||
|
* A phone that has *joined* a hotspot has a Wi-Fi network, and naming it is
|
||||||
|
* enough. A phone that is *sharing* one does not: its guests live on the
|
||||||
|
* tethering interface, and ConnectivityManager does not offer that as a
|
||||||
|
* [Network] anything can bind to — so the socket falls back to the default
|
||||||
|
* network, which is mobile data, and a connection to a guest goes out over
|
||||||
|
* cellular and dies. Binding this end to an address on the guest's own subnet
|
||||||
|
* picks the route instead, which is the only lever available.
|
||||||
|
*/
|
||||||
|
fun bindForTarget(context: Context, socket: Socket, target: String) {
|
||||||
|
val network = wifiNetwork(context)
|
||||||
|
if (network != null) {
|
||||||
|
runCatching { network.bindSocket(socket) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val source = localIpv4Addresses().firstOrNull { sameSubnet(it, target) } ?: return
|
||||||
|
runCatching { socket.bind(InetSocketAddress(InetAddress.getByName(source), 0)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether two IPv4 addresses are on the same /24.
|
||||||
|
*
|
||||||
|
* A guess, but a safe one here: every Android hotspot hands out a /24, and the
|
||||||
|
* only thing this decides is which of our own addresses to send from. Getting
|
||||||
|
* it wrong costs a bind that would not have helped anyway.
|
||||||
|
*/
|
||||||
|
fun sameSubnet(a: String, b: String): Boolean {
|
||||||
|
val left = a.split('.')
|
||||||
|
val right = b.split('.')
|
||||||
|
if (left.size != 4 || right.size != 4) return false
|
||||||
|
return left.subList(0, 3) == right.subList(0, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The addresses another device on the same Wi-Fi could reach this one at.
|
||||||
|
*
|
||||||
|
* Read off the interfaces rather than asked of [WifiManager], because the phone
|
||||||
|
* running the hotspot is not a Wi-Fi client and has no client address to give —
|
||||||
|
* its guests reach it at whatever the tethering interface is numbered, which
|
||||||
|
* only the interface list knows.
|
||||||
|
*/
|
||||||
|
fun localIpv4Addresses(): List<String> {
|
||||||
|
val interfaces = runCatching { NetworkInterface.getNetworkInterfaces() }
|
||||||
|
.getOrNull() ?: return emptyList()
|
||||||
|
|
||||||
|
val found = mutableListOf<Pair<Int, String>>()
|
||||||
|
for (nic in interfaces) {
|
||||||
|
if (runCatching { !nic.isUp || nic.isLoopback }.getOrDefault(true)) continue
|
||||||
|
for (address in nic.inetAddresses) {
|
||||||
|
if (address !is Inet4Address || address.isLoopbackAddress) continue
|
||||||
|
val text = address.hostAddress ?: continue
|
||||||
|
// Tethering interfaces first: on the phone running the hotspot that
|
||||||
|
// is the only address its guests can reach, and it is the case the
|
||||||
|
// list exists to serve.
|
||||||
|
val rank = when {
|
||||||
|
nic.name.startsWith("ap") || nic.name.startsWith("swlan") -> 0
|
||||||
|
nic.name.startsWith("wlan") -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
found += rank to text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found.sortedBy { it.first }.map { it.second }.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the Wi-Fi chip's multicast filter open.
|
||||||
|
*
|
||||||
|
* mDNS is multicast, and without this lock the radio drops multicast frames
|
||||||
|
* that are not addressed to this device before the system ever sees them. The
|
||||||
|
* manifest has asked for the permission since the beginning; nothing ever took
|
||||||
|
* the lock.
|
||||||
|
*/
|
||||||
|
class MulticastGuard(context: Context) {
|
||||||
|
|
||||||
|
private val wifi = context.applicationContext
|
||||||
|
.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||||
|
|
||||||
|
private var lock: WifiManager.MulticastLock? = null
|
||||||
|
|
||||||
|
fun acquire() {
|
||||||
|
if (lock != null) return
|
||||||
|
lock = runCatching {
|
||||||
|
wifi?.createMulticastLock("plgames-discovery")?.apply {
|
||||||
|
setReferenceCounted(false)
|
||||||
|
acquire()
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
runCatching { lock?.takeIf { it.isHeld }?.release() }
|
||||||
|
lock = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An address and port typed in by hand, once it has been made sense of. */
|
||||||
|
data class ManualAddress(val address: String, val port: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads what somebody typed into the "connect to" box.
|
||||||
|
*
|
||||||
|
* Accepts a bare address, since [DEFAULT_HOST_PORT] means the port is usually
|
||||||
|
* the same one and asking a person to copy five more digits off another screen
|
||||||
|
* is a good way to have them type four of them. A port may still be given
|
||||||
|
* after a colon for the case where the usual one was taken.
|
||||||
|
*/
|
||||||
|
fun parseManualAddress(typed: String, defaultPort: Int = DEFAULT_HOST_PORT): ManualAddress? {
|
||||||
|
val text = typed.trim()
|
||||||
|
if (text.isEmpty()) return null
|
||||||
|
|
||||||
|
val colon = text.lastIndexOf(':')
|
||||||
|
if (colon < 0) return ManualAddress(text, defaultPort).takeIf { looksLikeHost(it.address) }
|
||||||
|
|
||||||
|
val address = text.substring(0, colon).trim()
|
||||||
|
val port = text.substring(colon + 1).trim().toIntOrNull() ?: return null
|
||||||
|
if (port !in 1..65_535) return null
|
||||||
|
return ManualAddress(address, port).takeIf { looksLikeHost(it.address) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enough of a check to catch a slip, and no more. Anything that survives this
|
||||||
|
* is handed to the socket, which is the real judge of whether it exists.
|
||||||
|
*/
|
||||||
|
private fun looksLikeHost(address: String): Boolean =
|
||||||
|
address.isNotEmpty() && address.none { it.isWhitespace() }
|
||||||
@@ -2,6 +2,7 @@ package org.prolibertate.games.net
|
|||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -56,7 +57,7 @@ class MatchController<S : Any, M : Any>(
|
|||||||
* waits for [confirmAdvance].
|
* waits for [confirmAdvance].
|
||||||
*/
|
*/
|
||||||
private val awaitsConfirmation: (S) -> Boolean = { false },
|
private val awaitsConfirmation: (S) -> Boolean = { false },
|
||||||
) {
|
) : LinkRebinder {
|
||||||
|
|
||||||
enum class Role { HOST, CLIENT }
|
enum class Role { HOST, CLIENT }
|
||||||
|
|
||||||
@@ -111,15 +112,32 @@ class MatchController<S : Any, M : Any>(
|
|||||||
private var released = false
|
private var released = false
|
||||||
|
|
||||||
private val peers = mutableMapOf<String, Connection>()
|
private val peers = mutableMapOf<String, Connection>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reader attached to each peer's link.
|
||||||
|
*
|
||||||
|
* Held so a replaced link's collector can be stopped. Collecting a
|
||||||
|
* [Connection.incoming] never ends on its own — a SharedFlow has no
|
||||||
|
* completion — so a link swapped out mid-match would otherwise leave its
|
||||||
|
* collector behind for the rest of the game.
|
||||||
|
*/
|
||||||
|
private val peerJobs = mutableMapOf<String, Job>()
|
||||||
private var hostLink: Connection? = null
|
private var hostLink: Connection? = null
|
||||||
|
private var hostLinkJob: Job? = null
|
||||||
|
|
||||||
private val seatKinds: Map<Int, PlayerKind> =
|
private val seatKinds: Map<Int, PlayerKind> =
|
||||||
config.seats.associate { it.seat to it.kind }
|
config.seats.associate { it.seat to it.kind }
|
||||||
private val seatPeers: Map<Int, String?> =
|
|
||||||
config.seats.associate { it.seat to it.peerId }
|
/**
|
||||||
|
* Which link each seat is reached on. Mutable because a player who drops
|
||||||
|
* and comes back is the same player in the same seat, but not necessarily
|
||||||
|
* on the same address — a reconnecting phone can be handed a different one.
|
||||||
|
*/
|
||||||
|
private val seatPeers: MutableMap<Int, String?> =
|
||||||
|
config.seats.associate { it.seat to it.peerId }.toMutableMap()
|
||||||
|
|
||||||
private fun nameOfPeer(peerId: String): String =
|
private fun nameOfPeer(peerId: String): String =
|
||||||
config.seats.firstOrNull { it.peerId == peerId }?.name ?: "The other player"
|
config.seats.firstOrNull { it.peerId == peerId }?.name ?: "the other player"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records that this table cannot be played any further.
|
* Records that this table cannot be played any further.
|
||||||
@@ -140,12 +158,7 @@ class MatchController<S : Any, M : Any>(
|
|||||||
/** Host: begin the match and start pushing state to [clients]. */
|
/** Host: begin the match and start pushing state to [clients]. */
|
||||||
fun startAsHost(clients: List<Connection>) {
|
fun startAsHost(clients: List<Connection>) {
|
||||||
check(role == Role.HOST) { "Not hosting" }
|
check(role == Role.HOST) { "Not hosting" }
|
||||||
clients.forEach { connection ->
|
clients.forEach { connection -> listenToPeer(connection) }
|
||||||
peers[connection.peerId] = connection
|
|
||||||
scope.launch {
|
|
||||||
connection.incoming.collect { message -> onHostMessage(connection, message) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val initial = runCatching { rules.initialState(config) }.getOrElse { error ->
|
val initial = runCatching { rules.initialState(config) }.getOrElse { error ->
|
||||||
// A table the rules will not accept is a dead end, not a reason
|
// A table the rules will not accept is a dead end, not a reason
|
||||||
@@ -162,11 +175,18 @@ class MatchController<S : Any, M : Any>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun listenToPeer(connection: Connection) {
|
||||||
|
peers[connection.peerId] = connection
|
||||||
|
peerJobs[connection.peerId] = scope.launch {
|
||||||
|
connection.incoming.collect { message -> onHostMessage(connection, message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Client: render whatever arrives from [link]. */
|
/** Client: render whatever arrives from [link]. */
|
||||||
fun startAsClient(link: Connection) {
|
fun startAsClient(link: Connection) {
|
||||||
check(role == Role.CLIENT) { "Not a client" }
|
check(role == Role.CLIENT) { "Not a client" }
|
||||||
hostLink = link
|
hostLink = link
|
||||||
scope.launch {
|
hostLinkJob = scope.launch {
|
||||||
link.incoming
|
link.incoming
|
||||||
// Asked for from inside onSubscription, which is what makes it
|
// Asked for from inside onSubscription, which is what makes it
|
||||||
// reliable: the request cannot leave before this collector is
|
// reliable: the request cannot leave before this collector is
|
||||||
@@ -196,11 +216,14 @@ class MatchController<S : Any, M : Any>(
|
|||||||
|
|
||||||
is Rejected -> _notice.value = message.reason
|
is Rejected -> _notice.value = message.reason
|
||||||
is Bye -> {
|
is Bye -> {
|
||||||
// The host has gone: the game cannot go on, and
|
// The link to the host has gone, either because the
|
||||||
// saying so is what gives the screen something to
|
// host left or because the link died under it. The
|
||||||
// offer a way out of.
|
// two are indistinguishable from this end, so this
|
||||||
|
// says only what is actually known — and it stops
|
||||||
|
// the table, which gives the screen a way out to
|
||||||
|
// offer if getting back in does not work.
|
||||||
_legalMoves.value = emptyList()
|
_legalMoves.value = emptyList()
|
||||||
abandon("The host left the game.")
|
abandon("The connection to the host was lost.")
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> Unit
|
else -> Unit
|
||||||
@@ -209,6 +232,59 @@ class MatchController<S : Any, M : Any>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Getting back in
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host: a player who dropped is back, on [connection].
|
||||||
|
*
|
||||||
|
* The seat, and the position it is sitting in, were never given up — only
|
||||||
|
* the link was — so nothing here touches the game. The old link is let go
|
||||||
|
* of, the new one is read from, and the seat is sent the table as it now
|
||||||
|
* stands so the returning player has something to play on.
|
||||||
|
*/
|
||||||
|
override fun rebindPeer(seat: Int, connection: Connection) {
|
||||||
|
if (role != Role.HOST) return
|
||||||
|
val previous = seatPeers[seat]
|
||||||
|
if (previous != null) {
|
||||||
|
peerJobs.remove(previous)?.cancel()
|
||||||
|
// Not closed: the lobby owns these sockets and has already dealt
|
||||||
|
// with the one being replaced. Closing a link this end has stopped
|
||||||
|
// reading would only race the reader that is on its way out.
|
||||||
|
peers.remove(previous)
|
||||||
|
}
|
||||||
|
seatPeers[seat] = connection.peerId
|
||||||
|
listenToPeer(connection)
|
||||||
|
// Only once everybody is reachable again. At a table of four, one player
|
||||||
|
// coming back says nothing about the other one who has not.
|
||||||
|
if (seatPeers.values.filterNotNull().all { peers[it]?.isOpen == true }) {
|
||||||
|
clearAbandoned()
|
||||||
|
}
|
||||||
|
scope.launch { sendStateTo(connection) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client: the way back to the host is [connection].
|
||||||
|
*
|
||||||
|
* The collector asks for a resync as it attaches, which is what fills the
|
||||||
|
* screen back in: everything a client draws came off the wire, and whatever
|
||||||
|
* was pushed while the link was down was pushed into nothing.
|
||||||
|
*/
|
||||||
|
override fun rebindHostLink(connection: Connection) {
|
||||||
|
if (role != Role.CLIENT) return
|
||||||
|
if (hostLink === connection) return
|
||||||
|
hostLinkJob?.cancel()
|
||||||
|
clearAbandoned()
|
||||||
|
startAsClient(connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts a table that was stopped back into play. */
|
||||||
|
private fun clearAbandoned() {
|
||||||
|
_abandoned.value = null
|
||||||
|
_notice.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Input
|
// Input
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -232,10 +308,15 @@ class MatchController<S : Any, M : Any>(
|
|||||||
|
|
||||||
private suspend fun onHostMessage(connection: Connection, message: NetMessage) {
|
private suspend fun onHostMessage(connection: Connection, message: NetMessage) {
|
||||||
if (message is Bye) {
|
if (message is Bye) {
|
||||||
// One of the players has left. The host keeps the position — there is
|
// Anything arriving on a link that has since been replaced belongs
|
||||||
// nothing wrong with it — but a table that cannot go on should say so
|
// to the link, not to the seat: a player who dropped and came back
|
||||||
// rather than leave somebody waiting on a move that is not coming.
|
// must not be reported as having left on the way in.
|
||||||
abandon("${nameOfPeer(connection.peerId)} left the game.")
|
if (peers[connection.peerId] !== connection) return
|
||||||
|
// One of the players is no longer reachable. The host keeps the
|
||||||
|
// position — there is nothing wrong with it — but a table that
|
||||||
|
// cannot go on should say so rather than leave somebody waiting on a
|
||||||
|
// move that is not coming.
|
||||||
|
abandon("The connection to ${nameOfPeer(connection.peerId)} was lost.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (message is Resync) {
|
if (message is Resync) {
|
||||||
@@ -433,8 +514,12 @@ class MatchController<S : Any, M : Any>(
|
|||||||
* [StreamConnection.close], which this leans on to not block.
|
* [StreamConnection.close], which this leans on to not block.
|
||||||
*/
|
*/
|
||||||
fun close() {
|
fun close() {
|
||||||
|
peerJobs.values.forEach { it.cancel() }
|
||||||
|
peerJobs.clear()
|
||||||
peers.values.forEach { runCatching { it.close() } }
|
peers.values.forEach { runCatching { it.close() } }
|
||||||
peers.clear()
|
peers.clear()
|
||||||
|
hostLinkJob?.cancel()
|
||||||
|
hostLinkJob = null
|
||||||
runCatching { hostLink?.close() }
|
runCatching { hostLink?.close() }
|
||||||
hostLink = null
|
hostLink = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ import org.prolibertate.games.game.engine.TableConfig
|
|||||||
* takes effect. Each client is sent a state redacted for its own seat, so an
|
* takes effect. Each client is sent a state redacted for its own seat, so an
|
||||||
* opponent's hand is never transmitted in the first place.
|
* opponent's hand is never transmitted in the first place.
|
||||||
*/
|
*/
|
||||||
const val PROTOCOL_VERSION = 1
|
/**
|
||||||
|
* 2 added the heartbeat. A peer that does not answer [Ping] cannot be told
|
||||||
|
* apart from one that has gone away, so both ends have to speak it or a quiet
|
||||||
|
* link would be dropped as dead the first time nobody moved for a while. The
|
||||||
|
* lobby already refuses a mismatch with "update both devices", which is the
|
||||||
|
* honest thing to say about it.
|
||||||
|
*/
|
||||||
|
const val PROTOCOL_VERSION = 2
|
||||||
|
|
||||||
/** Service type and name advertised over mDNS. */
|
/** Service type and name advertised over mDNS. */
|
||||||
const val SERVICE_TYPE = "_plgames._tcp"
|
const val SERVICE_TYPE = "_plgames._tcp"
|
||||||
@@ -116,6 +123,30 @@ data object Resync : NetMessage
|
|||||||
@SerialName("rejected")
|
@SerialName("rejected")
|
||||||
data class Rejected(val reason: String) : NetMessage
|
data class Rejected(val reason: String) : NetMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent to a link nobody has spoken on for a while, and answered with [Pong].
|
||||||
|
*
|
||||||
|
* Two things need it. A phone whose screen has gone off puts its Wi-Fi radio
|
||||||
|
* into power save and, given long enough, stops the app's networking
|
||||||
|
* altogether; an access point ages an idle association out of its tables. A
|
||||||
|
* link carrying nothing between one player's turn and the next is idle for
|
||||||
|
* minutes at a time, which is exactly what both of those collect. And a TCP
|
||||||
|
* link that dies that way dies quietly: the read never returns and the next
|
||||||
|
* write is buffered into a socket that no longer goes anywhere, so without
|
||||||
|
* traffic of its own the app cannot tell a link that is waiting from one that
|
||||||
|
* is gone.
|
||||||
|
*
|
||||||
|
* Neither reaches a screen — see [StreamConnection], which answers and swallows
|
||||||
|
* them, so nothing above the transport has to know they exist.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("ping")
|
||||||
|
data object Ping : NetMessage
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("pong")
|
||||||
|
data object Pong : NetMessage
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("bye")
|
@SerialName("bye")
|
||||||
data object Bye : NetMessage
|
data object Bye : NetMessage
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ package org.prolibertate.games.net
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
@@ -33,6 +36,28 @@ data class DiscoveredHost(
|
|||||||
val port: Int = 0,
|
val port: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a host is listening, for reading out loud.
|
||||||
|
*
|
||||||
|
* Discovery is not always possible — a phone sharing its own hotspot does not
|
||||||
|
* reliably advertise over the tethering interface, whatever the other end does
|
||||||
|
* — so a host has to be able to say where it is in a form somebody can type
|
||||||
|
* into the other phone.
|
||||||
|
*/
|
||||||
|
data class HostEndpoint(val addresses: List<String>, val port: Int) {
|
||||||
|
/** The one to read out, tethering address first. */
|
||||||
|
val primary: String? get() = addresses.firstOrNull()?.let { "$it:$port" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The port a host listens on when it can have it.
|
||||||
|
*
|
||||||
|
* Fixed rather than assigned by the system, so that joining by hand is typing
|
||||||
|
* an address and not an address and a five-digit number. A host that finds it
|
||||||
|
* taken falls back to whatever it is given and says so.
|
||||||
|
*/
|
||||||
|
const val DEFAULT_HOST_PORT = 47_654
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One peer-to-peer link carrying [NetMessage]s.
|
* One peer-to-peer link carrying [NetMessage]s.
|
||||||
*
|
*
|
||||||
@@ -60,12 +85,35 @@ class StreamConnection(
|
|||||||
private val output: OutputStream,
|
private val output: OutputStream,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val onClosed: (StreamConnection) -> Unit = {},
|
private val onClosed: (StreamConnection) -> Unit = {},
|
||||||
|
/** How often a link with nothing to say is pinged. */
|
||||||
|
private val heartbeatMillis: Long = HEARTBEAT_MILLIS,
|
||||||
|
/** How long a link may go completely silent before it counts as gone. */
|
||||||
|
private val silenceMillis: Long = SILENCE_MILLIS,
|
||||||
) : Connection {
|
) : Connection {
|
||||||
|
|
||||||
private val reader: BufferedReader = input.bufferedReader()
|
private val reader: BufferedReader = input.bufferedReader()
|
||||||
private val writer: BufferedWriter = output.bufferedWriter()
|
private val writer: BufferedWriter = output.bufferedWriter()
|
||||||
|
private val writeLock = Mutex()
|
||||||
private val closed = AtomicBoolean(false)
|
private val closed = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this end closed the link on purpose, as leaving a game does.
|
||||||
|
*
|
||||||
|
* Kept apart from [closed] because the two answer different questions. A
|
||||||
|
* link is closed either way; only one of the two ways is worth telling
|
||||||
|
* anybody about, and the heartbeat closes links that very much are.
|
||||||
|
*/
|
||||||
|
private val closedOnPurpose = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the peer was last heard from, on the monotonic clock.
|
||||||
|
*
|
||||||
|
* Wall time would do the wrong thing across a clock adjustment, and
|
||||||
|
* SystemClock is Android's, which the unit tests do not have.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var lastHeardNanos: Long = System.nanoTime()
|
||||||
|
|
||||||
private val _incoming = MutableSharedFlow<NetMessage>(replay = 0, extraBufferCapacity = 64)
|
private val _incoming = MutableSharedFlow<NetMessage>(replay = 0, extraBufferCapacity = 64)
|
||||||
override val incoming: SharedFlow<NetMessage> = _incoming
|
override val incoming: SharedFlow<NetMessage> = _incoming
|
||||||
|
|
||||||
@@ -73,8 +121,6 @@ class StreamConnection(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
// Told apart from a link this side closed on purpose, because only
|
|
||||||
// one of the two is worth telling anybody about.
|
|
||||||
var droppedByPeer = false
|
var droppedByPeer = false
|
||||||
try {
|
try {
|
||||||
while (isActive && !closed.get()) {
|
while (isActive && !closed.get()) {
|
||||||
@@ -83,11 +129,22 @@ class StreamConnection(
|
|||||||
droppedByPeer = true
|
droppedByPeer = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// Anything at all counts as a sign of life, including a line
|
||||||
|
// this version cannot make sense of: the point is that the
|
||||||
|
// peer is still there and still talking.
|
||||||
|
lastHeardNanos = System.nanoTime()
|
||||||
if (line.isBlank()) continue
|
if (line.isBlank()) continue
|
||||||
val message = runCatching {
|
val message = runCatching {
|
||||||
protocolJson.decodeFromString<NetMessage>(line)
|
protocolJson.decodeFromString<NetMessage>(line)
|
||||||
}.getOrNull() ?: continue // ignore anything we can't parse
|
}.getOrNull() ?: continue // ignore anything we can't parse
|
||||||
_incoming.emit(message)
|
// The heartbeat is the transport's own business and stops
|
||||||
|
// here. Answering on this thread is safe: send() hops to IO,
|
||||||
|
// which is where this already is.
|
||||||
|
when (message) {
|
||||||
|
is Ping -> send(Pong)
|
||||||
|
is Pong -> Unit
|
||||||
|
else -> _incoming.emit(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// A dropped link is normal: someone walked out of range or
|
// A dropped link is normal: someone walked out of range or
|
||||||
@@ -98,30 +155,62 @@ class StreamConnection(
|
|||||||
// lock, so it is the only one that can close it without
|
// lock, so it is the only one that can close it without
|
||||||
// waiting. See close().
|
// waiting. See close().
|
||||||
runCatching { reader.close() }
|
runCatching { reader.close() }
|
||||||
val weClosedIt = closed.get()
|
val deliberate = closedOnPurpose.get()
|
||||||
close()
|
shutdown()
|
||||||
// A link that died on its own reaches the screens on the same
|
// A link that died on its own reaches the screens on the same
|
||||||
// channel as everything else, so leaving a game is something the
|
// channel as everything else, so losing a player is something the
|
||||||
// other end finds out about the same way it finds out about a
|
// other end finds out about the same way it finds out about a
|
||||||
// move. Withdrawing deliberately needs no announcement, and the
|
// move. Withdrawing deliberately needs no announcement, and the
|
||||||
// emit is not cancellable — the whole point of it is to arrive
|
// emit is not cancellable — the whole point of it is to arrive
|
||||||
// while everything around it is being torn down.
|
// while everything around it is being torn down.
|
||||||
if (droppedByPeer && !weClosedIt) {
|
if (droppedByPeer && !deliberate) {
|
||||||
withContext(NonCancellable) { runCatching { _incoming.emit(Bye) } }
|
withContext(NonCancellable) { runCatching { _incoming.emit(Bye) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keeps the link warm and, more to the point, keeps it answerable: a
|
||||||
|
// link nobody has spoken on cannot be told from a link that no longer
|
||||||
|
// exists, and between two turns of a card game that is most of the game.
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
while (isActive && !closed.get()) {
|
||||||
|
delay(heartbeatMillis)
|
||||||
|
if (closed.get()) break
|
||||||
|
val silentFor = (System.nanoTime() - lastHeardNanos) / 1_000_000
|
||||||
|
if (silentFor > silenceMillis) {
|
||||||
|
// Closing the stream is what unblocks the read; the reader
|
||||||
|
// then announces the drop from its own thread, exactly as it
|
||||||
|
// would for a peer that had hung up.
|
||||||
|
shutdown()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
send(Ping)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun send(message: NetMessage) {
|
override suspend fun send(message: NetMessage) {
|
||||||
if (closed.get()) return
|
if (closed.get()) return
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
// One message at a time. The framing is what makes a line a message,
|
||||||
writer.write(protocolJson.encodeToString<NetMessage>(message))
|
// and a write is three calls — the text, the newline, the flush — so
|
||||||
writer.newLine()
|
// two of them running at once would splice one message through the
|
||||||
writer.flush()
|
// middle of another and neither would arrive. The heartbeat is what
|
||||||
} catch (_: Exception) {
|
// makes that likely: it writes on its own schedule, without caring
|
||||||
close()
|
// what the game happens to be sending.
|
||||||
|
writeLock.withLock {
|
||||||
|
try {
|
||||||
|
writer.write(protocolJson.encodeToString<NetMessage>(message))
|
||||||
|
writer.newLine()
|
||||||
|
writer.flush()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Not close(): a write that failed is a link that broke, and
|
||||||
|
// the screen has to hear about that. Going through close()
|
||||||
|
// here used to mark it deliberate, so a player whose link
|
||||||
|
// had died tapped a board that answered nothing and was
|
||||||
|
// never told why.
|
||||||
|
shutdown()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,11 +232,52 @@ class StreamConnection(
|
|||||||
* buffered in it.
|
* buffered in it.
|
||||||
*/
|
*/
|
||||||
override fun close() {
|
override fun close() {
|
||||||
|
closedOnPurpose.set(true)
|
||||||
|
shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets go of the streams without saying anything about why. */
|
||||||
|
private fun shutdown() {
|
||||||
if (!closed.compareAndSet(false, true)) return
|
if (!closed.compareAndSet(false, true)) return
|
||||||
runCatching { input.close() }
|
runCatching { input.close() }
|
||||||
runCatching { output.close() }
|
runCatching { output.close() }
|
||||||
onClosed(this)
|
onClosed(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* Often enough to hold an access point's idea of the association open
|
||||||
|
* and to notice a dead link inside a turn, seldom enough that it is a
|
||||||
|
* few dozen bytes a minute.
|
||||||
|
*/
|
||||||
|
const val HEARTBEAT_MILLIS = 5_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Several missed beats rather than one. A phone with its screen off
|
||||||
|
* answers late — the radio is in power save and the timer that would
|
||||||
|
* have sent the ping is deferred — and a link is not dead just because
|
||||||
|
* it is slow.
|
||||||
|
*/
|
||||||
|
const val SILENCE_MILLIS = 25_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A table that can be handed a replacement for a link it was given.
|
||||||
|
*
|
||||||
|
* A dropped link is not the end of a game — the position is untouched and the
|
||||||
|
* player is still sitting there — so the lobby, which owns the sockets and goes
|
||||||
|
* on listening for the whole match, reconnects and passes the new link through
|
||||||
|
* this. Narrow on purpose: the lobby has no business knowing what game is being
|
||||||
|
* played on the other side of it.
|
||||||
|
*/
|
||||||
|
interface LinkRebinder {
|
||||||
|
|
||||||
|
/** Host: [connection] is the seat's link from now on. */
|
||||||
|
fun rebindPeer(seat: Int, connection: Connection)
|
||||||
|
|
||||||
|
/** Client: [connection] is the way to the host from now on. */
|
||||||
|
fun rebindHostLink(connection: Connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ data class ScorePlayer(val id: Int, val name: String = "")
|
|||||||
/**
|
/**
|
||||||
* What each player scored in one round, keyed by [ScorePlayer.id].
|
* What each player scored in one round, keyed by [ScorePlayer.id].
|
||||||
*
|
*
|
||||||
* A player absent from the map scored nothing, which is the same as zero — so
|
* A player absent from the map had nothing entered, which counts as zero in the
|
||||||
* a round where only one person scored carries one entry rather than a column
|
* totals — so a round where only one person scored carries one entry rather
|
||||||
* of noughts.
|
* than a column of noughts. A 0 that was typed is kept, so it can be shown.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ScoreRound(val deltas: Map<Int, Int> = emptyMap()) {
|
data class ScoreRound(val deltas: Map<Int, Int> = emptyMap()) {
|
||||||
@@ -114,12 +114,12 @@ data class ScoreSheet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A round holding only what is worth storing: entries for players who are
|
* A round holding only entries for players who are still at the table.
|
||||||
* still at the table, and only where somebody actually scored.
|
* Zeros stay: a typed 0 is different from a cell left blank.
|
||||||
*/
|
*/
|
||||||
private fun roundOf(deltas: Map<Int, Int>): ScoreRound {
|
private fun roundOf(deltas: Map<Int, Int>): ScoreRound {
|
||||||
val ids = players.map { it.id }.toSet()
|
val ids = players.map { it.id }.toSet()
|
||||||
return ScoreRound(deltas.filterKeys { it in ids }.filterValues { it != 0 })
|
return ScoreRound(deltas.filterKeys { it in ids })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drops points recorded against anybody no longer at the table. */
|
/** Drops points recorded against anybody no longer at the table. */
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -45,6 +46,7 @@ import org.prolibertate.games.net.LobbyController
|
|||||||
import org.prolibertate.games.score.ScorekeeperRepository
|
import org.prolibertate.games.score.ScorekeeperRepository
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
import org.prolibertate.games.settings.SettingsRepository
|
import org.prolibertate.games.settings.SettingsRepository
|
||||||
|
import org.prolibertate.games.ui.game.LocalCardSpeed
|
||||||
import org.prolibertate.games.update.UpdateChannel
|
import org.prolibertate.games.update.UpdateChannel
|
||||||
import org.prolibertate.games.update.Updater
|
import org.prolibertate.games.update.Updater
|
||||||
|
|
||||||
@@ -121,14 +123,19 @@ fun AppRoot(env: AppEnv) {
|
|||||||
var splashDone by remember { mutableStateOf(false) }
|
var splashDone by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AppContent(
|
// Card motion reads its speed from here rather than from a parameter.
|
||||||
env = env,
|
// Every card screen animates, most of them several layers deep, and the
|
||||||
settings = settings,
|
// alternative was passing one unchanging number down through all of it.
|
||||||
stack = stack,
|
CompositionLocalProvider(LocalCardSpeed provides settings.animationSpeed) {
|
||||||
showComingSoon = showComingSoon,
|
AppContent(
|
||||||
push = push,
|
env = env,
|
||||||
pop = pop,
|
settings = settings,
|
||||||
)
|
stack = stack,
|
||||||
|
showComingSoon = showComingSoon,
|
||||||
|
push = push,
|
||||||
|
pop = pop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Over the top, so the menu behind it is already composed when it fades.
|
// Over the top, so the menu behind it is already composed when it fades.
|
||||||
if (!splashDone) {
|
if (!splashDone) {
|
||||||
|
|||||||
@@ -6,24 +6,30 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.prolibertate.games.game.engine.PlayerKind
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
import org.prolibertate.games.net.DiscoveredHost
|
|
||||||
import org.prolibertate.games.net.LobbyController
|
import org.prolibertate.games.net.LobbyController
|
||||||
import org.prolibertate.games.net.seatForDevice
|
import org.prolibertate.games.net.seatForDevice
|
||||||
|
|
||||||
@@ -75,7 +81,13 @@ fun LobbyScreen(
|
|||||||
val title = if (route.hosting) "Hosting" else "Join a game"
|
val title = if (route.hosting) "Hosting" else "Join a game"
|
||||||
|
|
||||||
ScreenScaffold(title = title, onBack = onBack) { modifier ->
|
ScreenScaffold(title = title, onBack = onBack) { modifier ->
|
||||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
// Scrolls rather than filling: a lazy list here took the whole screen
|
||||||
|
// and pushed the Start button — and now the address to type in — off
|
||||||
|
// the bottom of it. There are never more than a handful of rows.
|
||||||
|
Column(
|
||||||
|
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
val available = env.lobby.availableTransports()
|
val available = env.lobby.availableTransports()
|
||||||
Text(
|
Text(
|
||||||
text = if (available.isEmpty()) {
|
text = if (available.isEmpty()) {
|
||||||
@@ -94,7 +106,7 @@ fun LobbyScreen(
|
|||||||
if (route.hosting) {
|
if (route.hosting) {
|
||||||
HostingBody(env.lobby, state)
|
HostingBody(env.lobby, state)
|
||||||
} else {
|
} else {
|
||||||
JoiningBody(env, state.discovered, playerName)
|
JoiningBody(env, state, playerName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,34 +114,47 @@ fun LobbyScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HostingBody(lobby: LobbyController, state: LobbyController.State) {
|
private fun HostingBody(lobby: LobbyController, state: LobbyController.State) {
|
||||||
|
// Where to find this phone, for when the other one cannot find it by
|
||||||
|
// itself. Over a phone's own hotspot that is the normal case rather than
|
||||||
|
// the exception, so this is stated plainly rather than tucked away.
|
||||||
|
state.endpoint?.primary?.let { where ->
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Text("This phone is at", style = MaterialTheme.typography.labelSmall)
|
||||||
|
Text(where, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
text = "If the other phone doesn't find the game on its own — which " +
|
||||||
|
"happens when you're sharing this phone's hotspot — type that " +
|
||||||
|
"into its Join screen.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text("Seats", fontWeight = FontWeight.Bold)
|
Text("Seats", fontWeight = FontWeight.Bold)
|
||||||
LazyColumn(
|
state.seats.forEach { slot ->
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Row(
|
||||||
) {
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
items(state.seats) { slot ->
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
Row(
|
) {
|
||||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
Column {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Text("Seat ${slot.seat} — ${slot.name}")
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
Text(
|
||||||
) {
|
text = when (slot.kind) {
|
||||||
Column {
|
PlayerKind.HUMAN_LOCAL -> "You"
|
||||||
Text("Seat ${slot.seat} — ${slot.name}")
|
PlayerKind.HUMAN_REMOTE -> "Joined"
|
||||||
Text(
|
PlayerKind.AI -> "Computer"
|
||||||
text = when (slot.kind) {
|
} + " · Team ${slot.team}",
|
||||||
PlayerKind.HUMAN_LOCAL -> "You"
|
style = MaterialTheme.typography.bodySmall,
|
||||||
PlayerKind.HUMAN_REMOTE -> "Joined"
|
)
|
||||||
PlayerKind.AI -> "Computer"
|
}
|
||||||
} + " · Team ${slot.team}",
|
if (slot.kind == PlayerKind.HUMAN_REMOTE) {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
TextButton(onClick = {
|
||||||
)
|
lobby.setSeatKind(slot.seat, PlayerKind.AI, "Computer ${slot.seat}")
|
||||||
}
|
}) { Text("Drop") }
|
||||||
if (slot.kind == PlayerKind.HUMAN_REMOTE) {
|
|
||||||
TextButton(onClick = {
|
|
||||||
lobby.setSeatKind(slot.seat, PlayerKind.AI, "Computer ${slot.seat}")
|
|
||||||
}) { Text("Drop") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,20 +175,21 @@ private fun HostingBody(lobby: LobbyController, state: LobbyController.State) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun JoiningBody(
|
private fun JoiningBody(
|
||||||
env: AppEnv,
|
env: AppEnv,
|
||||||
discovered: List<DiscoveredHost>,
|
state: LobbyController.State,
|
||||||
playerName: String,
|
playerName: String,
|
||||||
) {
|
) {
|
||||||
|
val discovered = state.discovered
|
||||||
|
|
||||||
if (discovered.isEmpty()) {
|
if (discovered.isEmpty()) {
|
||||||
Text("No games found yet. Make sure the host has started theirs.")
|
Text("No games found yet. Make sure the host has started theirs.")
|
||||||
Text(
|
Text(
|
||||||
text = "Both devices need to be on the same Wi-Fi network.",
|
text = "Both phones need to be on the same Wi-Fi — including one phone's " +
|
||||||
|
"hotspot, though over a hotspot the game often has to be joined by " +
|
||||||
|
"address rather than found.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
return
|
} else {
|
||||||
}
|
discovered.forEach { host ->
|
||||||
|
|
||||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
items(discovered) { host ->
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -179,4 +205,44 @@ private fun JoiningBody(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
ManualJoin(env, playerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joining by address.
|
||||||
|
*
|
||||||
|
* Always offered rather than kept for when discovery has failed, because over a
|
||||||
|
* phone's hotspot discovery does not fail loudly — it just never finds
|
||||||
|
* anything, and a screen that only says "no games found" gives no way forward.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ManualJoin(env: AppEnv, playerName: String) {
|
||||||
|
var typed by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Text("Join by address", fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
text = "The host's screen shows where it is. The port can be left off unless " +
|
||||||
|
"the host is showing a different one.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = typed,
|
||||||
|
onValueChange = { typed = it },
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text("192.168.43.1") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { env.lobby.joinAt(typed, playerName, env.peerId) },
|
||||||
|
enabled = typed.isNotBlank(),
|
||||||
|
) { Text("Connect") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import org.prolibertate.games.game.GameCatalog
|
import org.prolibertate.games.game.GameCatalog
|
||||||
import org.prolibertate.games.game.chess.ChessAi
|
import org.prolibertate.games.game.chess.ChessAi
|
||||||
import org.prolibertate.games.game.chess.ChessMove
|
import org.prolibertate.games.game.chess.ChessMove
|
||||||
@@ -683,6 +686,32 @@ private fun <S : Any, M : Any> StartMatch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
DisposableEffect(controller) {
|
DisposableEffect(controller) {
|
||||||
onDispose { controller.close() }
|
// The lobby owns the sockets and goes on listening for the whole match,
|
||||||
|
// so it is the lobby that reconnects a dropped player — and this is
|
||||||
|
// where it finds the table to hand the new link to.
|
||||||
|
env.lobby.rebinder = controller
|
||||||
|
onDispose {
|
||||||
|
env.lobby.rebinder = null
|
||||||
|
controller.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coming back to a table that has been out of sight.
|
||||||
|
//
|
||||||
|
// A locked phone is a stopped activity, and its Wi-Fi radio goes to sleep
|
||||||
|
// with the screen. Anything the host pushed in the meantime — the player's
|
||||||
|
// own turn arriving is the one that matters — was pushed at a link that may
|
||||||
|
// no longer have been there, and a client draws nothing but what it was
|
||||||
|
// sent. So the table is asked for again on the way back in, over a fresh
|
||||||
|
// link if the old one did not survive.
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(lifecycleOwner, route.hosting) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_START && !route.hosting) {
|
||||||
|
env.lobby.restoreLink()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -193,9 +195,21 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
|
|||||||
var renaming by remember { mutableStateOf(-1) }
|
var renaming by remember { mutableStateOf(-1) }
|
||||||
// Which cell the ± button flips: the last one typed into.
|
// Which cell the ± button flips: the last one typed into.
|
||||||
var lastTyped by remember { mutableStateOf(-1) }
|
var lastTyped by remember { mutableStateOf(-1) }
|
||||||
|
// The cell to put the cursor in when a round is opened by tapping it.
|
||||||
|
var focusPlayer by remember { mutableStateOf(-1) }
|
||||||
|
|
||||||
val editing = if (correcting >= 0) correction else pending
|
val editing = if (correcting >= 0) correction else pending
|
||||||
|
|
||||||
|
// Opens a round for correction. Both the round number and any of its cells
|
||||||
|
// come through here, so tapping a score edits that score directly.
|
||||||
|
val startCorrection: (Int, Int) -> Unit = { index, focus ->
|
||||||
|
correcting = index
|
||||||
|
correction.clear()
|
||||||
|
sheet.rounds[index].deltas.forEach { (id, points) -> correction[id] = points.toString() }
|
||||||
|
lastTyped = -1
|
||||||
|
focusPlayer = focus
|
||||||
|
}
|
||||||
|
|
||||||
// Keep the row being filled in in view when a round is written down.
|
// Keep the row being filled in in view when a round is written down.
|
||||||
LaunchedEffect(sheet.rounds.size) { rows.animateScrollTo(rows.maxValue) }
|
LaunchedEffect(sheet.rounds.size) { rows.animateScrollTo(rows.maxValue) }
|
||||||
|
|
||||||
@@ -264,15 +278,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
|
|||||||
if (underCorrection) {
|
if (underCorrection) {
|
||||||
correcting = -1
|
correcting = -1
|
||||||
} else {
|
} else {
|
||||||
correcting = index
|
startCorrection(index, -1)
|
||||||
correction.clear()
|
|
||||||
sheet.players.forEach { player ->
|
|
||||||
val points = round.delta(player.id)
|
|
||||||
if (points != 0) {
|
|
||||||
correction[player.id] = points.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastTyped = -1
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -282,13 +288,17 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
|
|||||||
if (underCorrection) {
|
if (underCorrection) {
|
||||||
PointsField(
|
PointsField(
|
||||||
text = correction[player.id].orEmpty(),
|
text = correction[player.id].orEmpty(),
|
||||||
|
autoFocus = focusPlayer == player.id,
|
||||||
onText = {
|
onText = {
|
||||||
correction[player.id] = it
|
correction[player.id] = it
|
||||||
lastTyped = player.id
|
lastTyped = player.id
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
PointsCell(text = signed(round.delta(player.id)))
|
PointsCell(
|
||||||
|
text = signed(round.deltas[player.id]),
|
||||||
|
onClick = { startCorrection(index, player.id) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,17 +368,20 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
|
|||||||
pending.clear()
|
pending.clear()
|
||||||
}
|
}
|
||||||
lastTyped = -1
|
lastTyped = -1
|
||||||
|
focusPlayer = -1
|
||||||
},
|
},
|
||||||
onDelete = {
|
onDelete = {
|
||||||
onChange(sheet.withoutRound(correcting))
|
onChange(sheet.withoutRound(correcting))
|
||||||
correcting = -1
|
correcting = -1
|
||||||
correction.clear()
|
correction.clear()
|
||||||
lastTyped = -1
|
lastTyped = -1
|
||||||
|
focusPlayer = -1
|
||||||
},
|
},
|
||||||
onCancel = {
|
onCancel = {
|
||||||
correcting = -1
|
correcting = -1
|
||||||
correction.clear()
|
correction.clear()
|
||||||
lastTyped = -1
|
lastTyped = -1
|
||||||
|
focusPlayer = -1
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -583,11 +596,12 @@ private fun RoundLabel(text: String, selected: Boolean, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PointsCell(text: String, muted: Boolean = false) {
|
private fun PointsCell(text: String, muted: Boolean = false, onClick: (() -> Unit)? = null) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.border(Dp.Hairline, MaterialTheme.colorScheme.outlineVariant),
|
.border(Dp.Hairline, MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -603,9 +617,11 @@ private fun PointsCell(text: String, muted: Boolean = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A cell being typed into. Blank means nothing scored, which is the same as nought. */
|
/** A cell being typed into. Blank means nothing entered; a typed 0 is kept and shown. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun PointsField(text: String, onText: (String) -> Unit) {
|
private fun PointsField(text: String, onText: (String) -> Unit, autoFocus: Boolean = false) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(autoFocus) { if (autoFocus) focus.requestFocus() }
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { onText(sanitiseAmount(it)) },
|
onValueChange = { onText(sanitiseAmount(it)) },
|
||||||
@@ -616,7 +632,7 @@ private fun PointsField(text: String, onText: (String) -> Unit) {
|
|||||||
),
|
),
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().focusRequester(focus),
|
||||||
decorationBox = { field ->
|
decorationBox = { field ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -758,14 +774,13 @@ private fun flipSign(text: String): String {
|
|||||||
return (-value).toString()
|
return (-value).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The typed cells as points. Blanks, a lone minus and noughts all drop out. */
|
/** The typed cells as points. Blanks and a lone minus drop out; a typed 0 stays. */
|
||||||
private fun amountsAsPoints(amounts: Map<Int, String>): Map<Int, Int> =
|
private fun amountsAsPoints(amounts: Map<Int, String>): Map<Int, Int> =
|
||||||
amounts.mapNotNull { (id, text) -> text.toIntOrNull()?.takeIf { it != 0 }?.let { id to it } }
|
amounts.mapNotNull { (id, text) -> text.toIntOrNull()?.let { id to it } }.toMap()
|
||||||
.toMap()
|
|
||||||
|
|
||||||
/** Nothing scored shows as an empty cell rather than a nought in every column. */
|
/** Not entered shows as an empty cell; an entered 0 shows as 0. */
|
||||||
private fun signed(points: Int): String = when {
|
private fun signed(points: Int?): String = when {
|
||||||
|
points == null -> ""
|
||||||
points > 0 -> "+$points"
|
points > 0 -> "+$points"
|
||||||
points < 0 -> points.toString()
|
else -> points.toString()
|
||||||
else -> ""
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
@@ -41,6 +42,13 @@ fun PlayingCardView(
|
|||||||
width: Dp = 64.dp,
|
width: Dp = 64.dp,
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* How far off the surface the card is sitting. The default is the thickness
|
||||||
|
* of a card lying on a table, which is what separates a card *on* the felt
|
||||||
|
* from a card painted onto it; a card in flight is raised further by
|
||||||
|
* whatever is animating it.
|
||||||
|
*/
|
||||||
|
elevation: Dp = CARD_RESTING_ELEVATION,
|
||||||
/**
|
/**
|
||||||
* Short note printed on the face, for when the rank and suit alone do not
|
* Short note printed on the face, for when the rank and suit alone do not
|
||||||
* say what the card does — a Sequence jack being the obvious case, since
|
* say what the card does — a Sequence jack being the obvious case, since
|
||||||
@@ -54,6 +62,7 @@ fun PlayingCardView(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(width)
|
.width(width)
|
||||||
.aspectRatio(CARD_ASPECT)
|
.aspectRatio(CARD_ASPECT)
|
||||||
|
.shadow(elevation, RoundedCornerShape(width * 0.12f))
|
||||||
.clip(RoundedCornerShape(width * 0.12f))
|
.clip(RoundedCornerShape(width * 0.12f))
|
||||||
.background(Color.White)
|
.background(Color.White)
|
||||||
.border(
|
.border(
|
||||||
@@ -102,11 +111,13 @@ fun PlayingCardView(
|
|||||||
fun CardBackView(
|
fun CardBackView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
width: Dp = 64.dp,
|
width: Dp = 64.dp,
|
||||||
|
elevation: Dp = CARD_RESTING_ELEVATION,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(width)
|
.width(width)
|
||||||
.aspectRatio(CARD_ASPECT)
|
.aspectRatio(CARD_ASPECT)
|
||||||
|
.shadow(elevation, RoundedCornerShape(width * 0.12f))
|
||||||
.clip(RoundedCornerShape(width * 0.12f))
|
.clip(RoundedCornerShape(width * 0.12f))
|
||||||
.background(MaterialTheme.colorScheme.primary)
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
.border(1.dp, Color(0x33000000), RoundedCornerShape(width * 0.12f)),
|
.border(1.dp, Color(0x33000000), RoundedCornerShape(width * 0.12f)),
|
||||||
@@ -121,3 +132,11 @@ fun CardBackView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const val CARD_ASPECT = 5f / 7f
|
const val CARD_ASPECT = 5f / 7f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card lying on a surface. Small enough to read as the thickness of a piece
|
||||||
|
* of card rather than as a floating tile — the miniature card backs used as
|
||||||
|
* hand counters are twelve dp wide, and anything heavier turns those into
|
||||||
|
* smudges.
|
||||||
|
*/
|
||||||
|
val CARD_RESTING_ELEVATION: Dp = 1.5.dp
|
||||||
|
|||||||
@@ -0,0 +1,708 @@
|
|||||||
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.settings.Settings
|
||||||
|
import org.prolibertate.games.ui.common.CARD_ASPECT
|
||||||
|
import org.prolibertate.games.ui.common.CARD_RESTING_ELEVATION
|
||||||
|
import org.prolibertate.games.ui.common.CardBackView
|
||||||
|
import org.prolibertate.games.ui.common.PlayingCardView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How cards move.
|
||||||
|
*
|
||||||
|
* A card thrown onto a table does three things that a card faded into place
|
||||||
|
* does not: it arrives from the side of the person who threw it, it overshoots
|
||||||
|
* and rocks back rather than stopping dead, and it comes to rest a little
|
||||||
|
* crooked and a little off the spot it was aimed at. This file is those three
|
||||||
|
* things, and nothing else — no ragdolls, no collisions. Real cards played by
|
||||||
|
* a real hand are only slightly random, and the whole difficulty is in keeping
|
||||||
|
* the randomness *small*: a table where every card lands at a jaunty angle
|
||||||
|
* stops looking like cards and starts looking like a screensaver.
|
||||||
|
*
|
||||||
|
* The randomness is therefore bounded and deterministic. Bounded because every
|
||||||
|
* scatter is a fraction of a card width or a handful of degrees; deterministic
|
||||||
|
* because it is hashed out of the card's own identity rather than drawn from a
|
||||||
|
* generator, which is what stops a card twitching to a new angle every time
|
||||||
|
* something else on the screen recomposes. The same card in the same place
|
||||||
|
* always lies the same way, and two cards never lie the same way as each other.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bounds. Every one of these is deliberately smaller than it wants to be.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Most a settled card is left turned by, in degrees. */
|
||||||
|
const val REST_TILT_DEGREES = 4.5f
|
||||||
|
|
||||||
|
/** Most a settled card misses its spot by, as a fraction of its own width. */
|
||||||
|
const val REST_DRIFT_FRACTION = 0.055f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same two bounds for a card thrown onto a heap rather than laid in front
|
||||||
|
* of a player, which is a good deal less tidy. They are also what makes a heap
|
||||||
|
* legible: with the tight bounds a discard pile of five cards looks like one
|
||||||
|
* card, because the four underneath are hidden to within a twentieth of a card
|
||||||
|
* width of the top one.
|
||||||
|
*/
|
||||||
|
const val HEAP_TILT_DEGREES = 8f
|
||||||
|
const val HEAP_DRIFT_FRACTION = 0.15f
|
||||||
|
|
||||||
|
/** Extra turn a card carries while it is still travelling. */
|
||||||
|
const val THROW_SPIN_DEGREES = 15f
|
||||||
|
|
||||||
|
/** Tilt of a card held in a hand, where the pack has been squared up. */
|
||||||
|
const val HAND_TILT_DEGREES = 1.6f
|
||||||
|
|
||||||
|
/** How far off the table a thrown card starts, in card widths. */
|
||||||
|
const val THROW_DISTANCE = 2.2f
|
||||||
|
|
||||||
|
/** How much bigger a card looks while it is above the table rather than on it. */
|
||||||
|
private const val THROW_SCALE = 1.11f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shadow under a card in flight. What it settles to is the ordinary resting
|
||||||
|
* elevation every other card on the screen already has, read from there rather
|
||||||
|
* than repeated, so a card that has just landed is indistinguishable from one
|
||||||
|
* that has been lying there all along.
|
||||||
|
*/
|
||||||
|
private const val THROW_ELEVATION_DP = 9f
|
||||||
|
private val REST_ELEVATION_DP = CARD_RESTING_ELEVATION.value
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounce in the settle. Below 1 is an underdamped spring, so the card carries
|
||||||
|
* past its resting place and comes back — which is the whole point. Much below
|
||||||
|
* this and it wobbles like rubber.
|
||||||
|
*/
|
||||||
|
private const val SETTLE_DAMPING = 0.62f
|
||||||
|
|
||||||
|
/** Stiffness that gives [CARD_THROW_MILLIS] at normal speed. */
|
||||||
|
private const val SETTLE_STIFFNESS = 420f
|
||||||
|
|
||||||
|
/** Nominal flight time of a card, before the animation-speed setting. */
|
||||||
|
const val CARD_THROW_MILLIS = 380
|
||||||
|
|
||||||
|
/** Nominal time a finished trick takes to be gathered up. */
|
||||||
|
const val CARD_SWEEP_MILLIS = 520
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of the sweep is spent letting the stragglers go. Cards do not all
|
||||||
|
* leave at once when a hand scoops them up, but they do all leave quickly.
|
||||||
|
*/
|
||||||
|
private const val SWEEP_SPREAD = 0.3f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last stretch of the sweep, over which a gathered card fades out. It has
|
||||||
|
* to be late: the cards vanish exactly as the winner's pile grows by one, and
|
||||||
|
* that swap is only invisible if they are still whole most of the way there.
|
||||||
|
*/
|
||||||
|
private const val SWEEP_FADE = 0.42f
|
||||||
|
|
||||||
|
/** How small four cards squared into a bundle end up against one card face. */
|
||||||
|
private const val GATHERED_SCALE = 0.62f
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The pure part: where a card lands, and how far through leaving it is.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hash, not a random number.
|
||||||
|
*
|
||||||
|
* Two rounds of a linear congruential step with a shift between them, which is
|
||||||
|
* more than enough to stop neighbouring seeds producing neighbouring angles —
|
||||||
|
* the visible failure would be a whole suit landing at the same tilt.
|
||||||
|
*/
|
||||||
|
private fun scramble(seed: Int): Int {
|
||||||
|
var h = seed
|
||||||
|
repeat(2) {
|
||||||
|
h = h * 1_664_525 + 1_013_904_223
|
||||||
|
h = h xor (h ushr 15)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A value in `[-1, 1)` for this [seed] and [salt], stable for the life of the
|
||||||
|
* program and identical between runs. [salt] separates the several independent
|
||||||
|
* numbers one card needs from a single seed.
|
||||||
|
*/
|
||||||
|
fun unitNoise(seed: Int, salt: Int): Float {
|
||||||
|
val bits = scramble(seed * 31 + salt * 0x5F5F) ushr 8
|
||||||
|
return bits / 8_388_608f - 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A seed for one card, optionally distinguished by where it is lying. */
|
||||||
|
fun cardSeed(card: Card, place: Int = 0): Int =
|
||||||
|
(card.rank.ordinal * 4 + card.suit.ordinal) * 71 + place * 7919
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a card has come to rest, and how it got there.
|
||||||
|
*
|
||||||
|
* All four scatters come off one seed, so the same card always lies the same
|
||||||
|
* way: [tiltDegrees] and the two drifts are where it ended up, [spinDegrees] is
|
||||||
|
* the turn it was still carrying on the way in, and [lateness] is its place in
|
||||||
|
* the queue when a group of cards moves together.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class CardRest(
|
||||||
|
val tiltDegrees: Float,
|
||||||
|
val driftX: Float,
|
||||||
|
val driftY: Float,
|
||||||
|
val spinDegrees: Float,
|
||||||
|
val lateness: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The resting scatter for [seed], within the bounds set at the top of the file. */
|
||||||
|
fun cardRest(
|
||||||
|
seed: Int,
|
||||||
|
tilt: Float = REST_TILT_DEGREES,
|
||||||
|
drift: Float = REST_DRIFT_FRACTION,
|
||||||
|
): CardRest = CardRest(
|
||||||
|
tiltDegrees = unitNoise(seed, 1) * tilt,
|
||||||
|
driftX = unitNoise(seed, 2) * drift,
|
||||||
|
driftY = unitNoise(seed, 3) * drift,
|
||||||
|
spinDegrees = unitNoise(seed, 4) * THROW_SPIN_DEGREES,
|
||||||
|
lateness = (unitNoise(seed, 5) + 1f) / 2f,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same, at the looser bounds a heap gets.
|
||||||
|
*
|
||||||
|
* A card keeps this scatter for as long as it is on the heap, including once
|
||||||
|
* something has been played on top of it. Switching a card to a wider scatter
|
||||||
|
* the moment it stopped being the top card would make it shuffle sideways on
|
||||||
|
* its own, which is the one thing on a table that never happens.
|
||||||
|
*/
|
||||||
|
fun heapRest(seed: Int): CardRest = cardRest(seed, HEAP_TILT_DEGREES, HEAP_DRIFT_FRACTION)
|
||||||
|
|
||||||
|
/** The small tilt of a card sitting in a squared-up hand. */
|
||||||
|
fun handTilt(seed: Int): Float = unitNoise(seed, 6) * HAND_TILT_DEGREES
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a card thrown by a seat comes in from, in card widths, for a four-seat
|
||||||
|
* table where relative seat 0 sits at the bottom and the rest run clockwise.
|
||||||
|
* A card arrives from beyond its own player's edge, which is the direction it
|
||||||
|
* would have been thrown from.
|
||||||
|
*/
|
||||||
|
fun seatOrigin(relative: Int, distance: Float = THROW_DISTANCE): Pair<Float, Float> =
|
||||||
|
when (relative) {
|
||||||
|
0 -> 0f to distance
|
||||||
|
1 -> -distance to 0f
|
||||||
|
2 -> 0f to -distance
|
||||||
|
else -> distance to 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a card lands on a pile from, in card widths, when there is no seat to
|
||||||
|
* read a direction off — a discard pile takes cards from everyone. The angle is
|
||||||
|
* hashed rather than drawn, so a card always arrives from the same side.
|
||||||
|
*/
|
||||||
|
fun pileOrigin(seed: Int, distance: Float = THROW_DISTANCE): Pair<Float, Float> {
|
||||||
|
val angle = (unitNoise(seed, 7) + 1f) * PI.toFloat()
|
||||||
|
return cos(angle) * distance to sin(angle) * distance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a card that has been played *on* lies, given how much ground the cards
|
||||||
|
* now covering it take up — half the width and half the height of it, in
|
||||||
|
* pixels.
|
||||||
|
*
|
||||||
|
* Pushed out towards the edge of that ground rather than scattered evenly
|
||||||
|
* across it. A card buried dead centre under a set of four is a card nobody can
|
||||||
|
* see, and the entire point of still drawing it is that you can tell the heap
|
||||||
|
* has a history. Out at the edge a corner shows, which is what a real heap
|
||||||
|
* looks like from above.
|
||||||
|
*/
|
||||||
|
fun buriedOffset(seed: Int, halfWidth: Float, halfHeight: Float): Pair<Float, Float> {
|
||||||
|
val angle = (unitNoise(seed, 11) + 1f) * PI.toFloat()
|
||||||
|
// Never right at the rim and never far inside it.
|
||||||
|
val reach = 0.72f + 0.28f * abs(unitNoise(seed, 12))
|
||||||
|
return cos(angle) * halfWidth * reach to sin(angle) * halfHeight * reach
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acceleration curve for cards being dragged off a table: slow to break away,
|
||||||
|
* then gone. Written out rather than taken from an easing class so the sweep
|
||||||
|
* arithmetic stays plain Kotlin and can be tested without a composition.
|
||||||
|
*/
|
||||||
|
private fun accelerate(x: Float): Float = x * x * (0.35f + 0.65f * x)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far through leaving a single card is, given how far through the sweep as
|
||||||
|
* a whole is. Cards start over a window of [spread] rather than together, so a
|
||||||
|
* trick gathers up in one motion rather than four cards moving in lockstep.
|
||||||
|
*/
|
||||||
|
fun sweepProgress(sweep: Float, rest: CardRest, spread: Float = SWEEP_SPREAD): Float {
|
||||||
|
val start = rest.lateness.coerceIn(0f, 1f) * spread.coerceIn(0f, 0.9f)
|
||||||
|
val raw = ((sweep - start) / (1f - start)).coerceIn(0f, 1f)
|
||||||
|
return accelerate(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Straight interpolation, [fraction] being free to overshoot past one. */
|
||||||
|
fun blend(from: Float, to: Float, fraction: Float): Float =
|
||||||
|
from + (to - from) * fraction
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How solid a card being gathered up still is. Whole for most of the journey
|
||||||
|
* and gone at the end of it, so it disappears at the moment the winner's pile
|
||||||
|
* grows rather than dissolving on the way over.
|
||||||
|
*/
|
||||||
|
fun gatherAlpha(leaving: Float): Float = ((1f - leaving) / SWEEP_FADE).coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
/** And how far it has been squared down into the bundle it is joining. */
|
||||||
|
fun gatherScale(leaving: Float): Float = blend(1f, GATHERED_SCALE, leaving.coerceIn(0f, 1f))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The Compose part.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The animation-speed setting, so card motion follows the same slider as
|
||||||
|
* everything else. A composition local rather than a parameter because it would
|
||||||
|
* otherwise have to be threaded through every card screen and down into every
|
||||||
|
* pile inside them, to say one number that never changes mid-game.
|
||||||
|
*/
|
||||||
|
val LocalCardSpeed = staticCompositionLocalOf { 1f }
|
||||||
|
|
||||||
|
/** A nominal duration, adjusted for the speed the player has chosen. */
|
||||||
|
@Composable
|
||||||
|
fun motionMillis(nominal: Int): Int {
|
||||||
|
val speed = LocalCardSpeed.current.coerceIn(Settings.MIN_SPEED, Settings.MAX_SPEED)
|
||||||
|
return (nominal / speed).toInt().coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a card is, and what it looks like, part way through being thrown.
|
||||||
|
*
|
||||||
|
* [alpha] is only ever 0 or 1, and only ever 0 for a card waiting its turn in a
|
||||||
|
* set that is being played together. Without it a delayed card would sit
|
||||||
|
* plainly visible two card widths off the table until its moment came, which
|
||||||
|
* reads as a card teleporting out and flying back rather than as one still in
|
||||||
|
* somebody's hand.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class CardLanding(
|
||||||
|
val offsetX: Float,
|
||||||
|
val offsetY: Float,
|
||||||
|
val rotation: Float,
|
||||||
|
val scale: Float,
|
||||||
|
val elevation: Dp,
|
||||||
|
val alpha: Float = 1f,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follows one card from the moment it is played until it has stopped moving.
|
||||||
|
*
|
||||||
|
* The whole flight is one underdamped spring on a single 0-to-1 value, and
|
||||||
|
* everything else is read off it. That is why the card overshoots its resting
|
||||||
|
* place and rocks back instead of easing to a halt: the spring passes 1 and
|
||||||
|
* returns, and the position, the angle and the height above the table all
|
||||||
|
* follow it there and back together. Driving them separately was tried and
|
||||||
|
* looked like three animations that happened to start at the same time.
|
||||||
|
*
|
||||||
|
* [key] is what counts as a new card arriving. It must change when the card in
|
||||||
|
* this place changes and at no other time, or the card either fails to animate
|
||||||
|
* or animates again every recomposition.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberCardLanding(
|
||||||
|
key: Any?,
|
||||||
|
rest: CardRest,
|
||||||
|
facingDegrees: Float,
|
||||||
|
fromX: Float,
|
||||||
|
fromY: Float,
|
||||||
|
cardWidthPx: Float,
|
||||||
|
durationMillis: Int,
|
||||||
|
delayMillis: Int = 0,
|
||||||
|
): CardLanding {
|
||||||
|
val settle = remember(key) { Animatable(0f) }
|
||||||
|
var launched by remember(key) { mutableStateOf(delayMillis <= 0) }
|
||||||
|
LaunchedEffect(key) {
|
||||||
|
if (delayMillis > 0) {
|
||||||
|
delay(delayMillis.toLong())
|
||||||
|
launched = true
|
||||||
|
}
|
||||||
|
settle.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = SETTLE_DAMPING,
|
||||||
|
stiffness = SETTLE_STIFFNESS * CARD_THROW_MILLIS / durationMillis,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val travelled = settle.value
|
||||||
|
// Height above the table is the one thing that must not overshoot: a card
|
||||||
|
// that has landed cannot rise again, whatever the spring is still doing.
|
||||||
|
val landed = travelled.coerceIn(0f, 1f)
|
||||||
|
return CardLanding(
|
||||||
|
offsetX = blend(fromX, rest.driftX * cardWidthPx, travelled),
|
||||||
|
offsetY = blend(fromY, rest.driftY * cardWidthPx, travelled),
|
||||||
|
rotation = blend(
|
||||||
|
facingDegrees + rest.spinDegrees,
|
||||||
|
facingDegrees + rest.tiltDegrees,
|
||||||
|
travelled,
|
||||||
|
),
|
||||||
|
scale = blend(THROW_SCALE, 1f, landed),
|
||||||
|
elevation = blend(THROW_ELEVATION_DP, REST_ELEVATION_DP, landed).dp,
|
||||||
|
alpha = if (launched) 1f else 0f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts a card where its [landing] says it is. For cards drawn on their own. */
|
||||||
|
fun Modifier.landed(landing: CardLanding): Modifier = this
|
||||||
|
.offset { IntOffset(landing.offsetX.roundToInt(), landing.offsetY.roundToInt()) }
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = landing.scale
|
||||||
|
scaleY = landing.scale
|
||||||
|
rotationZ = landing.rotation
|
||||||
|
alpha = landing.alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many cards of a pile are worth drawing. Below this it is not a heap. */
|
||||||
|
private const val PILE_DEPTH = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many taken tricks are worth drawing separately. Past this the pile stops
|
||||||
|
* growing rather than climbing off the table — Wizard's last round is twenty
|
||||||
|
* tricks, and twenty bundles of stagger is not a pile, it is a staircase.
|
||||||
|
*/
|
||||||
|
private const val WON_STACK_DEPTH = 8
|
||||||
|
|
||||||
|
/** How far each taken trick sits off the one under it, in card widths. */
|
||||||
|
private const val WON_STACK_STEP = 0.045f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tricks a player has taken, squared up and turned face down in front of
|
||||||
|
* them.
|
||||||
|
*
|
||||||
|
* One card back per trick rather than four, which is what a real table looks
|
||||||
|
* like: a trick is gathered, squared and put down as a single bundle, and from
|
||||||
|
* above you see the top card of each bundle and the edges of the rest. The
|
||||||
|
* count is the pile — it is the thing a player actually glances at to see how
|
||||||
|
* the hand is going, and it is why cards are kept rather than binned.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WonTrickStack(
|
||||||
|
tricks: Int,
|
||||||
|
width: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
facingDegrees: Float = 0f,
|
||||||
|
/** Where on the table these were gathered from, in pixels. */
|
||||||
|
fromX: Float = 0f,
|
||||||
|
fromY: Float = 0f,
|
||||||
|
) {
|
||||||
|
if (tricks <= 0) return
|
||||||
|
|
||||||
|
val shown = tricks.coerceAtMost(WON_STACK_DEPTH)
|
||||||
|
val step = width * WON_STACK_STEP
|
||||||
|
val widthPx = with(LocalDensity.current) { width.toPx() }
|
||||||
|
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(end = step * shown, bottom = step * shown)
|
||||||
|
.width(width)
|
||||||
|
.height(width / CARD_ASPECT),
|
||||||
|
) {
|
||||||
|
for (bundle in 0 until shown) {
|
||||||
|
key(bundle) {
|
||||||
|
// Each bundle is squared up, not thrown down, so it gets a
|
||||||
|
// fraction of the scatter a played card gets.
|
||||||
|
val rest = cardRest(bundle * 613, tilt = REST_TILT_DEGREES * 0.5f)
|
||||||
|
val top = bundle == shown - 1
|
||||||
|
val landing = if (!top) {
|
||||||
|
CardLanding(
|
||||||
|
offsetX = 0f,
|
||||||
|
offsetY = 0f,
|
||||||
|
rotation = facingDegrees + rest.tiltDegrees,
|
||||||
|
scale = 1f,
|
||||||
|
elevation = REST_ELEVATION_DP.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Only the newest bundle arrives; the ones under it have
|
||||||
|
// been sitting there since earlier in the hand.
|
||||||
|
rememberCardLanding(
|
||||||
|
key = tricks,
|
||||||
|
rest = rest.copy(driftX = 0f, driftY = 0f),
|
||||||
|
facingDegrees = facingDegrees,
|
||||||
|
fromX = fromX,
|
||||||
|
fromY = fromY,
|
||||||
|
cardWidthPx = widthPx,
|
||||||
|
durationMillis = throwMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CardBackView(
|
||||||
|
width = width,
|
||||||
|
elevation = landing.elevation,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(x = step * bundle, y = step * bundle)
|
||||||
|
.landed(landing),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How wide a taken-trick pile is drawn against a card on the table. */
|
||||||
|
private const val WON_STACK_SCALE = 0.5f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everyone's taken tricks, at a table of four with partners opposite.
|
||||||
|
*
|
||||||
|
* Each pile sits beside its own player's card, one place clockwise along their
|
||||||
|
* edge, which is both where a real player would put them and the one spot on
|
||||||
|
* each edge that nothing else on the table is using.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BoxScope.TakenTricks(tricksWon: List<Int>, localSeat: Int, cardWidth: Dp) {
|
||||||
|
val stackWidth = cardWidth * WON_STACK_SCALE
|
||||||
|
val stackWidthPx = with(LocalDensity.current) { stackWidth.toPx() }
|
||||||
|
|
||||||
|
for (seat in 0 until 4) {
|
||||||
|
val relative = ((seat - localSeat) % 4 + 4) % 4
|
||||||
|
val alignment = when (relative) {
|
||||||
|
0 -> Alignment.BottomCenter
|
||||||
|
1 -> Alignment.CenterStart
|
||||||
|
2 -> Alignment.TopCenter
|
||||||
|
else -> Alignment.CenterEnd
|
||||||
|
}
|
||||||
|
val (nudgeX, nudgeY) = when (relative) {
|
||||||
|
0 -> cardWidth * 1.6f to 0.dp
|
||||||
|
1 -> 0.dp to cardWidth * 1.4f
|
||||||
|
2 -> -(cardWidth * 1.6f) to 0.dp
|
||||||
|
else -> 0.dp to -(cardWidth * 1.4f)
|
||||||
|
}
|
||||||
|
// Gathered in from the middle of the table, which is the opposite way
|
||||||
|
// from the edge this player sits at.
|
||||||
|
val (outX, outY) = seatOrigin(relative, 2.5f)
|
||||||
|
|
||||||
|
WonTrickStack(
|
||||||
|
tricks = tricksWon.getOrElse(seat) { 0 },
|
||||||
|
width = stackWidth,
|
||||||
|
facingDegrees = relative * 90f,
|
||||||
|
fromX = -outX * stackWidthPx,
|
||||||
|
fromY = -outY * stackWidthPx,
|
||||||
|
modifier = Modifier.align(alignment).offset(x = nudgeX, y = nudgeY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same, round a table of any size: each pile sits half a seat clockwise
|
||||||
|
* from its player's card, just inside the ring the cards are played on.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BoxScope.TakenTricksAround(
|
||||||
|
tricksWon: List<Int>,
|
||||||
|
localSeat: Int,
|
||||||
|
players: Int,
|
||||||
|
cardWidth: Dp,
|
||||||
|
radiusPx: Float,
|
||||||
|
) {
|
||||||
|
val stackWidth = cardWidth * WON_STACK_SCALE
|
||||||
|
val stackWidthPx = with(LocalDensity.current) { stackWidth.toPx() }
|
||||||
|
|
||||||
|
for (seat in 0 until players) {
|
||||||
|
val relative = ((seat - localSeat) % players + players) % players
|
||||||
|
val angle = 2.0 * PI * relative / players + PI / players
|
||||||
|
val x = -sin(angle) * radiusPx
|
||||||
|
val y = cos(angle) * radiusPx
|
||||||
|
|
||||||
|
WonTrickStack(
|
||||||
|
tricks = tricksWon.getOrElse(seat) { 0 },
|
||||||
|
width = stackWidth,
|
||||||
|
facingDegrees = relative * 360f / players,
|
||||||
|
fromX = (sin(angle) * stackWidthPx * 2.5f).toFloat(),
|
||||||
|
fromY = (-cos(angle) * stackWidthPx * 2.5f).toFloat(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.offset { IntOffset(x.roundToInt(), y.roundToInt()) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trick counts, held back until the cards have actually got there.
|
||||||
|
*
|
||||||
|
* The rules count a trick the instant its last card is played, which is a beat
|
||||||
|
* before the cards are gathered and two before they arrive. Reading the count
|
||||||
|
* straight from the state would grow the winner's pile while the trick was
|
||||||
|
* still lying in the middle of the table, and the cards would then slide over
|
||||||
|
* and land on a pile that had already counted them.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberArrivedTricks(
|
||||||
|
tricksWon: List<Int>,
|
||||||
|
sweeping: Boolean,
|
||||||
|
sweepStarted: Boolean,
|
||||||
|
sweepMillis: Int,
|
||||||
|
): List<Int> {
|
||||||
|
var arrived by remember { mutableStateOf(tricksWon) }
|
||||||
|
LaunchedEffect(tricksWon, sweeping, sweepStarted) {
|
||||||
|
// Still on the table waiting to be read: the pile has not won it yet.
|
||||||
|
if (sweeping && !sweepStarted) return@LaunchedEffect
|
||||||
|
if (sweeping) delay(sweepMillis.toLong())
|
||||||
|
arrived = tricksWon
|
||||||
|
}
|
||||||
|
return arrived
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holder for [rememberPileWatermark]. Deliberately not snapshot state. */
|
||||||
|
private class PileWatermark(var value: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of a pile was already lying there rather than played onto it.
|
||||||
|
*
|
||||||
|
* Cards above this line get thrown on; cards below it are simply drawn where
|
||||||
|
* they lie. Two things have to be true and only one of them is obvious. The
|
||||||
|
* obvious one is that a pile you have just opened the screen on was not built
|
||||||
|
* in front of you, so none of it should fly in. The other is that a pile gets
|
||||||
|
* *shorter* between rounds — a new deal replaces a twenty-card heap with a
|
||||||
|
* single turned card — and a mark left at twenty would then sit above every
|
||||||
|
* card of the new round and quietly switch the animation off for the rest of
|
||||||
|
* the game. So the mark follows the pile down, and only ever down.
|
||||||
|
*
|
||||||
|
* A plain holder rather than snapshot state on purpose: this is written and
|
||||||
|
* read within one composition, and it must not be the reason anything
|
||||||
|
* recomposes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberPileWatermark(size: Int): Int {
|
||||||
|
val mark = remember { PileWatermark(size) }
|
||||||
|
if (size < mark.value) mark.value = size
|
||||||
|
return mark.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pile of cards seen from above.
|
||||||
|
*
|
||||||
|
* Only the top few are drawn, each lying where it fell, which is enough to read
|
||||||
|
* as a heap without painting fifty cards nobody can see. Cards already on the
|
||||||
|
* pile when the screen opens are drawn settled — the pile was built before you
|
||||||
|
* got here — and only cards played after that are thrown on.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CardPile(
|
||||||
|
cards: List<Card>,
|
||||||
|
width: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
depth: Int = PILE_DEPTH,
|
||||||
|
) {
|
||||||
|
if (cards.isEmpty()) return
|
||||||
|
|
||||||
|
// Position in the pile, not position in the window, so a card keeps its
|
||||||
|
// identity — and so its resting angle — as the window slides past it.
|
||||||
|
val bottom = (cards.size - depth).coerceAtLeast(0)
|
||||||
|
val alreadyDown = rememberPileWatermark(cards.size)
|
||||||
|
val cardWidthPx = with(LocalDensity.current) { width.toPx() }
|
||||||
|
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||||
|
|
||||||
|
// Room for the scatter, so the outermost card is not clipped by whatever
|
||||||
|
// this pile has been placed inside.
|
||||||
|
val margin = width * HEAP_DRIFT_FRACTION
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(horizontal = margin, vertical = margin)
|
||||||
|
.width(width)
|
||||||
|
.height(width / CARD_ASPECT),
|
||||||
|
) {
|
||||||
|
for (index in bottom until cards.size) {
|
||||||
|
key(index) {
|
||||||
|
val card = cards[index]
|
||||||
|
val rest = heapRest(cardSeed(card, index))
|
||||||
|
val landing = if (index < alreadyDown) {
|
||||||
|
CardLanding(
|
||||||
|
offsetX = rest.driftX * cardWidthPx,
|
||||||
|
offsetY = rest.driftY * cardWidthPx,
|
||||||
|
rotation = rest.tiltDegrees,
|
||||||
|
scale = 1f,
|
||||||
|
elevation = REST_ELEVATION_DP.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val (fromX, fromY) = pileOrigin(cardSeed(card, index))
|
||||||
|
rememberCardLanding(
|
||||||
|
key = index,
|
||||||
|
rest = rest,
|
||||||
|
facingDegrees = 0f,
|
||||||
|
fromX = fromX * cardWidthPx,
|
||||||
|
fromY = fromY * cardWidthPx,
|
||||||
|
cardWidthPx = cardWidthPx,
|
||||||
|
durationMillis = throwMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PlayingCardView(
|
||||||
|
card = card,
|
||||||
|
width = width,
|
||||||
|
elevation = landing.elevation,
|
||||||
|
modifier = Modifier.landed(landing),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stock of face-down cards, drawn with enough of the pack under the top card
|
||||||
|
* to show there is something left in it. The offsets are a squared-up deck's
|
||||||
|
* worth — a couple of percent — rather than the scatter a played card gets.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CardBackStack(
|
||||||
|
count: Int,
|
||||||
|
width: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
depth: Int = 3,
|
||||||
|
) {
|
||||||
|
if (count <= 0) return
|
||||||
|
|
||||||
|
val shown = count.coerceAtMost(depth)
|
||||||
|
val step = width * 0.018f
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(end = step * shown, bottom = step * shown)
|
||||||
|
.width(width)
|
||||||
|
.height(width / CARD_ASPECT),
|
||||||
|
) {
|
||||||
|
// Bottom of the pack first, so the top card is drawn last and sits on top.
|
||||||
|
for (below in (shown - 1) downTo 0) {
|
||||||
|
CardBackView(
|
||||||
|
width = width,
|
||||||
|
elevation = if (below == 0) REST_ELEVATION_DP.dp else 0.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(x = step * below, y = step * below)
|
||||||
|
.rotate(unitNoise(below, 8) * 0.8f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -181,7 +182,10 @@ private fun PileArea(state: CrazyEightsState, cardWidth: Dp) {
|
|||||||
color = MaterialTheme.colorScheme.onPrimary,
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CardBackView(width = cardWidth)
|
// A pack, not a single back: the stock is the one thing on
|
||||||
|
// this screen that is visibly a stack of card, and drawing
|
||||||
|
// it as one is most of what makes the table look real.
|
||||||
|
CardBackStack(count = state.stock.size, width = cardWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
@@ -190,7 +194,9 @@ private fun PileArea(state: CrazyEightsState, cardWidth: Dp) {
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
)
|
)
|
||||||
state.topCard?.let { PlayingCardView(card = it, width = cardWidth) }
|
// The discard heap rather than only its top card, so a card
|
||||||
|
// played onto it lands on something.
|
||||||
|
CardPile(cards = state.discard, width = cardWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// An eight on top says nothing about what may be played next, so the
|
// An eight on top says nothing about what may be played next, so the
|
||||||
@@ -331,6 +337,7 @@ private fun HandRow(
|
|||||||
enabled = canPlay,
|
enabled = canPlay,
|
||||||
selected = card == pendingWild,
|
selected = card == pendingWild,
|
||||||
caption = if (isWild(card)) "WILD" else null,
|
caption = if (isWild(card)) "WILD" else null,
|
||||||
|
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||||
onClick = { onTap(card) },
|
onClick = { onTap(card) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,16 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -280,8 +283,34 @@ private fun TableArea(state: CribbageState, localSeat: Int, cardWidth: Dp) {
|
|||||||
color = ink,
|
color = ink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
state.series.forEach { pegged ->
|
// The series stays on the table until it is counted out, so each
|
||||||
PlayingCardView(card = pegged.card, width = cardWidth * 0.7f)
|
// card is laid beside the last rather than replacing it. Keyed
|
||||||
|
// by its place in the series so only the card just pegged is
|
||||||
|
// thrown down.
|
||||||
|
val pegWidth = cardWidth * 0.7f
|
||||||
|
val pegWidthPx = with(LocalDensity.current) { pegWidth.toPx() }
|
||||||
|
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||||
|
state.series.forEachIndexed { place, pegged ->
|
||||||
|
key(place) {
|
||||||
|
val seed = cardSeed(pegged.card, place)
|
||||||
|
val rest = cardRest(seed)
|
||||||
|
val (fromX, fromY) = pileOrigin(seed)
|
||||||
|
val landing = rememberCardLanding(
|
||||||
|
key = place,
|
||||||
|
rest = rest,
|
||||||
|
facingDegrees = 0f,
|
||||||
|
fromX = fromX * pegWidthPx,
|
||||||
|
fromY = fromY * pegWidthPx,
|
||||||
|
cardWidthPx = pegWidthPx,
|
||||||
|
durationMillis = throwMillis,
|
||||||
|
)
|
||||||
|
PlayingCardView(
|
||||||
|
card = pegged.card,
|
||||||
|
width = pegWidth,
|
||||||
|
elevation = landing.elevation,
|
||||||
|
modifier = Modifier.landed(landing),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,6 +444,7 @@ private fun HandRow(
|
|||||||
width = cardWidth,
|
width = cardWidth,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
selected = card in chosen,
|
selected = card in chosen,
|
||||||
|
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||||
onClick = { onTap(card) },
|
onClick = { onTap(card) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -25,6 +26,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -33,6 +35,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
@@ -184,9 +187,6 @@ private fun seatLabel(relative: Int): String = when (relative) {
|
|||||||
/** How long a completed trick rests on the table before being swept away. */
|
/** How long a completed trick rests on the table before being swept away. */
|
||||||
const val TRICK_HOLD_MILLIS = 1_000L
|
const val TRICK_HOLD_MILLIS = 1_000L
|
||||||
|
|
||||||
/** Duration of the sweep itself. */
|
|
||||||
private const val TRICK_SWEEP_MILLIS = 450
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrickArea(
|
private fun TrickArea(
|
||||||
state: EuchreState,
|
state: EuchreState,
|
||||||
@@ -206,9 +206,12 @@ private fun TrickArea(
|
|||||||
sweepStarted = true
|
sweepStarted = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Driven linearly, because the shape of the sweep belongs to each card
|
||||||
|
// rather than to the clock: sweepProgress gives them their own start and
|
||||||
|
// their own acceleration off this one value.
|
||||||
val sweep by animateFloatAsState(
|
val sweep by animateFloatAsState(
|
||||||
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
||||||
animationSpec = tween(durationMillis = TRICK_SWEEP_MILLIS),
|
animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing),
|
||||||
label = "trickSweep",
|
label = "trickSweep",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -220,10 +223,25 @@ private fun TrickArea(
|
|||||||
2 -> 0f to -1f
|
2 -> 0f to -1f
|
||||||
else -> 1f to 0f
|
else -> 1f to 0f
|
||||||
}
|
}
|
||||||
val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() }
|
val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() }
|
||||||
|
val travelPx = cardWidthPx * 2.5f
|
||||||
|
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||||
|
|
||||||
|
// The count follows the cards rather than the rules, so a pile grows as the
|
||||||
|
// trick reaches it and not the moment the last card was played.
|
||||||
|
val arrived = rememberArrivedTricks(
|
||||||
|
tricksWon = state.tricksWon,
|
||||||
|
sweeping = sweeping,
|
||||||
|
sweepStarted = sweepStarted,
|
||||||
|
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
|
||||||
|
)
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||||
|
|
||||||
|
// What each player has taken, face down in front of them. Drawn first
|
||||||
|
// so a trick still being gathered passes over the pile it is joining.
|
||||||
|
TakenTricks(arrived, localSeat, cardWidth)
|
||||||
|
|
||||||
// The turn card sits in the middle of the table during bidding.
|
// The turn card sits in the middle of the table during bidding.
|
||||||
state.upCard?.let { up ->
|
state.upCard?.let { up ->
|
||||||
Column(
|
Column(
|
||||||
@@ -249,45 +267,77 @@ private fun TrickArea(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyed by seat: one card per seat per trick, so this holds a card's
|
||||||
|
// flight steady while the trick fills up and while the finished trick
|
||||||
|
// is swapped for the copy that gets swept away. Without it the list
|
||||||
|
// position would decide identity and every card would jump back into
|
||||||
|
// the air whenever another one landed.
|
||||||
cardsOnTable.forEach { played ->
|
cardsOnTable.forEach { played ->
|
||||||
val relative = relativePosition(played.seat, localSeat)
|
key(played.seat) {
|
||||||
val alignment = when (relative) {
|
val relative = relativePosition(played.seat, localSeat)
|
||||||
0 -> Alignment.BottomCenter
|
val alignment = when (relative) {
|
||||||
1 -> Alignment.CenterStart
|
0 -> Alignment.BottomCenter
|
||||||
2 -> Alignment.TopCenter
|
1 -> Alignment.CenterStart
|
||||||
else -> Alignment.CenterEnd
|
2 -> Alignment.TopCenter
|
||||||
}
|
else -> Alignment.CenterEnd
|
||||||
// Rotate to face the player who played it: the side seats end up
|
}
|
||||||
// landscape, the partner's card upside down from your side.
|
// Turned to face the player who played it: the side seats end
|
||||||
val rotation = when (relative) {
|
// up landscape, the partner's card upside down from your side.
|
||||||
1 -> 90f
|
val facing = when (relative) {
|
||||||
2 -> 180f
|
1 -> 90f
|
||||||
3 -> -90f
|
2 -> 180f
|
||||||
else -> 0f
|
3 -> -90f
|
||||||
}
|
else -> 0f
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
val rest = cardRest(cardSeed(played.card, played.seat))
|
||||||
modifier = Modifier
|
val (fromX, fromY) = seatOrigin(relative)
|
||||||
.align(alignment)
|
val landing = rememberCardLanding(
|
||||||
.offset {
|
key = played.card,
|
||||||
IntOffset(
|
rest = rest,
|
||||||
x = (dirX * travelPx * sweep).roundToInt(),
|
facingDegrees = facing,
|
||||||
y = (dirY * travelPx * sweep).roundToInt(),
|
fromX = fromX * cardWidthPx,
|
||||||
)
|
fromY = fromY * cardWidthPx,
|
||||||
}
|
cardWidthPx = cardWidthPx,
|
||||||
.alpha(1f - sweep),
|
durationMillis = throwMillis,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
PlayingCardView(
|
|
||||||
card = played.card,
|
|
||||||
width = cardWidth,
|
|
||||||
modifier = Modifier.rotate(rotation),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = seatLabel(relative),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
)
|
)
|
||||||
|
val leaving = sweepProgress(sweep, rest)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(alignment)
|
||||||
|
.offset {
|
||||||
|
IntOffset(
|
||||||
|
x = (landing.offsetX + dirX * travelPx * leaving).roundToInt(),
|
||||||
|
y = (landing.offsetY + dirY * travelPx * leaving).roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.graphicsLayer {
|
||||||
|
// Squared down into the bundle it is joining, and
|
||||||
|
// still whole until it gets there.
|
||||||
|
val gathered = landing.scale * gatherScale(leaving)
|
||||||
|
scaleX = gathered
|
||||||
|
scaleY = gathered
|
||||||
|
alpha = landing.alpha * gatherAlpha(leaving)
|
||||||
|
},
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
PlayingCardView(
|
||||||
|
card = played.card,
|
||||||
|
width = cardWidth,
|
||||||
|
elevation = landing.elevation,
|
||||||
|
// The turn it picks up on the way out is the same turn
|
||||||
|
// it carried on the way in, so a card leaves the table
|
||||||
|
// the way it arrived.
|
||||||
|
modifier = Modifier
|
||||||
|
.rotate(landing.rotation + rest.spinDegrees * leaving),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = seatLabel(relative),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +499,11 @@ private fun HandRow(
|
|||||||
card = card,
|
card = card,
|
||||||
width = cardWidth,
|
width = cardWidth,
|
||||||
enabled = canPlay || canDiscard,
|
enabled = canPlay || canDiscard,
|
||||||
|
// A hand that has been squared up is still not square. Two
|
||||||
|
// degrees is below the threshold at which anyone notices a
|
||||||
|
// tilt and above the one at which a row of cards stops
|
||||||
|
// looking printed.
|
||||||
|
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||||
onClick = {
|
onClick = {
|
||||||
when {
|
when {
|
||||||
canDiscard -> onPlay(Discard(card))
|
canDiscard -> onPlay(Discard(card))
|
||||||
|
|||||||
@@ -252,7 +252,14 @@ private fun StockAndDiscard(
|
|||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text("Stock", style = MaterialTheme.typography.labelSmall,
|
Text("Stock", style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onPrimary)
|
color = MaterialTheme.colorScheme.onPrimary)
|
||||||
CardBackView(width = cardWidth)
|
// Drawing the pack rather than one back also means an empty stock
|
||||||
|
// now looks empty, where before it showed a card that was not there.
|
||||||
|
if (state.stock.isEmpty()) {
|
||||||
|
Text("empty", style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
} else {
|
||||||
|
CardBackStack(count = state.stock.size, width = cardWidth)
|
||||||
|
}
|
||||||
if (legal.contains(DrawFromStock)) {
|
if (legal.contains(DrawFromStock)) {
|
||||||
Button(onClick = { onMove(DrawFromStock) }) { Text("Draw") }
|
Button(onClick = { onMove(DrawFromStock) }) { Text("Draw") }
|
||||||
}
|
}
|
||||||
@@ -261,7 +268,7 @@ private fun StockAndDiscard(
|
|||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text("Discard", style = MaterialTheme.typography.labelSmall,
|
Text("Discard", style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onPrimary)
|
color = MaterialTheme.colorScheme.onPrimary)
|
||||||
state.discard.lastOrNull()?.let { PlayingCardView(card = it, width = cardWidth) }
|
CardPile(cards = state.discard, width = cardWidth)
|
||||||
if (legal.contains(DrawFromDiscard)) {
|
if (legal.contains(DrawFromDiscard)) {
|
||||||
Button(onClick = { onMove(DrawFromDiscard) }) { Text("Take") }
|
Button(onClick = { onMove(DrawFromDiscard) }) { Text("Take") }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -25,6 +26,8 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
|
|||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.prolibertate.games.game.cards.Card
|
import org.prolibertate.games.game.cards.Card
|
||||||
@@ -126,6 +129,7 @@ fun HeartsScreen(
|
|||||||
selected = chosen.contains(card),
|
selected = chosen.contains(card),
|
||||||
enabled = if (selectable) true else playable.contains(card),
|
enabled = if (selectable) true else playable.contains(card),
|
||||||
caption = pointsOf(card).takeIf { it > 0 }?.let { "−$it" },
|
caption = pointsOf(card).takeIf { it > 0 }?.let { "−$it" },
|
||||||
|
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||||
onClick = {
|
onClick = {
|
||||||
if (selectable) {
|
if (selectable) {
|
||||||
toggle(chosen, card)
|
toggle(chosen, card)
|
||||||
@@ -188,13 +192,35 @@ private fun TrickView(state: HeartsState) {
|
|||||||
if (showing.isEmpty()) {
|
if (showing.isEmpty()) {
|
||||||
Text("Nothing played yet.", style = MaterialTheme.typography.bodySmall)
|
Text("Nothing played yet.", style = MaterialTheme.typography.bodySmall)
|
||||||
} else {
|
} else {
|
||||||
|
// Hearts lays its trick out in a row rather than round a table, so
|
||||||
|
// there is no seat direction to throw a card in from — each one
|
||||||
|
// comes in from its own side, which the seed decides and keeps.
|
||||||
|
val width = 46.dp
|
||||||
|
val widthPx = with(LocalDensity.current) { width.toPx() }
|
||||||
|
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
showing.forEach { played ->
|
showing.forEach { played ->
|
||||||
PlayingCardView(
|
key(played.seat) {
|
||||||
card = played.card,
|
val seed = cardSeed(played.card, played.seat)
|
||||||
width = 46.dp,
|
val rest = cardRest(seed)
|
||||||
caption = "seat ${played.seat}",
|
val (fromX, fromY) = pileOrigin(seed)
|
||||||
)
|
val landing = rememberCardLanding(
|
||||||
|
key = played.card,
|
||||||
|
rest = rest,
|
||||||
|
facingDegrees = 0f,
|
||||||
|
fromX = fromX * widthPx,
|
||||||
|
fromY = fromY * widthPx,
|
||||||
|
cardWidthPx = widthPx,
|
||||||
|
durationMillis = throwMillis,
|
||||||
|
)
|
||||||
|
PlayingCardView(
|
||||||
|
card = played.card,
|
||||||
|
width = width,
|
||||||
|
caption = "seat ${played.seat}",
|
||||||
|
elevation = landing.elevation,
|
||||||
|
modifier = Modifier.landed(landing),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,6 +240,16 @@ private fun ScoreRow(state: HeartsState, localSeat: Int) {
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontWeight = if (seat == localSeat) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (seat == localSeat) FontWeight.Bold else FontWeight.Normal,
|
||||||
)
|
)
|
||||||
|
// The tricks this player has taken, face down in front of them.
|
||||||
|
// Hearts keeps the cards themselves rather than a count, since
|
||||||
|
// it is the cards that are scored at the end of the round, so
|
||||||
|
// the number of tricks is however many players-worth of them
|
||||||
|
// there are.
|
||||||
|
WonTrickStack(
|
||||||
|
tricks = state.taken.getOrElse(seat) { emptyList() }.size /
|
||||||
|
state.options.playerCount,
|
||||||
|
width = 26.dp,
|
||||||
|
)
|
||||||
Text("$score", fontWeight = FontWeight.Bold)
|
Text("$score", fontWeight = FontWeight.Bold)
|
||||||
// What this round has cost so far, which is the number anybody
|
// What this round has cost so far, which is the number anybody
|
||||||
// actually watches while a hand is being played.
|
// actually watches while a hand is being played.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -25,6 +26,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -32,6 +34,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
@@ -181,9 +184,6 @@ private fun seatLabel(relative: Int): String = when (relative) {
|
|||||||
else -> "Right"
|
else -> "Right"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Duration of the sweep towards the winner. */
|
|
||||||
private const val SWEEP_MILLIS = 450
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrickArea(
|
private fun TrickArea(
|
||||||
state: KaiserState,
|
state: KaiserState,
|
||||||
@@ -203,7 +203,7 @@ private fun TrickArea(
|
|||||||
}
|
}
|
||||||
val sweep by animateFloatAsState(
|
val sweep by animateFloatAsState(
|
||||||
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
||||||
animationSpec = tween(durationMillis = SWEEP_MILLIS),
|
animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing),
|
||||||
label = "kaiserSweep",
|
label = "kaiserSweep",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -214,9 +214,25 @@ private fun TrickArea(
|
|||||||
2 -> 0f to -1f
|
2 -> 0f to -1f
|
||||||
else -> 1f to 0f
|
else -> 1f to 0f
|
||||||
}
|
}
|
||||||
val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() }
|
val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() }
|
||||||
|
val travelPx = cardWidthPx * 2.5f
|
||||||
|
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||||
|
|
||||||
|
// Kaiser scores in points rather than tricks, but the cards are still won
|
||||||
|
// and still kept, and how many each player is sitting on is worth seeing.
|
||||||
|
val arrived = rememberArrivedTricks(
|
||||||
|
tricksWon = state.tricksWon,
|
||||||
|
sweeping = sweeping,
|
||||||
|
sweepStarted = sweepStarted,
|
||||||
|
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
|
||||||
|
)
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||||
|
|
||||||
|
// Drawn first, so a trick still being gathered passes over the pile it
|
||||||
|
// is on its way to joining.
|
||||||
|
TakenTricks(arrived, localSeat, cardWidth)
|
||||||
|
|
||||||
if (cardsOnTable.isEmpty()) {
|
if (cardsOnTable.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = when {
|
text = when {
|
||||||
@@ -230,43 +246,65 @@ private fun TrickArea(
|
|||||||
}
|
}
|
||||||
|
|
||||||
cardsOnTable.forEach { played ->
|
cardsOnTable.forEach { played ->
|
||||||
val relative = relativePosition(played.seat, localSeat)
|
key(played.seat) {
|
||||||
val alignment = when (relative) {
|
val relative = relativePosition(played.seat, localSeat)
|
||||||
0 -> Alignment.BottomCenter
|
val alignment = when (relative) {
|
||||||
1 -> Alignment.CenterStart
|
0 -> Alignment.BottomCenter
|
||||||
2 -> Alignment.TopCenter
|
1 -> Alignment.CenterStart
|
||||||
else -> Alignment.CenterEnd
|
2 -> Alignment.TopCenter
|
||||||
}
|
else -> Alignment.CenterEnd
|
||||||
val rotation = when (relative) {
|
}
|
||||||
1 -> 90f
|
val facing = when (relative) {
|
||||||
2 -> 180f
|
1 -> 90f
|
||||||
3 -> -90f
|
2 -> 180f
|
||||||
else -> 0f
|
3 -> -90f
|
||||||
}
|
else -> 0f
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
val rest = cardRest(cardSeed(played.card, played.seat))
|
||||||
modifier = Modifier
|
val (fromX, fromY) = seatOrigin(relative)
|
||||||
.align(alignment)
|
val landing = rememberCardLanding(
|
||||||
.offset {
|
key = played.card,
|
||||||
IntOffset(
|
rest = rest,
|
||||||
x = (dirX * travelPx * sweep).roundToInt(),
|
facingDegrees = facing,
|
||||||
y = (dirY * travelPx * sweep).roundToInt(),
|
fromX = fromX * cardWidthPx,
|
||||||
)
|
fromY = fromY * cardWidthPx,
|
||||||
}
|
cardWidthPx = cardWidthPx,
|
||||||
.alpha(1f - sweep),
|
durationMillis = throwMillis,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
PlayingCardView(
|
|
||||||
card = played.card,
|
|
||||||
width = cardWidth,
|
|
||||||
caption = counterCaption(played.card),
|
|
||||||
modifier = Modifier.rotate(rotation),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = seatLabel(relative),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
)
|
)
|
||||||
|
val leaving = sweepProgress(sweep, rest)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(alignment)
|
||||||
|
.offset {
|
||||||
|
IntOffset(
|
||||||
|
x = (landing.offsetX + dirX * travelPx * leaving).roundToInt(),
|
||||||
|
y = (landing.offsetY + dirY * travelPx * leaving).roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.graphicsLayer {
|
||||||
|
val gathered = landing.scale * gatherScale(leaving)
|
||||||
|
scaleX = gathered
|
||||||
|
scaleY = gathered
|
||||||
|
alpha = landing.alpha * gatherAlpha(leaving)
|
||||||
|
},
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
PlayingCardView(
|
||||||
|
card = played.card,
|
||||||
|
width = cardWidth,
|
||||||
|
caption = counterCaption(played.card),
|
||||||
|
elevation = landing.elevation,
|
||||||
|
modifier = Modifier
|
||||||
|
.rotate(landing.rotation + rest.spinDegrees * leaving),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = seatLabel(relative),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +444,7 @@ private fun HandRow(
|
|||||||
width = cardWidth,
|
width = cardWidth,
|
||||||
enabled = canPlay,
|
enabled = canPlay,
|
||||||
caption = counterCaption(card),
|
caption = counterCaption(card),
|
||||||
|
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||||
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -11,7 +13,9 @@ import androidx.compose.foundation.layout.FlowRow
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -20,11 +24,17 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import org.prolibertate.games.game.president.PassTurn
|
import org.prolibertate.games.game.president.PassTurn
|
||||||
import org.prolibertate.games.game.president.PlayCards
|
import org.prolibertate.games.game.president.PlayCards
|
||||||
import org.prolibertate.games.game.president.PresidentMove
|
import org.prolibertate.games.game.president.PresidentMove
|
||||||
@@ -34,6 +44,7 @@ import org.prolibertate.games.game.president.titleFor
|
|||||||
import org.prolibertate.games.net.MatchController
|
import org.prolibertate.games.net.MatchController
|
||||||
import org.prolibertate.games.ui.PrimaryAction
|
import org.prolibertate.games.ui.PrimaryAction
|
||||||
import org.prolibertate.games.ui.ScreenScaffold
|
import org.prolibertate.games.ui.ScreenScaffold
|
||||||
|
import org.prolibertate.games.ui.common.CARD_ASPECT
|
||||||
import org.prolibertate.games.ui.common.CardBackView
|
import org.prolibertate.games.ui.common.CardBackView
|
||||||
import org.prolibertate.games.ui.common.PlayingCardView
|
import org.prolibertate.games.ui.common.PlayingCardView
|
||||||
import org.prolibertate.games.ui.theme.FeltGreenDark
|
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||||
@@ -155,11 +166,7 @@ private fun PileView(state: PresidentState, cardWidth: Dp) {
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
PileHeap(state, cardWidth)
|
||||||
state.pile.takeLast(state.setSize).forEach { card ->
|
|
||||||
PlayingCardView(card = card, width = cardWidth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = "Beat it with ${state.setSize} card(s)",
|
text = "Beat it with ${state.setSize} card(s)",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -168,6 +175,120 @@ private fun PileView(state: PresidentState, cardWidth: Dp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How much of what is under the top set is worth drawing. */
|
||||||
|
private const val BURIED_DEPTH = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pile, as a pile.
|
||||||
|
*
|
||||||
|
* Every card played since the pile was last cleared is still lying there, and a
|
||||||
|
* new set is played *onto* them rather than instead of them — which is what the
|
||||||
|
* pile is for. Only the top set has to be readable, since that is the one that
|
||||||
|
* has to be beaten, so it is laid out square and side by side; everything under
|
||||||
|
* it is pushed out towards the edge of the ground the top set covers, so a
|
||||||
|
* corner of it shows.
|
||||||
|
*
|
||||||
|
* The whole pile is drawn in one loop keyed by position, which is what lets a
|
||||||
|
* set slide out of the way as the next one lands on it instead of vanishing
|
||||||
|
* from the row and reappearing under the heap. Each card animates from wherever
|
||||||
|
* it actually is to wherever it now belongs, so being buried needs no
|
||||||
|
* bookkeeping about where it used to sit.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PileHeap(state: PresidentState, cardWidth: Dp) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val cardWidthPx = with(density) { cardWidth.toPx() }
|
||||||
|
val cardHeightPx = cardWidthPx / CARD_ASPECT
|
||||||
|
val gapPx = with(density) { 6.dp.toPx() }
|
||||||
|
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||||
|
|
||||||
|
// Where the cards of the top set sit: centred, side by side.
|
||||||
|
val step = cardWidthPx + gapPx
|
||||||
|
val slotOffset = (state.setSize - 1) / 2f
|
||||||
|
|
||||||
|
// Half the ground the top set covers, which is what the cards underneath
|
||||||
|
// have to get out from under.
|
||||||
|
val halfWidth = (step * state.setSize) / 2f
|
||||||
|
val halfHeight = cardHeightPx * 0.34f
|
||||||
|
|
||||||
|
val lowest = (state.pile.size - state.setSize - BURIED_DEPTH).coerceAtLeast(0)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(
|
||||||
|
width = with(density) { (halfWidth * 2f + cardWidthPx * 0.9f).toDp() },
|
||||||
|
height = with(density) { (cardHeightPx + halfHeight * 2f).toDp() },
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
for (place in lowest until state.pile.size) {
|
||||||
|
key(place) {
|
||||||
|
val card = state.pile[place]
|
||||||
|
val seed = cardSeed(card, place)
|
||||||
|
val depth = state.pile.size - 1 - place
|
||||||
|
val onTop = depth < state.setSize
|
||||||
|
|
||||||
|
val rest = heapRest(seed)
|
||||||
|
val (buriedX, buriedY) = buriedOffset(seed, halfWidth, halfHeight)
|
||||||
|
val (targetX, targetY) = if (onTop) {
|
||||||
|
// Slot 0 is the leftmost card of the set, which is the
|
||||||
|
// deepest of them — they were dealt onto the pile in order.
|
||||||
|
val slot = state.setSize - 1 - depth
|
||||||
|
(slot - slotOffset) * step to 0f
|
||||||
|
} else {
|
||||||
|
buriedX to buriedY
|
||||||
|
}
|
||||||
|
|
||||||
|
val slideSpec = tween<Float>(durationMillis = throwMillis)
|
||||||
|
val x by animateFloatAsState(targetX, slideSpec, label = "pileX")
|
||||||
|
val y by animateFloatAsState(targetY, slideSpec, label = "pileY")
|
||||||
|
// Buried cards are knocked further askew than the tidy set on
|
||||||
|
// top of them, which is the other half of reading as a heap.
|
||||||
|
val tilt by animateFloatAsState(
|
||||||
|
targetValue = if (onTop) rest.tiltDegrees * 0.4f else rest.tiltDegrees,
|
||||||
|
animationSpec = slideSpec,
|
||||||
|
label = "pileTilt",
|
||||||
|
)
|
||||||
|
|
||||||
|
// The throw is relative to wherever the card belongs, so the two
|
||||||
|
// compose: a card lands in its slot, and is later shoved aside
|
||||||
|
// from it without the landing having to know.
|
||||||
|
val (fromX, fromY) = pileOrigin(seed)
|
||||||
|
val landing = rememberCardLanding(
|
||||||
|
key = place,
|
||||||
|
rest = rest,
|
||||||
|
facingDegrees = 0f,
|
||||||
|
fromX = fromX * cardWidthPx,
|
||||||
|
fromY = fromY * cardWidthPx,
|
||||||
|
cardWidthPx = cardWidthPx,
|
||||||
|
durationMillis = throwMillis,
|
||||||
|
// A set leaves the hand together and lands one after
|
||||||
|
// another rather than as a block.
|
||||||
|
delayMillis = (rest.lateness * throwMillis * 0.3f).toInt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
PlayingCardView(
|
||||||
|
card = card,
|
||||||
|
width = cardWidth,
|
||||||
|
elevation = landing.elevation,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset {
|
||||||
|
IntOffset(
|
||||||
|
(x + landing.offsetX).roundToInt(),
|
||||||
|
(y + landing.offsetY).roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = landing.scale
|
||||||
|
scaleY = landing.scale
|
||||||
|
rotationZ = tilt + (landing.rotation - rest.tiltDegrees)
|
||||||
|
alpha = landing.alpha
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Everyone else, as card counts — their hands are never sent to this device. */
|
/** Everyone else, as card counts — their hands are never sent to this device. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun OpponentRow(state: PresidentState, localSeat: Int) {
|
private fun OpponentRow(state: PresidentState, localSeat: Int) {
|
||||||
@@ -258,7 +379,11 @@ private fun HandView(state: PresidentState, localSeat: Int, cardWidth: Dp) {
|
|||||||
Text("—", style = MaterialTheme.typography.bodySmall)
|
Text("—", style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
hand.forEach { card ->
|
hand.forEach { card ->
|
||||||
PlayingCardView(card = card, width = cardWidth * 0.8f)
|
PlayingCardView(
|
||||||
|
card = card,
|
||||||
|
width = cardWidth * 0.8f,
|
||||||
|
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -26,6 +27,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -33,6 +35,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
@@ -190,9 +193,6 @@ private fun BidTally(state: WizardState, localSeat: Int) {
|
|||||||
private fun seatLabel(seat: Int, localSeat: Int): String =
|
private fun seatLabel(seat: Int, localSeat: Int): String =
|
||||||
if (seat == localSeat) "You" else "Seat $seat"
|
if (seat == localSeat) "You" else "Seat $seat"
|
||||||
|
|
||||||
/** Duration of the sweep towards the winner. */
|
|
||||||
private const val SWEEP_MILLIS = 450
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrickArea(
|
private fun TrickArea(
|
||||||
state: WizardState,
|
state: WizardState,
|
||||||
@@ -213,7 +213,7 @@ private fun TrickArea(
|
|||||||
}
|
}
|
||||||
val sweep by animateFloatAsState(
|
val sweep by animateFloatAsState(
|
||||||
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
||||||
animationSpec = tween(durationMillis = SWEEP_MILLIS),
|
animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing),
|
||||||
label = "wizardSweep",
|
label = "wizardSweep",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -226,7 +226,26 @@ private fun TrickArea(
|
|||||||
// Cards sit on a circle that fits inside whatever space the table has.
|
// Cards sit on a circle that fits inside whatever space the table has.
|
||||||
val radius = (minOf(maxWidth, maxHeight) / 2 - cardWidth * 0.7f).coerceAtLeast(0.dp)
|
val radius = (minOf(maxWidth, maxHeight) / 2 - cardWidth * 0.7f).coerceAtLeast(0.dp)
|
||||||
val radiusPx = with(LocalDensity.current) { radius.toPx() }
|
val radiusPx = with(LocalDensity.current) { radius.toPx() }
|
||||||
val travelPx = with(LocalDensity.current) { (cardWidth * 2f).toPx() }
|
val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() }
|
||||||
|
val travelPx = cardWidthPx * 2f
|
||||||
|
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||||
|
|
||||||
|
val arrived = rememberArrivedTricks(
|
||||||
|
tricksWon = state.tricksWon,
|
||||||
|
sweeping = sweeping,
|
||||||
|
sweepStarted = sweepStarted,
|
||||||
|
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
|
||||||
|
)
|
||||||
|
|
||||||
|
// What each player has taken, face down beside their own place at the
|
||||||
|
// ring. Drawn first so a trick being gathered passes over its pile.
|
||||||
|
TakenTricksAround(
|
||||||
|
tricksWon = arrived,
|
||||||
|
localSeat = localSeat,
|
||||||
|
players = players,
|
||||||
|
cardWidth = cardWidth,
|
||||||
|
radiusPx = radiusPx,
|
||||||
|
)
|
||||||
|
|
||||||
if (cardsOnTable.isEmpty()) {
|
if (cardsOnTable.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -241,37 +260,63 @@ private fun TrickArea(
|
|||||||
}
|
}
|
||||||
|
|
||||||
cardsOnTable.forEach { played ->
|
cardsOnTable.forEach { played ->
|
||||||
val relative = ((played.seat - localSeat) % players + players) % players
|
key(played.seat) {
|
||||||
val angle = 2.0 * PI * relative / players
|
val relative = ((played.seat - localSeat) % players + players) % players
|
||||||
// Relative seat 0 is you, at the bottom; the rest run clockwise.
|
val angle = 2.0 * PI * relative / players
|
||||||
val x = -sin(angle) * radiusPx
|
// Relative seat 0 is you, at the bottom; the rest run clockwise.
|
||||||
val y = cos(angle) * radiusPx
|
val x = -sin(angle) * radiusPx
|
||||||
// Turn each card to face whoever played it.
|
val y = cos(angle) * radiusPx
|
||||||
val rotation = (relative * 360f / players)
|
// Turn each card to face whoever played it.
|
||||||
|
val facing = (relative * 360f / players)
|
||||||
|
|
||||||
Column(
|
// A card comes in along the ray it sits on, from further out —
|
||||||
modifier = Modifier
|
// which on a round table is the direction its player is in,
|
||||||
.align(Alignment.Center)
|
// however many of them there are.
|
||||||
.offset {
|
val rest = cardRest(cardSeed(played.card, played.seat))
|
||||||
IntOffset(
|
val landing = rememberCardLanding(
|
||||||
x = (x + -sin(winnerAngle) * travelPx * sweep).roundToInt(),
|
key = played.card,
|
||||||
y = (y + cos(winnerAngle) * travelPx * sweep).roundToInt(),
|
rest = rest,
|
||||||
)
|
facingDegrees = facing,
|
||||||
}
|
fromX = (-sin(angle) * THROW_DISTANCE * cardWidthPx).toFloat(),
|
||||||
.alpha(1f - sweep),
|
fromY = (cos(angle) * THROW_DISTANCE * cardWidthPx).toFloat(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
cardWidthPx = cardWidthPx,
|
||||||
) {
|
durationMillis = throwMillis,
|
||||||
PlayingCardView(
|
|
||||||
card = played.card,
|
|
||||||
width = cardWidth,
|
|
||||||
caption = specialCaption(played.card),
|
|
||||||
modifier = Modifier.rotate(rotation),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = seatLabel(played.seat, localSeat),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
)
|
)
|
||||||
|
val leaving = sweepProgress(sweep, rest)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.offset {
|
||||||
|
IntOffset(
|
||||||
|
x = (x + landing.offsetX +
|
||||||
|
-sin(winnerAngle) * travelPx * leaving).roundToInt(),
|
||||||
|
y = (y + landing.offsetY +
|
||||||
|
cos(winnerAngle) * travelPx * leaving).roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.graphicsLayer {
|
||||||
|
val gathered = landing.scale * gatherScale(leaving)
|
||||||
|
scaleX = gathered
|
||||||
|
scaleY = gathered
|
||||||
|
alpha = landing.alpha * gatherAlpha(leaving)
|
||||||
|
},
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
PlayingCardView(
|
||||||
|
card = played.card,
|
||||||
|
width = cardWidth,
|
||||||
|
caption = specialCaption(played.card),
|
||||||
|
elevation = landing.elevation,
|
||||||
|
modifier = Modifier
|
||||||
|
.rotate(landing.rotation + rest.spinDegrees * leaving),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = seatLabel(played.seat, localSeat),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +440,7 @@ private fun HandRow(
|
|||||||
width = cardWidth,
|
width = cardWidth,
|
||||||
enabled = canPlay,
|
enabled = canPlay,
|
||||||
caption = specialCaption(card),
|
caption = specialCaption(card),
|
||||||
|
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||||
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,39 @@ class KaiserRulesTest {
|
|||||||
assertEquals("all four held for display", 4, state.completedTrick.size)
|
assertEquals("all four held for display", 4, state.completedTrick.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kaiser scores in points, not tricks, so nothing in the rules reads this
|
||||||
|
* count — the table does, to draw the cards each player has taken. That is
|
||||||
|
* exactly why it is worth a test: a field no rule depends on is a field
|
||||||
|
* that can quietly stop being maintained.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `tricks are counted to the seat that took them`() {
|
||||||
|
// Everything is clubs and nothing is trump, so each trick simply goes
|
||||||
|
// to the highest card in it: the ace, then the king.
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.SEVEN, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.JACK, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.QUEEN, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
|
||||||
|
)
|
||||||
|
var state = playingState(hands, trump = Suit.SPADES)
|
||||||
|
assertEquals("nothing taken at the deal", listOf(0, 0, 0, 0), state.tricksWon)
|
||||||
|
|
||||||
|
repeat(4) {
|
||||||
|
val seat = KaiserRules.currentSeat(state)!!
|
||||||
|
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
|
||||||
|
}
|
||||||
|
assertEquals("the ace takes the first", listOf(1, 0, 0, 0), state.tricksWon)
|
||||||
|
|
||||||
|
repeat(4) {
|
||||||
|
val seat = KaiserRules.currentSeat(state)!!
|
||||||
|
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
|
||||||
|
}
|
||||||
|
assertEquals("the king takes the second", listOf(1, 0, 0, 1), state.tricksWon)
|
||||||
|
assertEquals("and they add up", 2, state.tricksWon.sum())
|
||||||
|
}
|
||||||
|
|
||||||
// -- Scoring ------------------------------------------------------------
|
// -- Scoring ------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package org.prolibertate.games.net
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading an address somebody has typed off another phone's screen.
|
||||||
|
*
|
||||||
|
* This is the way into a game when discovery cannot work, which over a phone's
|
||||||
|
* own hotspot is the normal case — so it has to accept what a person will
|
||||||
|
* actually type, including the trailing space the keyboard adds and the port
|
||||||
|
* they will usually leave off.
|
||||||
|
*/
|
||||||
|
class ManualAddressTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bare address gets the usual port`() {
|
||||||
|
assertEquals(
|
||||||
|
ManualAddress("192.168.43.1", DEFAULT_HOST_PORT),
|
||||||
|
parseManualAddress("192.168.43.1"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a port after a colon is used instead`() {
|
||||||
|
assertEquals(
|
||||||
|
ManualAddress("192.168.43.1", 50000),
|
||||||
|
parseManualAddress("192.168.43.1:50000"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typing on a phone puts spaces in places nobody intended. */
|
||||||
|
@Test
|
||||||
|
fun `surrounding space is ignored`() {
|
||||||
|
assertEquals(
|
||||||
|
ManualAddress("192.168.1.20", DEFAULT_HOST_PORT),
|
||||||
|
parseManualAddress(" 192.168.1.20 "),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ManualAddress("192.168.1.20", 47654),
|
||||||
|
parseManualAddress(" 192.168.1.20 : 47654 "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a hostname works as well as an address`() {
|
||||||
|
assertEquals(
|
||||||
|
ManualAddress("pixel-9.local", DEFAULT_HOST_PORT),
|
||||||
|
parseManualAddress("pixel-9.local"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing typed is nothing to connect to`() {
|
||||||
|
assertNull(parseManualAddress(""))
|
||||||
|
assertNull(parseManualAddress(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a port that is not a number is refused rather than guessed`() {
|
||||||
|
assertNull(parseManualAddress("192.168.43.1:door"))
|
||||||
|
assertNull(parseManualAddress("192.168.43.1:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a port outside the range is refused`() {
|
||||||
|
assertNull(parseManualAddress("192.168.43.1:0"))
|
||||||
|
assertNull(parseManualAddress("192.168.43.1:70000"))
|
||||||
|
assertNull(parseManualAddress("192.168.43.1:-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an address with a hole in it is refused`() {
|
||||||
|
assertNull(parseManualAddress(":47654"))
|
||||||
|
assertNull(parseManualAddress("192.168.43.1 20:47654"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default is only a default. A host that found the usual port taken
|
||||||
|
* shows a different one, and that is what gets typed.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the default port is only used when none is given`() {
|
||||||
|
assertEquals(DEFAULT_HOST_PORT, parseManualAddress("10.0.0.5")?.port)
|
||||||
|
assertEquals(1234, parseManualAddress("10.0.0.5:1234")?.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an endpoint reads out as address and port`() {
|
||||||
|
val endpoint = HostEndpoint(listOf("192.168.43.1", "10.0.0.5"), DEFAULT_HOST_PORT)
|
||||||
|
assertEquals("192.168.43.1:$DEFAULT_HOST_PORT", endpoint.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an endpoint with no addresses has nothing to read out`() {
|
||||||
|
assertNull(HostEndpoint(emptyList(), DEFAULT_HOST_PORT).primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the host shows must be what the joiner can type. */
|
||||||
|
@Test
|
||||||
|
fun `what a host shows is what the other end accepts`() {
|
||||||
|
val shown = HostEndpoint(listOf("192.168.43.1"), 47654).primary!!
|
||||||
|
assertEquals(ManualAddress("192.168.43.1", 47654), parseManualAddress(shown))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Choosing which of our own addresses to send from --------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The phone sharing the hotspot has no Wi-Fi network to bind to, so it
|
||||||
|
* picks a source address on the guest's subnet instead. Getting this wrong
|
||||||
|
* sends the connection out over mobile data.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `addresses on one hotspot subnet match`() {
|
||||||
|
assertTrue(sameSubnet("192.168.43.1", "192.168.43.117"))
|
||||||
|
assertTrue(sameSubnet("192.168.1.20", "192.168.1.20"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addresses on different subnets do not`() {
|
||||||
|
assertFalse(sameSubnet("192.168.43.1", "192.168.1.5"))
|
||||||
|
assertFalse(sameSubnet("192.168.43.1", "10.0.0.5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anything that is not a dotted quad is not a match, rather than a crash. */
|
||||||
|
@Test
|
||||||
|
fun `nonsense is not a match`() {
|
||||||
|
assertFalse(sameSubnet("", "192.168.43.1"))
|
||||||
|
assertFalse(sameSubnet("192.168.43", "192.168.43.1"))
|
||||||
|
assertFalse(sameSubnet("pixel-9.local", "192.168.43.1"))
|
||||||
|
assertFalse(sameSubnet("192.168.43.1.7", "192.168.43.1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -228,17 +228,78 @@ class MatchControllerNetworkTest {
|
|||||||
guestLeft.start()
|
guestLeft.start()
|
||||||
guestLeft.leave(Side.GUEST)
|
guestLeft.leave(Side.GUEST)
|
||||||
guestLeft.settle()
|
guestLeft.settle()
|
||||||
assertEquals("Guest left the game.", guestLeft.host.abandoned.value)
|
assertEquals("The connection to Guest was lost.", guestLeft.host.abandoned.value)
|
||||||
|
|
||||||
val hostLeft = chess()
|
val hostLeft = chess()
|
||||||
hostLeft.start()
|
hostLeft.start()
|
||||||
hostLeft.leave(Side.HOST)
|
hostLeft.leave(Side.HOST)
|
||||||
hostLeft.settle()
|
hostLeft.settle()
|
||||||
assertEquals("The host left the game.", hostLeft.client.abandoned.value)
|
assertEquals("The connection to the host was lost.", hostLeft.client.abandoned.value)
|
||||||
// Nothing more can be played on a table with no host.
|
// Nothing more can be played on a table with no host.
|
||||||
assertEquals(emptyList<ChessMove>(), hostLeft.client.legalMoves.value)
|
assertEquals(emptyList<ChessMove>(), hostLeft.client.legalMoves.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Coming back
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bug this is here for: a phone locked when its turn came round could
|
||||||
|
* not play once it was unlocked again.
|
||||||
|
*
|
||||||
|
* A locked phone puts its Wi-Fi radio to sleep, and a link carrying nothing
|
||||||
|
* between one turn and the next is exactly what that drops. The game was
|
||||||
|
* never wrong — the host still had the position and the seat was still the
|
||||||
|
* player's — but the only route to it was gone, and nothing rebuilt it. The
|
||||||
|
* board came back on unlocking with the state it had before the turn
|
||||||
|
* arrived, and every tap on it went nowhere.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a guest whose link died can play again once it is back`() = runBlocking {
|
||||||
|
val table = chess()
|
||||||
|
table.start()
|
||||||
|
table.white("e2", "e4")
|
||||||
|
// Twenty replies to 1. e4 — the guest is on the clock.
|
||||||
|
assertEquals(20, table.client.legalMoves.value.size)
|
||||||
|
|
||||||
|
table.dropGuestLink()
|
||||||
|
assertNotNull("A dead link has to stop the table", table.client.abandoned.value)
|
||||||
|
assertNotNull(table.host.abandoned.value)
|
||||||
|
|
||||||
|
table.reconnectGuest()
|
||||||
|
|
||||||
|
assertNull("The table is playable again and must not say otherwise", table.client.abandoned.value)
|
||||||
|
assertNull(table.host.abandoned.value)
|
||||||
|
assertNotNull("The guest came back to an empty board", table.client.state.value)
|
||||||
|
assertEquals(
|
||||||
|
"The guest is back but has nothing it is allowed to play",
|
||||||
|
20,
|
||||||
|
table.client.legalMoves.value.size,
|
||||||
|
)
|
||||||
|
|
||||||
|
// And a move made on the new link is a move that actually happens.
|
||||||
|
table.black("e7", "e5")
|
||||||
|
assertNotNull(table.host.state.value?.board?.get(squareFromName("e5")))
|
||||||
|
assertNull(table.host.state.value?.board?.get(squareFromName("e7")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A guest coming back must not be treated as a guest arriving. The host end
|
||||||
|
* of the old link is the one that died; anything it has left to say about
|
||||||
|
* itself belongs to it and not to the seat, which is occupied again.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a guest coming back is not reported as having left`() = runBlocking {
|
||||||
|
val table = chess()
|
||||||
|
table.start()
|
||||||
|
table.dropGuestLink()
|
||||||
|
table.reconnectGuest()
|
||||||
|
table.settle()
|
||||||
|
|
||||||
|
assertNull(table.host.abandoned.value)
|
||||||
|
assertNull(table.client.abandoned.value)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `closing a link on purpose is not reported as the other player leaving`() = runBlocking {
|
fun `closing a link on purpose is not reported as the other player leaving`() = runBlocking {
|
||||||
val table = chess()
|
val table = chess()
|
||||||
@@ -273,6 +334,11 @@ class MatchControllerNetworkTest {
|
|||||||
private val hostScope = CoroutineScope(Job() + Dispatchers.Default)
|
private val hostScope = CoroutineScope(Job() + Dispatchers.Default)
|
||||||
private val clientScope = CoroutineScope(Job() + Dispatchers.Default)
|
private val clientScope = CoroutineScope(Job() + Dispatchers.Default)
|
||||||
|
|
||||||
|
/** Every socket a reconnection has opened since, so dispose can close them. */
|
||||||
|
private val laterSockets = mutableListOf<Socket>()
|
||||||
|
private val guestPeerId: String
|
||||||
|
private val guestSeat: Int
|
||||||
|
|
||||||
val hostLink: StreamConnection
|
val hostLink: StreamConnection
|
||||||
private val clientLink: StreamConnection
|
private val clientLink: StreamConnection
|
||||||
val host: MatchController<S, M>
|
val host: MatchController<S, M>
|
||||||
@@ -281,7 +347,8 @@ class MatchControllerNetworkTest {
|
|||||||
init {
|
init {
|
||||||
clientSocket.connect(InetSocketAddress("127.0.0.1", server.localPort), CONNECT_MILLIS)
|
clientSocket.connect(InetSocketAddress("127.0.0.1", server.localPort), CONNECT_MILLIS)
|
||||||
hostSocket = server.accept()
|
hostSocket = server.accept()
|
||||||
val guestPeerId = hostSocket.inetAddress?.hostAddress ?: "guest"
|
guestPeerId = hostSocket.inetAddress?.hostAddress ?: "guest"
|
||||||
|
guestSeat = (0 until seatCount).first { it != hostSeat }
|
||||||
|
|
||||||
hostLink = StreamConnection(
|
hostLink = StreamConnection(
|
||||||
peerId = guestPeerId,
|
peerId = guestPeerId,
|
||||||
@@ -332,11 +399,7 @@ class MatchControllerNetworkTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
host = controller(MatchController.Role.HOST, hostSeat, hostScope)
|
host = controller(MatchController.Role.HOST, hostSeat, hostScope)
|
||||||
client = controller(
|
client = controller(MatchController.Role.CLIENT, guestSeat, clientScope)
|
||||||
MatchController.Role.CLIENT,
|
|
||||||
(0 until seatCount).first { it != hostSeat },
|
|
||||||
clientScope,
|
|
||||||
)
|
|
||||||
|
|
||||||
// The lobby is already reading the guest's link when the game starts,
|
// The lobby is already reading the guest's link when the game starts,
|
||||||
// and goes on reading it for the whole match. Without this the test
|
// and goes on reading it for the whole match. Without this the test
|
||||||
@@ -372,6 +435,54 @@ class MatchControllerNetworkTest {
|
|||||||
settle()
|
settle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guest's link dies under it, as a sleeping radio drops one.
|
||||||
|
*
|
||||||
|
* The sockets are killed rather than the connections closed: a link
|
||||||
|
* either end let go of on purpose is a player leaving, which is a
|
||||||
|
* different thing entirely and is already tested above.
|
||||||
|
*/
|
||||||
|
suspend fun dropGuestLink() {
|
||||||
|
runCatching { hostSocket.close() }
|
||||||
|
runCatching { clientSocket.close() }
|
||||||
|
settle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guest dials the host again, which is still listening on the same
|
||||||
|
* port, and both ends are handed the new link — the host from its lobby
|
||||||
|
* on the guest's Hello, the guest from its own once that has gone
|
||||||
|
* through.
|
||||||
|
*/
|
||||||
|
suspend fun reconnectGuest() {
|
||||||
|
val socket = Socket()
|
||||||
|
socket.connect(InetSocketAddress("127.0.0.1", server.localPort), CONNECT_MILLIS)
|
||||||
|
val accepted = server.accept()
|
||||||
|
laterSockets += socket
|
||||||
|
laterSockets += accepted
|
||||||
|
|
||||||
|
val newHostLink = StreamConnection(
|
||||||
|
peerId = guestPeerId,
|
||||||
|
kind = TransportKind.LAN,
|
||||||
|
input = accepted.getInputStream(),
|
||||||
|
output = accepted.getOutputStream(),
|
||||||
|
scope = hostScope,
|
||||||
|
onClosed = { runCatching { accepted.close() } },
|
||||||
|
)
|
||||||
|
val newClientLink = StreamConnection(
|
||||||
|
peerId = "lan:host",
|
||||||
|
kind = TransportKind.LAN,
|
||||||
|
input = socket.getInputStream(),
|
||||||
|
output = socket.getOutputStream(),
|
||||||
|
scope = clientScope,
|
||||||
|
onClosed = { runCatching { socket.close() } },
|
||||||
|
)
|
||||||
|
|
||||||
|
host.rebindPeer(guestSeat, newHostLink)
|
||||||
|
client.rebindHostLink(newClientLink)
|
||||||
|
settle()
|
||||||
|
}
|
||||||
|
|
||||||
/** Exactly what AppRoot.pop() and PlayScreen's onDispose do, in that order. */
|
/** Exactly what AppRoot.pop() and PlayScreen's onDispose do, in that order. */
|
||||||
fun leave(side: Side) {
|
fun leave(side: Side) {
|
||||||
if (side == Side.HOST) {
|
if (side == Side.HOST) {
|
||||||
@@ -392,6 +503,7 @@ class MatchControllerNetworkTest {
|
|||||||
fun dispose() {
|
fun dispose() {
|
||||||
runCatching { hostSocket.close() }
|
runCatching { hostSocket.close() }
|
||||||
runCatching { clientSocket.close() }
|
runCatching { clientSocket.close() }
|
||||||
|
laterSockets.forEach { runCatching { it.close() } }
|
||||||
runCatching { server.close() }
|
runCatching { server.close() }
|
||||||
runCatching { hostLink.close() }
|
runCatching { hostLink.close() }
|
||||||
runCatching { clientLink.close() }
|
runCatching { clientLink.close() }
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package org.prolibertate.games.net
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.net.Socket
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heartbeat, which exists because a link with nothing on it cannot be told
|
||||||
|
* from a link that is no longer there.
|
||||||
|
*
|
||||||
|
* Between one turn of a card game and the next, nothing at all crosses the
|
||||||
|
* socket. A phone whose screen has gone off puts its Wi-Fi radio into power
|
||||||
|
* save and, given long enough, the link goes with it — and TCP does not say so:
|
||||||
|
* the read simply never returns, and the next write disappears into a socket
|
||||||
|
* that goes nowhere. So the transport talks to itself, and treats a peer that
|
||||||
|
* has stopped answering as gone.
|
||||||
|
*/
|
||||||
|
class StreamConnectionHeartbeatTest {
|
||||||
|
|
||||||
|
private val scopes = mutableListOf<CoroutineScope>()
|
||||||
|
private val closeables = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
closeables.forEach { runCatching { it() } }
|
||||||
|
scopes.forEach { it.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scope(): CoroutineScope =
|
||||||
|
CoroutineScope(Job() + Dispatchers.Default).also { scopes += it }
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The case the whole thing has to get right: two players thinking, nobody
|
||||||
|
* moving, for far longer than a link is allowed to be silent. Both ends
|
||||||
|
* answer each other's pings, so neither concludes the other has gone.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a quiet link between two live peers is left alone`() = runBlocking {
|
||||||
|
val server = ServerSocket(0)
|
||||||
|
val clientSocket = Socket()
|
||||||
|
closeables += { server.close() }
|
||||||
|
closeables += { clientSocket.close() }
|
||||||
|
|
||||||
|
clientSocket.connect(InetSocketAddress("127.0.0.1", server.localPort), CONNECT_MILLIS)
|
||||||
|
val hostSocket = server.accept()
|
||||||
|
closeables += { hostSocket.close() }
|
||||||
|
|
||||||
|
val hostScope = scope()
|
||||||
|
val clientScope = scope()
|
||||||
|
val a = connection("host", hostSocket.getInputStream(), hostSocket.getOutputStream(), hostScope)
|
||||||
|
val b = connection("guest", clientSocket.getInputStream(), clientSocket.getOutputStream(), clientScope)
|
||||||
|
|
||||||
|
val heardByA = subscribe(a, hostScope)
|
||||||
|
val heardByB = subscribe(b, clientScope)
|
||||||
|
|
||||||
|
// Several times the silence allowance, with not one game message sent.
|
||||||
|
delay(SILENCE_MILLIS * 4)
|
||||||
|
|
||||||
|
assertTrue("The host's end gave up on a peer that was answering", a.isOpen)
|
||||||
|
assertTrue("The guest's end gave up on a peer that was answering", b.isOpen)
|
||||||
|
// And the pings are the transport's own business: nothing above it sees
|
||||||
|
// them, so a screen is not asked to redraw twice a second for nothing.
|
||||||
|
assertEquals(emptyList<NetMessage>(), heardByA)
|
||||||
|
assertEquals(emptyList<NetMessage>(), heardByB)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A link that has died without saying so. The reader would wait on it for
|
||||||
|
* as long as the game lasted, which is what left a player tapping a board
|
||||||
|
* that answered nothing.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a peer that stops answering is given up on and reported`() = runBlocking {
|
||||||
|
val scope = scope()
|
||||||
|
val input = BlockingInputStream()
|
||||||
|
closeables += { input.close() }
|
||||||
|
|
||||||
|
val link = connection("gone", input, DiscardingOutputStream(), scope)
|
||||||
|
val heard = subscribe(link, scope)
|
||||||
|
|
||||||
|
// Long enough for the silence to be noticed, and then some.
|
||||||
|
delay(SILENCE_MILLIS * 3)
|
||||||
|
|
||||||
|
assertFalse("A link nobody is answering on was left open", link.isOpen)
|
||||||
|
assertEquals(
|
||||||
|
"The screen was never told the link had gone",
|
||||||
|
listOf<NetMessage>(Bye),
|
||||||
|
heard,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A link this end let go of is not somebody else going away. */
|
||||||
|
@Test
|
||||||
|
fun `closing a link on purpose says nothing`() = runBlocking {
|
||||||
|
val scope = scope()
|
||||||
|
val input = BlockingInputStream()
|
||||||
|
closeables += { input.close() }
|
||||||
|
|
||||||
|
val link = connection("ours", input, DiscardingOutputStream(), scope)
|
||||||
|
val heard = subscribe(link, scope)
|
||||||
|
|
||||||
|
link.close()
|
||||||
|
delay(SILENCE_MILLIS)
|
||||||
|
|
||||||
|
assertFalse(link.isOpen)
|
||||||
|
assertEquals(emptyList<NetMessage>(), heard)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun connection(
|
||||||
|
peerId: String,
|
||||||
|
input: InputStream,
|
||||||
|
output: OutputStream,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
) = StreamConnection(
|
||||||
|
peerId = peerId,
|
||||||
|
kind = TransportKind.LAN,
|
||||||
|
input = input,
|
||||||
|
output = output,
|
||||||
|
scope = scope,
|
||||||
|
heartbeatMillis = HEARTBEAT_MILLIS,
|
||||||
|
silenceMillis = SILENCE_MILLIS,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Everything a screen would have been shown, in order. */
|
||||||
|
private fun subscribe(link: Connection, scope: CoroutineScope): List<NetMessage> {
|
||||||
|
val heard = Collections.synchronizedList(mutableListOf<NetMessage>())
|
||||||
|
scope.launch { link.incoming.collect { heard += it } }
|
||||||
|
return heard
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stream that has nothing to say and never will, which is what a link
|
||||||
|
* whose far end has vanished looks like from here. Blocks until it is
|
||||||
|
* closed, then fails the read the way a closed socket does.
|
||||||
|
*/
|
||||||
|
private class BlockingInputStream : InputStream() {
|
||||||
|
private val gate = CountDownLatch(1)
|
||||||
|
|
||||||
|
override fun read(): Int {
|
||||||
|
gate.await()
|
||||||
|
throw java.io.IOException("stream closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() = gate.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DiscardingOutputStream : OutputStream() {
|
||||||
|
override fun write(b: Int) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CONNECT_MILLIS = 2_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real intervals are seconds; these are the same arrangement run
|
||||||
|
* fast, so the tests take a moment rather than a minute.
|
||||||
|
*/
|
||||||
|
const val HEARTBEAT_MILLIS = 40L
|
||||||
|
const val SILENCE_MILLIS = 200L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,4 +182,13 @@ class ScoreSheetTest {
|
|||||||
assertEquals(0, sheet.totals()[sheet.players[0].id])
|
assertEquals(0, sheet.totals()[sheet.players[0].id])
|
||||||
assertEquals(4, sheet.totals()[sheet.players[1].id])
|
assertEquals(4, sheet.totals()[sheet.players[1].id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zero that was entered is kept, unlike a cell left blank`() {
|
||||||
|
val sheet = sheetOf("Alice", "Bob").let { it.withRound(mapOf(it.players[0].id to 0)) }
|
||||||
|
val round = sheet.rounds.first()
|
||||||
|
assertEquals(0, round.deltas[sheet.players[0].id])
|
||||||
|
assertEquals(null, round.deltas[sheet.players[1].id])
|
||||||
|
assertEquals(0, sheet.total(sheet.players[0].id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.math.abs
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Decks
|
||||||
|
import org.prolibertate.games.game.cards.Rank
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scatter under the card animations.
|
||||||
|
*
|
||||||
|
* Two properties matter and neither is visible in a screenshot. It has to be
|
||||||
|
* *bounded*, because the whole difference between cards on a table and cards
|
||||||
|
* thrown down a flight of stairs is the size of the numbers; and it has to be
|
||||||
|
* *stable*, because a card that picks a new angle on every recomposition
|
||||||
|
* twitches, and a card whose angle depends on where it sits in a list jumps
|
||||||
|
* whenever a card is played next to it.
|
||||||
|
*/
|
||||||
|
class CardPhysicsTest {
|
||||||
|
|
||||||
|
private val deck = Decks.standard52()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `noise stays inside minus one to one`() {
|
||||||
|
for (seed in -500..500) {
|
||||||
|
for (salt in 0..8) {
|
||||||
|
val n = unitNoise(seed, salt)
|
||||||
|
assertTrue("$seed/$salt gave $n", n >= -1f && n < 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `noise is the same every time it is asked`() {
|
||||||
|
assertEquals(unitNoise(12345, 3), unitNoise(12345, 3), 0f)
|
||||||
|
assertEquals(unitNoise(-7, 1), unitNoise(-7, 1), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The salt is what lets one seed produce a tilt, two drifts, a spin and a
|
||||||
|
* queue position that are not all the same number. If it stopped separating
|
||||||
|
* them, every card would drift exactly as far as it tilted.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `salts do not agree with each other`() {
|
||||||
|
val fromOneSeed = (0..8).map { unitNoise(4242, it) }
|
||||||
|
assertEquals(fromOneSeed.size, fromOneSeed.toSet().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sequential seeds are the normal case: adjacent cards in a hand. */
|
||||||
|
@Test
|
||||||
|
fun `neighbouring seeds do not give neighbouring values`() {
|
||||||
|
val runs = (0 until 200).count { abs(unitNoise(it, 1) - unitNoise(it + 1, 1)) < 0.05f }
|
||||||
|
assertTrue("$runs of 200 neighbouring seeds landed together", runs < 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every card in a deck rests within the stated bounds`() {
|
||||||
|
deck.forEach { card ->
|
||||||
|
val rest = cardRest(cardSeed(card))
|
||||||
|
assertTrue(abs(rest.tiltDegrees) <= REST_TILT_DEGREES)
|
||||||
|
assertTrue(abs(rest.driftX) <= REST_DRIFT_FRACTION)
|
||||||
|
assertTrue(abs(rest.driftY) <= REST_DRIFT_FRACTION)
|
||||||
|
assertTrue(abs(rest.spinDegrees) <= THROW_SPIN_DEGREES)
|
||||||
|
assertTrue(rest.lateness in 0f..1f)
|
||||||
|
assertTrue(abs(handTilt(cardSeed(card))) <= HAND_TILT_DEGREES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a card lies the same way however often it is asked`() {
|
||||||
|
val card = Card(Rank.QUEEN, Suit.SPADES)
|
||||||
|
assertEquals(cardRest(cardSeed(card)), cardRest(cardSeed(card)))
|
||||||
|
assertEquals(cardRest(cardSeed(card, 3)), cardRest(cardSeed(card, 3)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two cards lying at the same angle in the same place is the one thing that
|
||||||
|
* makes a scattered pile look printed rather than dealt.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `no two cards in a deck share a seed`() {
|
||||||
|
val seeds = deck.map { cardSeed(it) }
|
||||||
|
assertEquals(seeds.size, seeds.toSet().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pile can hold the same card twice — Golf and Spider deal more than one
|
||||||
|
* deck — so where a card is lying has to be part of its seed.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the same card in two places lies two ways`() {
|
||||||
|
val card = Card(Rank.SEVEN, Suit.HEARTS)
|
||||||
|
assertNotEquals(cardRest(cardSeed(card, 0)), cardRest(cardSeed(card, 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A heap is untidier than a card laid in front of a player, and has to be:
|
||||||
|
* at the tight bounds five cards on a discard pile are hidden to within a
|
||||||
|
* twentieth of a card width of each other and the pile looks like one card.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a heap lies looser than a table but still within bounds`() {
|
||||||
|
deck.forEach { card ->
|
||||||
|
val seed = cardSeed(card)
|
||||||
|
val heap = heapRest(seed)
|
||||||
|
val table = cardRest(seed)
|
||||||
|
assertTrue(abs(heap.tiltDegrees) <= HEAP_TILT_DEGREES)
|
||||||
|
assertTrue(abs(heap.driftX) <= HEAP_DRIFT_FRACTION)
|
||||||
|
assertTrue(abs(heap.driftY) <= HEAP_DRIFT_FRACTION)
|
||||||
|
assertTrue(abs(heap.tiltDegrees) >= abs(table.tiltDegrees))
|
||||||
|
assertTrue(abs(heap.driftX) >= abs(table.driftX))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two have to agree about everything except how far the card strayed,
|
||||||
|
* or a card would jump when it was drawn one way rather than the other.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a heap and a table agree about the throw`() {
|
||||||
|
deck.take(6).forEach { card ->
|
||||||
|
val seed = cardSeed(card)
|
||||||
|
assertEquals(cardRest(seed).spinDegrees, heapRest(seed).spinDegrees, 0f)
|
||||||
|
assertEquals(cardRest(seed).lateness, heapRest(seed).lateness, 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A buried card has to end up near the rim of whatever is covering it. One
|
||||||
|
* left in the middle is one nobody can see, and then drawing the pile as a
|
||||||
|
* pile achieves nothing.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a buried card ends up out where a corner of it shows`() {
|
||||||
|
deck.forEach { card ->
|
||||||
|
val (x, y) = buriedOffset(cardSeed(card), halfWidth = 100f, halfHeight = 40f)
|
||||||
|
val howFarOut = kotlin.math.hypot(x / 100f, y / 40f)
|
||||||
|
assertTrue("$card sat at $howFarOut of the way out", howFarOut >= 0.719f)
|
||||||
|
assertTrue(howFarOut <= 1.001f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two cards buried under the same set must not end up in the same place. */
|
||||||
|
@Test
|
||||||
|
fun `buried cards do not stack on each other`() {
|
||||||
|
val places = (0 until 6).map { buriedOffset(cardSeed(deck[it], it), 100f, 40f) }
|
||||||
|
assertEquals(places.size, places.toSet().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every seat's card arrives from that seat, and from a card-width or two out. */
|
||||||
|
@Test
|
||||||
|
fun `a card comes in from its own player's side`() {
|
||||||
|
assertEquals(0f to THROW_DISTANCE, seatOrigin(0))
|
||||||
|
assertEquals(-THROW_DISTANCE to 0f, seatOrigin(1))
|
||||||
|
assertEquals(0f to -THROW_DISTANCE, seatOrigin(2))
|
||||||
|
assertEquals(THROW_DISTANCE to 0f, seatOrigin(3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pile card comes in from somewhere off the pile`() {
|
||||||
|
deck.forEach { card ->
|
||||||
|
val (x, y) = pileOrigin(cardSeed(card))
|
||||||
|
val distance = kotlin.math.hypot(x, y)
|
||||||
|
assertEquals(THROW_DISTANCE, distance, 0.001f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing has left the table before the sweep starts`() {
|
||||||
|
deck.take(8).forEach { card ->
|
||||||
|
assertEquals(0f, sweepProgress(0f, cardRest(cardSeed(card))), 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `everything has left the table by the end of the sweep`() {
|
||||||
|
deck.take(8).forEach { card ->
|
||||||
|
assertEquals(1f, sweepProgress(1f, cardRest(cardSeed(card))), 0.0001f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a card only ever gets further from the table`() {
|
||||||
|
val rest = cardRest(cardSeed(Card(Rank.ACE, Suit.CLUBS)))
|
||||||
|
var previous = 0f
|
||||||
|
for (step in 0..100) {
|
||||||
|
val now = sweepProgress(step / 100f, rest)
|
||||||
|
assertTrue("went backwards at $step", now >= previous - 0.0001f)
|
||||||
|
assertTrue(now in 0f..1f)
|
||||||
|
previous = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stagger: a trick gathers up as one motion, so the cards must not all
|
||||||
|
* be at the same point in it, and none of them may still be sitting there
|
||||||
|
* when the sweep is nearly over.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `cards leave at different moments but none is left behind`() {
|
||||||
|
val hand = deck.take(4).map { cardRest(cardSeed(it)) }
|
||||||
|
val halfway = hand.map { sweepProgress(0.5f, it) }
|
||||||
|
assertEquals(halfway.size, halfway.toSet().size)
|
||||||
|
hand.forEach { assertTrue(sweepProgress(0.95f, it) > 0.5f) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A gathered card has to still be whole when it reaches the winner's pile,
|
||||||
|
* because it vanishes at the same moment the pile grows by one and that
|
||||||
|
* swap is only invisible if there was something there to swap.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a gathered card is whole most of the way and gone at the end`() {
|
||||||
|
assertEquals(1f, gatherAlpha(0f), 0f)
|
||||||
|
assertEquals(1f, gatherAlpha(0.5f), 0f)
|
||||||
|
assertEquals(0f, gatherAlpha(1f), 0f)
|
||||||
|
var previous = 1f
|
||||||
|
for (step in 0..100) {
|
||||||
|
val now = gatherAlpha(step / 100f)
|
||||||
|
assertTrue("went back up at $step", now <= previous + 0.0001f)
|
||||||
|
assertTrue(now in 0f..1f)
|
||||||
|
previous = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** And is squared down into the bundle rather than arriving full size. */
|
||||||
|
@Test
|
||||||
|
fun `a gathered card is squared down on the way`() {
|
||||||
|
assertEquals(1f, gatherScale(0f), 0f)
|
||||||
|
assertTrue(gatherScale(1f) < 1f)
|
||||||
|
assertTrue(gatherScale(1f) > 0.5f)
|
||||||
|
assertTrue(gatherScale(0.5f) < gatherScale(0.25f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A trick that never completes must not leave a card part way off the table. */
|
||||||
|
@Test
|
||||||
|
fun `a sweep that never starts moves nothing`() {
|
||||||
|
deck.take(4).forEach { card ->
|
||||||
|
assertEquals(0f, sweepProgress(0f, cardRest(cardSeed(card)), spread = 0f), 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -22,4 +22,4 @@ android.nonTransitiveRClass=true
|
|||||||
# Only the series is set by hand. The number after it is the CI run number, so
|
# Only the series is set by hand. The number after it is the CI run number, so
|
||||||
# it keeps climbing on its own and an APK's versionCode never goes backwards
|
# it keeps climbing on its own and an APK's versionCode never goes backwards
|
||||||
# when this changes.
|
# when this changes.
|
||||||
plgVersionSeries=1.2
|
plgVersionSeries=1.4
|
||||||
|
|||||||
Reference in New Issue
Block a user