Add roll values to the new UI

This commit is contained in:
Thomas Andres Gomez 2025-02-21 14:53:52 +01:00
parent 6385d4c8bd
commit 3c8eecdab5
16 changed files with 262 additions and 53 deletions

View file

@ -8,3 +8,6 @@
# Kotlinx coroutines rules seems to be outdated with the latest version of Kotlin and Proguard # Kotlinx coroutines rules seems to be outdated with the latest version of Kotlin and Proguard
-keep class kotlinx.coroutines.** { *; } -keep class kotlinx.coroutines.** { *; }
# OkHttp comming from COIL.
-dontwarn okhttp3.internal.platform.**

View file

@ -41,6 +41,7 @@ import com.pixelized.desktop.lwa.ui.navigation.window.destination.RollHistoryWin
import com.pixelized.desktop.lwa.ui.navigation.window.rememberMaxWindowHeight import com.pixelized.desktop.lwa.ui.navigation.window.rememberMaxWindowHeight
import com.pixelized.desktop.lwa.repository.network.NetworkRepository import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository.Status import com.pixelized.desktop.lwa.repository.network.NetworkRepository.Status
import com.pixelized.desktop.lwa.ui.navigation.screen.MainNavHost
import com.pixelized.desktop.lwa.ui.screen.campaign.player.CampaignScreen import com.pixelized.desktop.lwa.ui.screen.campaign.player.CampaignScreen
import com.pixelized.desktop.lwa.ui.screen.characterSheet.CharacterSheetMainNavHost import com.pixelized.desktop.lwa.ui.screen.characterSheet.CharacterSheetMainNavHost
import com.pixelized.desktop.lwa.ui.screen.rollhistory.RollHistoryPage import com.pixelized.desktop.lwa.ui.screen.rollhistory.RollHistoryPage

View file

@ -10,7 +10,7 @@ import com.pixelized.desktop.lwa.utils.extention.decodeFromFrame
import com.pixelized.desktop.lwa.utils.extention.encodeToFrame import com.pixelized.desktop.lwa.utils.extention.encodeToFrame
import com.pixelized.server.lwa.SERVER_PORT import com.pixelized.server.lwa.SERVER_PORT
import com.pixelized.server.lwa.protocol.Message import com.pixelized.server.lwa.protocol.Message
import com.pixelized.server.lwa.protocol.MessageContent import com.pixelized.server.lwa.protocol.MessageType
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.websocket.Frame import io.ktor.websocket.Frame
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -99,11 +99,13 @@ class NetworkRepository(
suspend fun share( suspend fun share(
playerName: String = settingsRepository.settings().playerName, playerName: String = settingsRepository.settings().playerName,
content: MessageContent, type: MessageType,
content: String,
) { ) {
if (status.value == Status.CONNECTED) { if (status.value == Status.CONNECTED) {
val message = Message( val message = Message(
from = playerName, from = playerName,
type = type,
value = content, value = content,
) )
// emit the message into the outgoing buffer // emit the message into the outgoing buffer

View file

@ -1,23 +1,28 @@
package com.pixelized.desktop.lwa.repository.roll_history package com.pixelized.desktop.lwa.repository.roll_history
import com.pixelized.desktop.lwa.repository.network.NetworkRepository import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.server.lwa.protocol.Message import com.pixelized.server.lwa.protocol.MessageType
import com.pixelized.server.lwa.protocol.RollMessage import com.pixelized.server.lwa.protocol.roll.RollMessage
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class RollHistoryRepository( class RollHistoryRepository(
private val network: NetworkRepository, private val network: NetworkRepository,
private val jsonFormatter: Json,
) { ) {
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
val rolls: SharedFlow<Message> = network.data val rolls: SharedFlow<RollMessage> = network.data
.mapNotNull { it.takeIf { it.value is RollMessage } } .mapNotNull { it.takeIf { it.type == MessageType.Roll } }
.map { jsonFormatter.decodeFromString<RollMessage>(it.value) }
.shareIn( .shareIn(
scope = scope, scope = scope,
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
@ -32,20 +37,24 @@ class RollHistoryRepository(
} }
suspend fun share( suspend fun share(
characterId: String,
skillLabel: String, skillLabel: String,
rollDifficulty: String?, rollDifficulty: String?,
rollValue: Int, rollValue: Int,
resultLabel: String?, resultLabel: String?,
rollSuccessLimit: Int?, rollSuccessLimit: Int?,
) { ) {
network.share( val content = RollMessage(
content = RollMessage( characterId = characterId,
skillLabel = skillLabel, skillLabel = skillLabel,
rollDifficulty = rollDifficulty, rollDifficulty = rollDifficulty,
rollValue = rollValue, rollValue = rollValue,
resultLabel = resultLabel, resultLabel = resultLabel,
rollSuccessLimit = rollSuccessLimit, rollSuccessLimit = rollSuccessLimit,
) )
network.share(
type = MessageType.Roll,
content = jsonFormatter.encodeToString(content),
) )
} }
} }

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -30,13 +31,19 @@ import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetai
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDiminishedViewModel import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDiminishedViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.PlayerRibbon import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.PlayerRibbon
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.DiminishedStatDialog import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.DiminishedStatDialog
import com.pixelized.desktop.lwa.ui.screen.network.NetworkViewModel
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun CampaignScreen( fun CampaignScreen(
characterDetailViewModel: CharacterDetailViewModel = koinViewModel(), characterDetailViewModel: CharacterDetailViewModel = koinViewModel(),
dismissedViewModel: CharacterDiminishedViewModel = koinViewModel(), dismissedViewModel: CharacterDiminishedViewModel = koinViewModel(),
networkViewModel: NetworkViewModel = koinViewModel(),
) { ) {
LaunchedEffect(Unit) {
networkViewModel.connect()
}
KeyHandler { KeyHandler {
when { when {
it.type == KeyEventType.KeyUp && it.key == Key.Escape -> { it.type == KeyEventType.KeyUp && it.key == Key.Escape -> {

View file

@ -0,0 +1,129 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.ic_d20_24dp
import org.jetbrains.compose.resources.painterResource
@Stable
data class PlayerPortraitRollAnimation(
val alpha: Animatable<Float, AnimationVector1D> = Animatable(0f),
val rotation: Animatable<Float, AnimationVector1D> = Animatable(0f),
val scale: Animatable<Float, AnimationVector1D> = Animatable(1f),
)
@Composable
fun PlayerPortraitRoll(
modifier: Modifier = Modifier,
value: Int?,
) {
AnimatedContent(
modifier = modifier.graphicsLayer { clip = false },
targetState = value,
transitionSpec = {
val enter = fadeIn()
val exit = fadeOut()
enter togetherWith exit using SizeTransform(clip = false)
}
) {
val animation = diceIconAnimation(key = it ?: Unit)
Box(
modifier = Modifier.graphicsLayer {
this.scaleX = animation.scale.value
this.scaleY = animation.scale.value
},
contentAlignment = Alignment.Center,
) {
when (it) {
null -> Unit
else -> {
Icon(
modifier = Modifier
.graphicsLayer {
this.alpha = 0.8f
this.rotationZ = animation.rotation.value
}
.size(48.dp),
painter = painterResource(Res.drawable.ic_d20_24dp),
tint = MaterialTheme.colors.primary,
contentDescription = null,
)
Text(
style = MaterialTheme.typography.h5.copy(
shadow = Shadow(
color = MaterialTheme.colors.surface,
offset = Offset.Zero,
blurRadius = 8f,
)
),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onSurface,
text = it.toString()
)
}
}
}
}
}
@Composable
private fun diceIconAnimation(key: Any = Unit): PlayerPortraitRollAnimation {
val animation = remember(key) {
PlayerPortraitRollAnimation()
}
LaunchedEffect(key) {
launch {
animation.scale.animateTo(
targetValue = 1.20f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = 800f,
)
)
animation.scale.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = 0.28f,
stiffness = 800f,
)
)
}
launch {
animation.rotation.animateTo(
targetValue = 360f * 3,
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessLow,
)
)
}
}
return animation
}

View file

@ -1,6 +1,8 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -25,10 +27,19 @@ fun PlayerRibbon(
items = characters.value, items = characters.value,
key = { it.id }, key = { it.id },
) { ) {
Row {
PlayerPortrait( PlayerPortrait(
character = it, character = it,
onCharacter = onCharacter, onCharacter = onCharacter,
) )
PlayerPortraitRoll(
modifier = Modifier.size(
width = 64.dp,
height = PlayerPortrait.Default.size.height
),
value = playerRibbonViewModel.roll(characterId = it.id).value,
)
}
} }
} }
} }

View file

@ -1,17 +1,26 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
class PlayerRibbonViewModel( class PlayerRibbonViewModel(
repository: CharacterSheetRepository, private val rollHistoryRepository: RollHistoryRepository,
characterRepository: CharacterSheetRepository,
) : ViewModel() { ) : ViewModel() {
val characters: StateFlow<List<PlayerPortraitUio>> = characterRepository.characterSheetFlow()
val characters = repository.characterSheetFlow()
.map { sheets -> .map { sheets ->
sheets.map { sheet -> sheets.map { sheet ->
PlayerPortraitUio( PlayerPortraitUio(
@ -28,4 +37,31 @@ class PlayerRibbonViewModel(
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
initialValue = emptyList() initialValue = emptyList()
) )
private val _rolls: HashMap<String, Int?> = hashMapOf()
val rolls: StateFlow<Map<String, Int?>> = rollHistoryRepository.rolls
.map {
_rolls[it.characterId] = it.rollValue
_rolls
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyMap()
)
@Composable
@Stable
fun roll(characterId: String): State<Int?> {
val state = rememberSaveable(characterId) {
mutableStateOf<Int?>(null)
}
LaunchedEffect(characterId) {
rollHistoryRepository.rolls.collect {
if (it.characterId == characterId) {
state.value = it.rollValue
}
}
}
return state
}
} }

View file

@ -95,13 +95,16 @@ fun RollPage(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
KeyHandler { KeyHandler {
if (it.type == KeyEventType.KeyUp && it.key == Key.Escape) { when {
it.type == KeyEventType.KeyUp && it.key == Key.Escape -> {
onDismissRequest() onDismissRequest()
true true
} else { }
else -> {
false false
} }
} }
}
Column( Column(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()

View file

@ -192,6 +192,7 @@ class RollViewModel(
) )
launch { launch {
rollHistoryRepository.share( rollHistoryRepository.share(
characterId = sheet.id,
skillLabel = _rollTitle.value.label, skillLabel = _rollTitle.value.label,
rollDifficulty = when (_rollDifficulty.value?.difficulty) { rollDifficulty = when (_rollDifficulty.value?.difficulty) {
Difficulty.EASY -> getString(Res.string.roll_page__dc_easy__label) Difficulty.EASY -> getString(Res.string.roll_page__dc_easy__label)

View file

@ -20,7 +20,7 @@ import org.jetbrains.compose.resources.stringResource
@Stable @Stable
data class RollHistoryItemUio( data class RollHistoryItemUio(
val from: String, val character: String,
val skillLabel: String, val skillLabel: String,
val rollDifficulty: String?, val rollDifficulty: String?,
val rollValue: Int, val rollValue: Int,
@ -50,7 +50,7 @@ fun RollHistoryItem(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
maxLines = 1, maxLines = 1,
text = roll.from, text = roll.character,
) )
Text( Text(
modifier = Modifier.alignByBaseline(), modifier = Modifier.alignByBaseline(),

View file

@ -4,12 +4,15 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.pixelized.server.lwa.protocol.RollMessage import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.characterSheet.model.CharacterSheet
import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class RollHistoryViewModel( class RollHistoryViewModel(
private val repository: RollHistoryRepository private val characterRepository: CharacterSheetRepository,
private val rollRepository: RollHistoryRepository,
) : ViewModel() { ) : ViewModel() {
private val _rolls = mutableStateOf((emptyList<RollHistoryItemUio>())) private val _rolls = mutableStateOf((emptyList<RollHistoryItemUio>()))
@ -17,13 +20,15 @@ class RollHistoryViewModel(
init { init {
viewModelScope.launch { viewModelScope.launch {
repository.rolls.collect { combine(
(it.value as? RollMessage)?.let { content -> characterRepository.characterSheetFlow(),
_rolls.value = _rolls.value.toMutableList().apply { rollRepository.rolls,
) { sheets: List<CharacterSheet>, content ->
_rolls.value.toMutableList().apply {
add( add(
index = 0, index = 0,
element = RollHistoryItemUio( element = RollHistoryItemUio(
from = it.from, character = sheets.firstOrNull { it.id == content.characterId }?.name ?: "",
skillLabel = content.skillLabel, skillLabel = content.skillLabel,
rollDifficulty = content.rollDifficulty, rollDifficulty = content.rollDifficulty,
resultLabel = content.resultLabel, resultLabel = content.resultLabel,
@ -32,7 +37,8 @@ class RollHistoryViewModel(
) )
) )
} }
} }.collect { content ->
_rolls.value = content
} }
} }
} }

View file

@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Message( data class Message(
val from: String, val from: String,
val value: MessageContent, val type: MessageType,
val value: String,
) )

View file

@ -1,6 +0,0 @@
package com.pixelized.server.lwa.protocol
import kotlinx.serialization.Serializable
@Serializable
sealed interface MessageContent

View file

@ -0,0 +1,5 @@
package com.pixelized.server.lwa.protocol
enum class MessageType {
Roll
}

View file

@ -1,12 +1,13 @@
package com.pixelized.server.lwa.protocol package com.pixelized.server.lwa.protocol.roll
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class RollMessage( data class RollMessage(
val characterId: String,
val skillLabel: String, val skillLabel: String,
val resultLabel: String?, val resultLabel: String?,
val rollDifficulty: String?, val rollDifficulty: String?,
val rollValue: Int, val rollValue: Int,
val rollSuccessLimit: Int?, val rollSuccessLimit: Int?,
) : MessageContent )