Initial public release.
133
subapp-koth/README.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# KTag King of the Hill Subapp
|
||||
|
||||
A Jetpack Compose Android application for running King of the Hill games with KTag devices.
|
||||
|
||||
## Overview
|
||||
|
||||
The King of the Hill (KOTH) app manages timed team-based games where teams compete for possession of the "hill." The app scans for KTag devices, tracks which team has more players present, and accumulates time for the team in possession. At game end, the team with more accumulated time wins.
|
||||
|
||||
## Architecture
|
||||
|
||||
The app follows the **MVVM (Model-View-ViewModel)** pattern with Jetpack Compose for the UI layer.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ KothActivity │
|
||||
│ (Compose Host) │
|
||||
└─────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────▼───────────────────────────────────┐
|
||||
│ KothViewModel │
|
||||
│ • State machine (Idle→Initiating→Countdown→Play→End) │
|
||||
│ • Timer management (coroutines) │
|
||||
│ • BLE scanning & advertising │
|
||||
│ • MQTT integration │
|
||||
└─────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────▼───────────────────────────────────┐
|
||||
│ UI Layer │
|
||||
│ KothScreen → GameTimer + StatusDisplay + FABs │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/main/java/club/clubk/ktag/apps/koth/
|
||||
├── KothActivity.kt # Compose activity with permissions
|
||||
├── KothViewModel.kt # State machine, timer, BLE, MQTT
|
||||
├── KothState.kt # Sealed classes for states/events
|
||||
├── GameData.kt # Score tracking data class
|
||||
├── KTagDeviceContact.kt # Device data class
|
||||
├── KothSubApp.kt # Subapp registration
|
||||
├── KothSettingsActivity.kt
|
||||
├── KothInitializer.kt
|
||||
├── HexUtils.java # Hex conversion
|
||||
├── ble/
|
||||
│ └── Packet.java # BLE packet parsing
|
||||
├── mqtt/
|
||||
│ └── KTagMQTTServer.java # MQTT client
|
||||
└── ui/
|
||||
├── KothScreen.kt # Main game screen
|
||||
├── GameTimer.kt # Seven Segment countdown display
|
||||
├── StatusDisplay.kt # Fourteen Segment status text
|
||||
├── GameControlFabs.kt # Play/Stop/Reset FABs
|
||||
└── theme/
|
||||
├── Theme.kt
|
||||
├── Color.kt
|
||||
└── Type.kt # Custom segment fonts
|
||||
```
|
||||
|
||||
## State Machine
|
||||
|
||||
The game progresses through five states:
|
||||
|
||||
```
|
||||
┌──────┐ START ┌────────────┐ TIMEOUT ┌──────────────┐
|
||||
│ IDLE │─────────▶│ INITIATING │──────────▶│ COUNTING_DOWN│
|
||||
└──────┘ └────────────┘ └──────────────┘
|
||||
▲ │
|
||||
│ RESET TIMEOUT│
|
||||
│ ▼
|
||||
┌──────────┐ TIMEOUT/STOP ┌─────────┐
|
||||
│ FINISHED │◀───────-─────----------------───│ PLAYING │
|
||||
└──────────┘ └─────────┘
|
||||
```
|
||||
|
||||
| State | Description |
|
||||
|-------|-------------|
|
||||
| `Idle` | Waiting to start, shows countdown delay |
|
||||
| `Initiating` | Broadcasting game start, countdown to begin |
|
||||
| `CountingDown` | 5-second countdown before play |
|
||||
| `Playing` | Active game, tracking possession |
|
||||
| `Finished` | Game over, showing results |
|
||||
|
||||
## Key Components
|
||||
|
||||
### KothViewModel
|
||||
|
||||
Manages game state and logic:
|
||||
|
||||
- **State Machine**: Transitions between game states via sealed classes
|
||||
- **Timer**: Coroutine-based 100ms tick for accurate timing
|
||||
- **BLE Scanning**: Detects nearby KTag devices and their team
|
||||
- **BLE Advertising**: Broadcasts "Instigating Game" packets
|
||||
- **Possession Tracking**: Determines which team has more players
|
||||
- **MQTT**: Publishes possession updates
|
||||
|
||||
### GameData
|
||||
|
||||
Immutable data class tracking team scores:
|
||||
|
||||
```kotlin
|
||||
data class GameData(
|
||||
val redTeamMillis: Long,
|
||||
val blueTeamMillis: Long
|
||||
)
|
||||
```
|
||||
|
||||
### UI Features
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `GameTimer` | Seven Segment font countdown display |
|
||||
| `StatusDisplay` | Fourteen Segment font for status/scores |
|
||||
| `GameControlFabs` | Animated FABs based on game state |
|
||||
| Background | Animates red/blue/white based on possession |
|
||||
| Tower Images | Crossfade to winner's tower at game end |
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| `countdown_delay_s` | Seconds before countdown starts |
|
||||
| `game_duration_min` | Game length in minutes |
|
||||
| `mqtt_*` | MQTT server configuration |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Jetpack Compose (Material3)
|
||||
- ViewModel + Compose integration
|
||||
- BLE (BluetoothLeScanner, BluetoothLeAdvertiser)
|
||||
- MQTT (Paho Android client)
|
||||
- Custom fonts (Seven Segment, Fourteen Segment)
|
||||
45
subapp-koth/build.gradle.kts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "club.clubk.ktag.apps.koth"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core"))
|
||||
implementation(project(":shared-services"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.startup)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.preference)
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
45
subapp-koth/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Request legacy Bluetooth permissions on older devices. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".KothActivity"
|
||||
android:exported="false"
|
||||
android:label="King of the Hill"
|
||||
android:theme="@style/Theme.AppCompat.Light.DarkActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".KothSettingsActivity"
|
||||
android:exported="false"
|
||||
android:label="Settings"
|
||||
android:parentActivityName=".KothActivity"
|
||||
android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".KothActivity"/>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="club.clubk.ktag.apps.koth.KothInitializer"
|
||||
android:value="androidx.startup" />
|
||||
</provider>
|
||||
|
||||
<service android:name="info.mqtt.android.service.MqttService" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package club.clubk.ktag.apps.koth
|
||||
|
||||
/**
|
||||
* Immutable data class representing the game score data.
|
||||
*/
|
||||
data class GameData(
|
||||
val redTeamMillis: Long = 0L,
|
||||
val blueTeamMillis: Long = 0L
|
||||
) {
|
||||
val isRedAhead: Boolean
|
||||
get() = redTeamMillis > blueTeamMillis
|
||||
|
||||
val isBlueAhead: Boolean
|
||||
get() = blueTeamMillis > redTeamMillis
|
||||
|
||||
val isTie: Boolean
|
||||
get() = redTeamMillis == blueTeamMillis
|
||||
|
||||
fun addRed(milliseconds: Long): GameData =
|
||||
copy(redTeamMillis = redTeamMillis + milliseconds)
|
||||
|
||||
fun addBlue(milliseconds: Long): GameData =
|
||||
copy(blueTeamMillis = blueTeamMillis + milliseconds)
|
||||
|
||||
companion object {
|
||||
fun empty() = GameData()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
package club.clubk.ktag.apps.koth
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import club.clubk.ktag.apps.koth.ui.KothScreen
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothTheme
|
||||
|
||||
private const val TAG = "KTag KotH"
|
||||
|
||||
class KothActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: KothViewModel by viewModels()
|
||||
|
||||
private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_ADVERTISE,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
} else {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH,
|
||||
Manifest.permission.BLUETOOTH_ADMIN,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
}
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
val allGranted = permissions.entries.all { it.value }
|
||||
Log.d(TAG, "Permission results: ${permissions.map { "${it.key}: ${it.value}" }}")
|
||||
|
||||
if (allGranted) {
|
||||
Log.i(TAG, "All permissions granted")
|
||||
showMainContent()
|
||||
} else {
|
||||
val deniedPermissions = permissions.filter { !it.value }.keys
|
||||
Log.w(TAG, "Some permissions denied: $deniedPermissions")
|
||||
showPermissionDeniedContent(deniedPermissions.toList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d(TAG, "onCreate called")
|
||||
|
||||
// Set portrait orientation and keep screen on
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
checkAndRequestPermissions()
|
||||
}
|
||||
|
||||
private fun checkAndRequestPermissions() {
|
||||
Log.d(TAG, "Checking permissions: ${requiredPermissions.joinToString()}")
|
||||
if (hasRequiredPermissions()) {
|
||||
Log.i(TAG, "All required permissions already granted")
|
||||
showMainContent()
|
||||
} else {
|
||||
Log.i(TAG, "Requesting permissions")
|
||||
requestPermissions()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasRequiredPermissions(): Boolean {
|
||||
return requiredPermissions.all { permission ->
|
||||
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestPermissions() {
|
||||
Log.d(TAG, "Launching permission request")
|
||||
permissionLauncher.launch(requiredPermissions)
|
||||
}
|
||||
|
||||
private fun showMainContent() {
|
||||
setContent {
|
||||
KothTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
KothMainScreen(
|
||||
viewModel = viewModel,
|
||||
context = this,
|
||||
onSettingsClick = { openSettings() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPermissionDeniedContent(deniedPermissions: List<String>) {
|
||||
setContent {
|
||||
KothTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
PermissionDeniedScreen(
|
||||
deniedPermissions = deniedPermissions,
|
||||
onRequestPermissions = { requestPermissions() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openSettings() {
|
||||
val intent = KothSettingsActivity.createIntent(this)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (hasRequiredPermissions()) {
|
||||
viewModel.startScanning()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
viewModel.stopScanning()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
viewModel.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun KothMainScreen(
|
||||
viewModel: KothViewModel,
|
||||
context: Context,
|
||||
onSettingsClick: () -> Unit
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
// Check if game is in an active state where settings/back should be blocked
|
||||
val gameState = viewModel.gameState
|
||||
val isGameActive = gameState is KothState.Initiating ||
|
||||
gameState is KothState.CountingDown ||
|
||||
gameState is KothState.Playing
|
||||
|
||||
// Block back button during active game
|
||||
BackHandler(enabled = isGameActive) {
|
||||
// Do nothing - back is disabled during active game
|
||||
}
|
||||
|
||||
// Initialize ViewModel and handle BLE scanning lifecycle
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.initialize(context)
|
||||
viewModel.startScanning()
|
||||
onDispose {
|
||||
viewModel.stopScanning()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.app_name)) },
|
||||
actions = {
|
||||
// Only show menu when game is not active
|
||||
if (!isGameActive) {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Menu")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_settings)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onSettingsClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
KothScreen(
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PermissionDeniedScreen(
|
||||
deniedPermissions: List<String>,
|
||||
onRequestPermissions: () -> Unit
|
||||
) {
|
||||
Log.d(TAG, "Showing permission denied screen for permissions: $deniedPermissions")
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "KTag needs the following permissions to detect devices:",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
deniedPermissions.forEach { permission ->
|
||||
Text(
|
||||
text = "• ${permission.split(".").last()}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
Log.d(TAG, "Permission request button clicked")
|
||||
onRequestPermissions()
|
||||
},
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
) {
|
||||
Text("Grant Permissions")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package club.clubk.ktag.apps.koth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.startup.Initializer
|
||||
import club.clubk.ktag.apps.core.SubAppRegistry
|
||||
|
||||
class KothInitializer : Initializer<Unit> {
|
||||
override fun create(context: Context) {
|
||||
SubAppRegistry.register(KothSubApp())
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package club.clubk.ktag.apps.koth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import club.clubk.ktag.apps.sharedservices.BaseSettingsActivity
|
||||
|
||||
class KothSettingsActivity : BaseSettingsActivity() {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun createIntent(context: Context): Intent {
|
||||
return BaseSettingsActivity.createIntent(context, R.xml.koth_settings_pref, KothSettingsActivity::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package club.clubk.ktag.apps.koth
|
||||
|
||||
/**
|
||||
* Sealed class representing the game states in King of the Hill.
|
||||
*/
|
||||
sealed class KothState {
|
||||
data object Idle : KothState()
|
||||
data object Initiating : KothState()
|
||||
data object CountingDown : KothState()
|
||||
data object Playing : KothState()
|
||||
data object Finished : KothState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed class representing the events that can trigger state transitions.
|
||||
*/
|
||||
sealed class KothEvent {
|
||||
data object Start : KothEvent()
|
||||
data object Timeout : KothEvent()
|
||||
data object Stop : KothEvent()
|
||||
data object Reset : KothEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed class representing which team has possession of the hill.
|
||||
*/
|
||||
sealed class Possession {
|
||||
data object None : Possession()
|
||||
data object Red : Possession()
|
||||
data object Blue : Possession()
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package club.clubk.ktag.apps.koth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import club.clubk.ktag.apps.sharedservices.SettingsSubApp
|
||||
|
||||
class KothSubApp : SettingsSubApp {
|
||||
override val id = "koth"
|
||||
override val name = "King of the Hill"
|
||||
override val icon = R.drawable.ic_koth
|
||||
override val settingsPreferencesResId = R.xml.koth_settings_pref
|
||||
override val usesMqtt = true
|
||||
|
||||
override fun createIntent(context: Context): Intent {
|
||||
return Intent(context, KothActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,454 @@
|
|||
package club.clubk.ktag.apps.koth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.le.AdvertiseData
|
||||
import android.bluetooth.le.AdvertiseSettings
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.media.SoundPool
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import club.clubk.ktag.apps.core.DeviceModel
|
||||
import club.clubk.ktag.apps.core.ble.Packet
|
||||
import club.clubk.ktag.apps.sharedservices.BleViewModel
|
||||
import club.clubk.ktag.apps.sharedservices.DeviceInfo
|
||||
import club.clubk.ktag.apps.sharedservices.DevicePreferenceKeys
|
||||
import club.clubk.ktag.apps.sharedservices.GamePreferenceKeys
|
||||
import club.clubk.ktag.apps.sharedservices.SharedMqttClient
|
||||
import club.clubk.ktag.apps.sharedservices.getIntPref
|
||||
import club.clubk.ktag.apps.sharedservices.getLongPref
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class KothViewModel : BleViewModel() {
|
||||
|
||||
override val logTag = "KTag KotH"
|
||||
|
||||
companion object {
|
||||
private const val TICK_INTERVAL_MS = 100L
|
||||
private const val COUNTDOWN_DURATION_MS = 6000L // 5 seconds + 1 second buffer
|
||||
const val KING_OF_THE_HILL = "King of\nthe Hill"
|
||||
}
|
||||
|
||||
// Game state
|
||||
var gameState by mutableStateOf<KothState>(KothState.Idle)
|
||||
private set
|
||||
|
||||
var gameData by mutableStateOf(GameData.empty())
|
||||
private set
|
||||
|
||||
var possession by mutableStateOf<Possession>(Possession.None)
|
||||
private set
|
||||
|
||||
var timeRemainingMs by mutableStateOf(0L)
|
||||
private set
|
||||
|
||||
var statusText by mutableStateOf(KING_OF_THE_HILL)
|
||||
private set
|
||||
|
||||
var showTimer by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
var showKingOfTheHillImage by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
var endGameResult by mutableStateOf<EndGameResult?>(null)
|
||||
private set
|
||||
|
||||
// Team counts for display
|
||||
var redCount by mutableStateOf(0)
|
||||
private set
|
||||
var blueCount by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
// Device contacts
|
||||
private val _contacts = mutableStateListOf<DeviceModel>()
|
||||
val contacts: List<DeviceModel> get() = _contacts
|
||||
|
||||
// Timer
|
||||
private var timerJob: Job? = null
|
||||
private var timerEndTimeMs = 0L
|
||||
private var lastTickRealtime = 0L
|
||||
private var previousTimeRemainingSeconds = 0L
|
||||
|
||||
// Sound
|
||||
private var soundPool: SoundPool? = null
|
||||
private var blipSoundId: Int = 0
|
||||
|
||||
// KotH-specific advertise settings
|
||||
private var btAdSettings: AdvertiseSettings? = null
|
||||
|
||||
// Preferences
|
||||
private var countdownDelayMs = 30000L
|
||||
private var gameDurationMs = 600000L // 10 minutes
|
||||
private var minimumRssi = -60
|
||||
private var deviceTtlMs = DeviceModel.DEFAULT_TTL_MS
|
||||
|
||||
override fun onStatusPacket(result: ScanResult) {
|
||||
val status = Packet.Status(result)
|
||||
addOrRefreshDevice(
|
||||
bleAddress = result.device.address,
|
||||
rssi = result.rssi,
|
||||
color = status.primary_color,
|
||||
health = status.health,
|
||||
teamId = status.team_ID
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateDeviceInfoFromRepository(infoMap: Map<String, DeviceInfo>) {
|
||||
applyDeviceInfoUpdate(_contacts, infoMap)
|
||||
}
|
||||
|
||||
fun initialize(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
initSound(context)
|
||||
initBluetooth(context)
|
||||
|
||||
// KotH-specific advertise settings
|
||||
btAdSettings = AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
|
||||
.setConnectable(false)
|
||||
.build()
|
||||
|
||||
loadPreferences(context)
|
||||
initMqtt()
|
||||
initDeviceInfo(context)
|
||||
|
||||
timeRemainingMs = countdownDelayMs
|
||||
}
|
||||
|
||||
private fun initMqtt() {
|
||||
try {
|
||||
val battlefield = SharedMqttClient.battlefield
|
||||
SharedMqttClient.publishHello("KTag King of the Hill App", "KotH")
|
||||
SharedMqttClient.subscribe("KTag/$battlefield/KotH/Listen", listenListener)
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to initialize MQTT: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun initSound(context: Context) {
|
||||
soundPool = SoundPool.Builder().setMaxStreams(1).build()
|
||||
blipSoundId = soundPool?.load(context, R.raw.blip, 1) ?: 0
|
||||
}
|
||||
|
||||
private fun loadPreferences(context: Context) {
|
||||
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
countdownDelayMs = sharedPrefs.getLongPref(GamePreferenceKeys.TIME_UNTIL_COUNTDOWN, GamePreferenceKeys.DEFAULT_TIME_UNTIL_COUNTDOWN) * 1000
|
||||
gameDurationMs = sharedPrefs.getLongPref(GamePreferenceKeys.GAME_DURATION, GamePreferenceKeys.DEFAULT_GAME_DURATION) * 60 * 1000
|
||||
minimumRssi = sharedPrefs.getIntPref("koth_min_rssi", "-60")
|
||||
deviceTtlMs = sharedPrefs.getIntPref(DevicePreferenceKeys.DEVICE_TTL, DevicePreferenceKeys.DEFAULT_DEVICE_TTL) * 1000
|
||||
}
|
||||
|
||||
fun processEvent(event: KothEvent) {
|
||||
val nextState = getNextState(gameState, event)
|
||||
if (nextState != null) {
|
||||
Log.d(logTag, "Transitioning from $gameState to $nextState")
|
||||
onExitState(gameState)
|
||||
gameState = nextState
|
||||
onEnterState(nextState)
|
||||
} else {
|
||||
Log.w(logTag, "No valid transition for $gameState with event $event")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNextState(current: KothState, event: KothEvent): KothState? {
|
||||
return when (current) {
|
||||
is KothState.Idle -> when (event) {
|
||||
is KothEvent.Start -> KothState.Initiating
|
||||
else -> null
|
||||
}
|
||||
is KothState.Initiating -> when (event) {
|
||||
is KothEvent.Timeout -> KothState.CountingDown
|
||||
else -> null
|
||||
}
|
||||
is KothState.CountingDown -> when (event) {
|
||||
is KothEvent.Timeout -> KothState.Playing
|
||||
else -> null
|
||||
}
|
||||
is KothState.Playing -> when (event) {
|
||||
is KothEvent.Timeout -> KothState.Finished
|
||||
is KothEvent.Stop -> KothState.Finished
|
||||
else -> null
|
||||
}
|
||||
is KothState.Finished -> when (event) {
|
||||
is KothEvent.Reset -> KothState.Idle
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun onEnterState(state: KothState) {
|
||||
when (state) {
|
||||
is KothState.Idle -> {
|
||||
gameData = GameData.empty()
|
||||
possession = Possession.None
|
||||
timeRemainingMs = countdownDelayMs
|
||||
statusText = KING_OF_THE_HILL
|
||||
showTimer = true
|
||||
showKingOfTheHillImage = true
|
||||
endGameResult = null
|
||||
}
|
||||
is KothState.Initiating -> {
|
||||
timeRemainingMs = countdownDelayMs
|
||||
startTimer()
|
||||
startAdvertisingInstigatingGame()
|
||||
}
|
||||
is KothState.CountingDown -> {
|
||||
timeRemainingMs = COUNTDOWN_DURATION_MS
|
||||
startTimer()
|
||||
}
|
||||
is KothState.Playing -> {
|
||||
timeRemainingMs = gameDurationMs
|
||||
startTimer()
|
||||
stopAdvertising()
|
||||
}
|
||||
is KothState.Finished -> {
|
||||
onGameStop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun onExitState(state: KothState) {
|
||||
when (state) {
|
||||
is KothState.Initiating -> {
|
||||
stopAdvertising()
|
||||
}
|
||||
is KothState.Playing -> {
|
||||
stopTimer()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTimer() {
|
||||
timerJob?.cancel()
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
timerEndTimeMs = now + timeRemainingMs
|
||||
lastTickRealtime = now
|
||||
timerJob = viewModelScope.launch {
|
||||
while (timeRemainingMs > 0) {
|
||||
val nextTickTarget = lastTickRealtime + TICK_INTERVAL_MS
|
||||
val sleepTime = nextTickTarget - SystemClock.elapsedRealtime()
|
||||
if (sleepTime > 0) {
|
||||
delay(sleepTime)
|
||||
}
|
||||
onTick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTimer() {
|
||||
timerJob?.cancel()
|
||||
timerJob = null
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun onTick() {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val actualElapsedMs = now - lastTickRealtime
|
||||
lastTickRealtime = now
|
||||
|
||||
timeRemainingMs = (timerEndTimeMs - now).coerceAtLeast(0)
|
||||
val currentSeconds = timeRemainingMs / 1000
|
||||
|
||||
if (currentSeconds != previousTimeRemainingSeconds) {
|
||||
playSound()
|
||||
previousTimeRemainingSeconds = currentSeconds
|
||||
}
|
||||
|
||||
when (gameState) {
|
||||
is KothState.Initiating -> {
|
||||
updateAdvertisingInstigatingGame()
|
||||
}
|
||||
is KothState.CountingDown -> {
|
||||
val seconds = (timeRemainingMs / 1000).toInt()
|
||||
statusText = "$seconds $seconds $seconds $seconds $seconds"
|
||||
}
|
||||
is KothState.Playing -> {
|
||||
cleanupExpiredContacts(actualElapsedMs.toInt())
|
||||
|
||||
val counts = DeviceModel.getTeamCounts(_contacts.toList())
|
||||
redCount = counts.red
|
||||
blueCount = counts.blue
|
||||
|
||||
possession = when {
|
||||
counts.red > counts.blue -> Possession.Red
|
||||
counts.blue > counts.red -> Possession.Blue
|
||||
else -> Possession.None
|
||||
}
|
||||
|
||||
val battlefield = SharedMqttClient.battlefield
|
||||
val possessionTopic = "KTag/$battlefield/KotH/Possession"
|
||||
gameData = when (possession) {
|
||||
is Possession.Red -> {
|
||||
SharedMqttClient.publish(possessionTopic, "Red has possession.".toByteArray(), qos = 2, retained = false)
|
||||
gameData.addRed(actualElapsedMs)
|
||||
}
|
||||
is Possession.Blue -> {
|
||||
SharedMqttClient.publish(possessionTopic, "Blue has possession.".toByteArray(), qos = 2, retained = false)
|
||||
gameData.addBlue(actualElapsedMs)
|
||||
}
|
||||
is Possession.None -> {
|
||||
SharedMqttClient.publish(possessionTopic, "No one has possession.".toByteArray(), qos = 2, retained = false)
|
||||
gameData
|
||||
}
|
||||
}
|
||||
|
||||
statusText = "Red: $redCount\n\nBlue: $blueCount"
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
if (timeRemainingMs <= 0) {
|
||||
processEvent(KothEvent.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupExpiredContacts(elapsedMs: Int) {
|
||||
val iterator = _contacts.listIterator()
|
||||
while (iterator.hasNext()) {
|
||||
val contact = iterator.next()
|
||||
val updated = contact.withDecrementedTtl(elapsedMs)
|
||||
if (updated.isExpired) {
|
||||
iterator.remove()
|
||||
} else {
|
||||
iterator.set(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGameStop() {
|
||||
showTimer = false
|
||||
showKingOfTheHillImage = false
|
||||
|
||||
endGameResult = when {
|
||||
gameData.isBlueAhead -> EndGameResult.BlueWins(
|
||||
redTime = gameData.redTeamMillis,
|
||||
blueTime = gameData.blueTeamMillis
|
||||
)
|
||||
gameData.isRedAhead -> EndGameResult.RedWins(
|
||||
redTime = gameData.redTeamMillis,
|
||||
blueTime = gameData.blueTeamMillis
|
||||
)
|
||||
else -> EndGameResult.Tie(
|
||||
redTime = gameData.redTeamMillis,
|
||||
blueTime = gameData.blueTeamMillis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun playSound() {
|
||||
soundPool?.play(blipSoundId, 1f, 1f, 1, 0, 1f)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startAdvertisingInstigatingGame() {
|
||||
val instigatingGame = Packet.InstigatingGame(gameDurationMs.toInt(), timeRemainingMs.toInt())
|
||||
val adData = AdvertiseData.Builder()
|
||||
.addManufacturerData(0xFFFF, instigatingGame.GetBytes())
|
||||
.build()
|
||||
btAdvertiser?.startAdvertising(btAdSettings, adData, advertisingCallback)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun updateAdvertisingInstigatingGame() {
|
||||
stopAdvertising()
|
||||
val instigatingGame = Packet.InstigatingGame(gameDurationMs.toInt(), timeRemainingMs.toInt())
|
||||
val adData = AdvertiseData.Builder()
|
||||
.addManufacturerData(0xFFFF, instigatingGame.GetBytes())
|
||||
.build()
|
||||
btAdvertiser?.startAdvertising(btAdSettings, adData, advertisingCallback)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startScanning() {
|
||||
startBleScanning()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun stopScanning() {
|
||||
stopBleScanning()
|
||||
}
|
||||
|
||||
private fun addOrRefreshDevice(
|
||||
bleAddress: String,
|
||||
rssi: Int,
|
||||
color: Int,
|
||||
health: Int,
|
||||
teamId: Byte
|
||||
) {
|
||||
val existingIndex = _contacts.indexOfFirst { it.bleAddress == bleAddress }
|
||||
|
||||
if (existingIndex == -1 && health > 0) {
|
||||
val deviceName = deviceInfoRepository?.getName(bleAddress) ?: "KTag Device"
|
||||
val info = deviceInfoRepository?.getInfo(bleAddress)
|
||||
_contacts.add(0, DeviceModel(
|
||||
name = deviceName,
|
||||
version = if (info != null) String.format("SystemK v%d.%02d", info.majorVersion, info.minorVersion) else "",
|
||||
deviceType = info?.deviceTypeName ?: "",
|
||||
id = 1,
|
||||
image = club.clubk.ktag.apps.core.R.drawable.ktag_shield,
|
||||
bleAddress = bleAddress,
|
||||
rssi = rssi,
|
||||
color = Color(color),
|
||||
teamId = teamId
|
||||
))
|
||||
} else if (existingIndex >= 0) {
|
||||
if (health > 0) {
|
||||
_contacts[existingIndex] = _contacts[existingIndex].withResetTtl(deviceTtlMs)
|
||||
}
|
||||
// If health <= 0, let TTL expire naturally
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
stopScanning()
|
||||
stopAdvertising()
|
||||
stopTimer()
|
||||
try {
|
||||
val battlefield = SharedMqttClient.battlefield
|
||||
SharedMqttClient.unsubscribe("KTag/$battlefield/KotH/Listen", listenListener)
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Error unsubscribing: ${e.message}")
|
||||
}
|
||||
cleanupDeviceInfo()
|
||||
soundPool?.release()
|
||||
soundPool = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
fun formatTimeDisplay(millis: Long): String {
|
||||
val totalSeconds = millis / 1000
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return String.format("%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed class for end game results.
|
||||
*/
|
||||
sealed class EndGameResult {
|
||||
abstract val redTime: Long
|
||||
abstract val blueTime: Long
|
||||
|
||||
data class RedWins(override val redTime: Long, override val blueTime: Long) : EndGameResult()
|
||||
data class BlueWins(override val redTime: Long, override val blueTime: Long) : EndGameResult()
|
||||
data class Tie(override val redTime: Long, override val blueTime: Long) : EndGameResult()
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package club.clubk.ktag.apps.koth.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import club.clubk.ktag.apps.koth.KothEvent
|
||||
import club.clubk.ktag.apps.koth.KothState
|
||||
import club.clubk.ktag.apps.koth.R
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothBlack
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothGreen
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothRed
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothWhite
|
||||
|
||||
@Composable
|
||||
fun GameControlFabs(
|
||||
gameState: KothState,
|
||||
onEvent: (KothEvent) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.padding(16.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
// Play button - visible in Idle state
|
||||
AnimatedVisibility(
|
||||
visible = gameState is KothState.Idle,
|
||||
enter = fadeIn() + scaleIn(),
|
||||
exit = fadeOut() + scaleOut()
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = { onEvent(KothEvent.Start) },
|
||||
containerColor = KothBlack
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_play),
|
||||
contentDescription = "Play",
|
||||
tint = KothGreen
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop button - visible in CountingDown and Playing states
|
||||
AnimatedVisibility(
|
||||
visible = gameState is KothState.CountingDown || gameState is KothState.Playing,
|
||||
enter = fadeIn() + scaleIn(),
|
||||
exit = fadeOut() + scaleOut()
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = { onEvent(KothEvent.Stop) },
|
||||
containerColor = KothBlack
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_stop),
|
||||
contentDescription = "Stop",
|
||||
tint = KothRed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset button - visible in Finished state
|
||||
AnimatedVisibility(
|
||||
visible = gameState is KothState.Finished,
|
||||
enter = fadeIn() + scaleIn(),
|
||||
exit = fadeOut() + scaleOut()
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = { onEvent(KothEvent.Reset) },
|
||||
containerColor = KothBlack
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_reset),
|
||||
contentDescription = "Reset",
|
||||
tint = KothWhite
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package club.clubk.ktag.apps.koth.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothBlack
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothGreen
|
||||
import club.clubk.ktag.apps.koth.ui.theme.SevenSegmentFont
|
||||
|
||||
@Composable
|
||||
fun GameTimer(
|
||||
timeDisplay: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = timeDisplay,
|
||||
fontFamily = SevenSegmentFont,
|
||||
fontSize = 60.sp,
|
||||
color = KothGreen,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(KothBlack.copy(alpha = 0.9f))
|
||||
.padding(20.dp)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
package club.clubk.ktag.apps.koth.ui
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import club.clubk.ktag.apps.koth.EndGameResult
|
||||
import club.clubk.ktag.apps.koth.KothEvent
|
||||
import club.clubk.ktag.apps.koth.KothState
|
||||
import club.clubk.ktag.apps.koth.KothViewModel
|
||||
import club.clubk.ktag.apps.koth.Possession
|
||||
import club.clubk.ktag.apps.koth.R
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothBlue
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothRed
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothWhite
|
||||
|
||||
@Composable
|
||||
fun KothScreen(
|
||||
viewModel: KothViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val gameState = viewModel.gameState
|
||||
val possession = viewModel.possession
|
||||
val timeRemainingMs = viewModel.timeRemainingMs
|
||||
val statusText = viewModel.statusText
|
||||
val showTimer = viewModel.showTimer
|
||||
val showKingOfTheHillImage = viewModel.showKingOfTheHillImage
|
||||
val endGameResult = viewModel.endGameResult
|
||||
|
||||
// Animate background color based on possession
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
gameState is KothState.Finished -> {
|
||||
when (endGameResult) {
|
||||
is EndGameResult.RedWins -> KothRed
|
||||
is EndGameResult.BlueWins -> KothBlue
|
||||
else -> KothWhite
|
||||
}
|
||||
}
|
||||
gameState is KothState.Playing -> {
|
||||
when (possession) {
|
||||
is Possession.Red -> KothRed
|
||||
is Possession.Blue -> KothBlue
|
||||
else -> KothWhite
|
||||
}
|
||||
}
|
||||
else -> KothWhite
|
||||
},
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "backgroundColor"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundColor)
|
||||
) {
|
||||
// End game tower image with crossfade
|
||||
if (gameState is KothState.Finished && endGameResult != null) {
|
||||
Crossfade(
|
||||
targetState = endGameResult,
|
||||
animationSpec = tween(durationMillis = 500),
|
||||
label = "towerImage"
|
||||
) { result ->
|
||||
val towerResId = when (result) {
|
||||
is EndGameResult.RedWins -> R.drawable.red_tower
|
||||
is EndGameResult.BlueWins -> R.drawable.blue_tower
|
||||
is EndGameResult.Tie -> R.drawable.white_tower
|
||||
}
|
||||
Image(
|
||||
painter = painterResource(id = towerResId),
|
||||
contentDescription = "Tower",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// King of the Hill image (visible when not finished)
|
||||
if (showKingOfTheHillImage) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.king_of_the_hill),
|
||||
contentDescription = "King of the Hill",
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Main content
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = if (gameState is KothState.Finished) Arrangement.Center else Arrangement.Top
|
||||
) {
|
||||
// Timer display
|
||||
if (showTimer) {
|
||||
GameTimer(
|
||||
timeDisplay = viewModel.formatTimeDisplay(timeRemainingMs),
|
||||
modifier = Modifier.padding(top = 48.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Status display
|
||||
if (gameState is KothState.Finished && endGameResult != null) {
|
||||
EndGameStatusDisplay(
|
||||
resultText = when (endGameResult) {
|
||||
is EndGameResult.RedWins -> stringResource(R.string.red_wins)
|
||||
is EndGameResult.BlueWins -> stringResource(R.string.blue_wins)
|
||||
is EndGameResult.Tie -> stringResource(R.string.its_a_tie)
|
||||
},
|
||||
redTimeText = viewModel.formatTimeDisplay(endGameResult.redTime),
|
||||
blueTimeText = viewModel.formatTimeDisplay(endGameResult.blueTime)
|
||||
)
|
||||
} else {
|
||||
StatusDisplay(
|
||||
statusText = statusText,
|
||||
gameState = gameState,
|
||||
modifier = Modifier.padding(top = 48.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// FAB controls
|
||||
GameControlFabs(
|
||||
gameState = gameState,
|
||||
onEvent = { viewModel.processEvent(it) },
|
||||
modifier = Modifier.align(Alignment.BottomEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package club.clubk.ktag.apps.koth.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import club.clubk.ktag.apps.koth.KothState
|
||||
import club.clubk.ktag.apps.koth.ui.theme.FourteenSegmentFont
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothBlack
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothBlue
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothGreen
|
||||
import club.clubk.ktag.apps.koth.ui.theme.KothRed
|
||||
|
||||
@Composable
|
||||
fun StatusDisplay(
|
||||
statusText: String,
|
||||
gameState: KothState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val displayText = when (gameState) {
|
||||
is KothState.Playing -> buildPlayingStatusText(statusText)
|
||||
else -> buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = KothGreen)) {
|
||||
append(statusText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = displayText,
|
||||
fontFamily = FourteenSegmentFont,
|
||||
fontSize = 36.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 44.sp,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(KothBlack.copy(alpha = 0.9f))
|
||||
.padding(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildPlayingStatusText(statusText: String): AnnotatedString {
|
||||
return buildAnnotatedString {
|
||||
// Parse "Red: X\n\nBlue: Y" format
|
||||
val lines = statusText.split("\n\n")
|
||||
if (lines.size == 2) {
|
||||
withStyle(SpanStyle(color = KothRed)) {
|
||||
append(lines[0])
|
||||
}
|
||||
append("\n\n")
|
||||
withStyle(SpanStyle(color = KothBlue)) {
|
||||
append(lines[1])
|
||||
}
|
||||
} else {
|
||||
withStyle(SpanStyle(color = KothGreen)) {
|
||||
append(statusText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EndGameStatusDisplay(
|
||||
resultText: String,
|
||||
redTimeText: String,
|
||||
blueTimeText: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val displayText = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = KothGreen)) {
|
||||
append(resultText)
|
||||
append("\n")
|
||||
}
|
||||
withStyle(SpanStyle(color = KothRed)) {
|
||||
append(redTimeText)
|
||||
append("\n")
|
||||
}
|
||||
withStyle(SpanStyle(color = KothBlue)) {
|
||||
append(blueTimeText)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = displayText,
|
||||
fontFamily = FourteenSegmentFont,
|
||||
fontSize = 36.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 44.sp,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(KothBlack.copy(alpha = 0.9f))
|
||||
.padding(20.dp)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package club.clubk.ktag.apps.koth.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagBlue
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagDarkGray
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagRed
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
|
||||
|
||||
// Game color aliases for clarity in game-specific code
|
||||
val KothRed = KTagRed
|
||||
val KothBlue = KTagBlue
|
||||
val KothGreen = KTagGreen
|
||||
val KothYellow = KTagYellow
|
||||
val KothWhite = Color(0xFFFFFFFF)
|
||||
val KothBlack = KTagDarkGray
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package club.clubk.ktag.apps.koth.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagDarkGray
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
|
||||
import club.clubk.ktag.apps.core.ui.theme.Typography
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = KTagYellow,
|
||||
onPrimary = KTagDarkGray,
|
||||
secondary = KTagGreen,
|
||||
onSecondary = KTagDarkGray,
|
||||
tertiary = KTagPurple
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = KTagYellow,
|
||||
onPrimary = KTagDarkGray,
|
||||
secondary = KTagGreen,
|
||||
onSecondary = KTagDarkGray,
|
||||
tertiary = KTagPurple
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun KothTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package club.clubk.ktag.apps.koth.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import club.clubk.ktag.apps.koth.R
|
||||
|
||||
// Custom fonts for the game display
|
||||
val SevenSegmentFont = FontFamily(
|
||||
Font(R.font.seven_segment)
|
||||
)
|
||||
|
||||
val FourteenSegmentFont = FontFamily(
|
||||
Font(R.font.fourteen_segment)
|
||||
)
|
||||
BIN
subapp-koth/src/main/res/drawable/blue_tower.jpg
Normal file
|
After Width: | Height: | Size: 790 KiB |
BIN
subapp-koth/src/main/res/drawable/ic_koth.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
subapp-koth/src/main/res/drawable/ic_play.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
subapp-koth/src/main/res/drawable/ic_reset.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
subapp-koth/src/main/res/drawable/ic_stop.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
subapp-koth/src/main/res/drawable/king_of_the_hill.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
subapp-koth/src/main/res/drawable/red_tower.jpg
Normal file
|
After Width: | Height: | Size: 802 KiB |
5
subapp-koth/src/main/res/drawable/rounded_corner.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/black" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
BIN
subapp-koth/src/main/res/drawable/white_tower.jpg
Normal file
|
After Width: | Height: | Size: 886 KiB |
BIN
subapp-koth/src/main/res/font/fourteen_segment.ttf
Normal file
BIN
subapp-koth/src/main/res/font/seven_segment.ttf
Normal file
BIN
subapp-koth/src/main/res/raw/blip.wav
Executable file
3
subapp-koth/src/main/res/values/arrays.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
</resources>
|
||||
16
subapp-koth/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- KTag brand colors -->
|
||||
<color name="ktag_green">#4BA838</color>
|
||||
<color name="ktag_blue">#4D6CFA</color>
|
||||
<color name="ktag_red">#F34213</color>
|
||||
<color name="ktag_yellow">#FFC857</color>
|
||||
<color name="ktag_dark_gray">#323031</color>
|
||||
|
||||
<!-- Legacy names (mapped to KTag colors) -->
|
||||
<color name="black">#323031</color>
|
||||
<color name="white">#FFFFFF</color>
|
||||
<color name="red">#F34213</color>
|
||||
<color name="green">#4BA838</color>
|
||||
<color name="blue">#4D6CFA</color>
|
||||
</resources>
|
||||
5
subapp-koth/src/main/res/values/dimens.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<resources>
|
||||
<!-- Default screen margins, per the Android Design guidelines. -->
|
||||
<dimen name="activity_horizontal_margin">16dp</dimen>
|
||||
<dimen name="activity_vertical_margin">16dp</dimen>
|
||||
</resources>
|
||||
9
subapp-koth/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<resources>
|
||||
<string name="app_name">King of the Hill</string>
|
||||
<string name="action_settings">Settings</string>
|
||||
<string name="blue_wins">Blue Wins</string>
|
||||
<string name="red_wins">Red Wins</string>
|
||||
<string name="its_a_tie">Tie</string>
|
||||
<string name="set">set</string>
|
||||
<string name="cancel">cancel</string>
|
||||
</resources>
|
||||
33
subapp-koth/src/main/res/xml/koth_settings_pref.xml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<PreferenceCategory android:title="Game Settings">
|
||||
|
||||
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
|
||||
app:customHint="Length of a game in minutes."
|
||||
android:inputType="time"
|
||||
android:key="game_duration_min"
|
||||
android:summary="%s:00 minutes"
|
||||
android:title="Game Duration" />
|
||||
|
||||
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
|
||||
app:customHint="Time until countdown begins in seconds."
|
||||
android:inputType="time"
|
||||
android:key="countdown_delay_s"
|
||||
android:summary="%s seconds"
|
||||
android:title="Time Until Countdown" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceCategory android:title="Device Detection">
|
||||
|
||||
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
|
||||
app:customHint='"-120" (far) to "0" (nearby); try "-60"'
|
||||
android:inputType="numberSigned"
|
||||
android:key="koth_min_rssi"
|
||||
android:summary="%s dBm"
|
||||
android:title="Minimum RSSI (dBm) for Device Detection" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
</PreferenceScreen>
|
||||