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

@ -7,4 +7,7 @@
-dontwarn androidx.compose.material.**
# 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.repository.network.NetworkRepository
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.characterSheet.CharacterSheetMainNavHost
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.server.lwa.SERVER_PORT
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.websocket.Frame
import kotlinx.coroutines.CoroutineScope
@ -99,11 +99,13 @@ class NetworkRepository(
suspend fun share(
playerName: String = settingsRepository.settings().playerName,
content: MessageContent,
type: MessageType,
content: String,
) {
if (status.value == Status.CONNECTED) {
val message = Message(
from = playerName,
type = type,
value = content,
)
// emit the message into the outgoing buffer

View file

@ -1,23 +1,28 @@
package com.pixelized.desktop.lwa.repository.roll_history
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.server.lwa.protocol.Message
import com.pixelized.server.lwa.protocol.RollMessage
import com.pixelized.server.lwa.protocol.MessageType
import com.pixelized.server.lwa.protocol.roll.RollMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class RollHistoryRepository(
private val network: NetworkRepository,
private val jsonFormatter: Json,
) {
private val scope = CoroutineScope(Dispatchers.IO)
val rolls: SharedFlow<Message> = network.data
.mapNotNull { it.takeIf { it.value is RollMessage } }
val rolls: SharedFlow<RollMessage> = network.data
.mapNotNull { it.takeIf { it.type == MessageType.Roll } }
.map { jsonFormatter.decodeFromString<RollMessage>(it.value) }
.shareIn(
scope = scope,
started = SharingStarted.Eagerly,
@ -32,20 +37,24 @@ class RollHistoryRepository(
}
suspend fun share(
characterId: String,
skillLabel: String,
rollDifficulty: String?,
rollValue: Int,
resultLabel: String?,
rollSuccessLimit: Int?,
) {
val content = RollMessage(
characterId = characterId,
skillLabel = skillLabel,
rollDifficulty = rollDifficulty,
rollValue = rollValue,
resultLabel = resultLabel,
rollSuccessLimit = rollSuccessLimit,
)
network.share(
content = RollMessage(
skillLabel = skillLabel,
rollDifficulty = rollDifficulty,
rollValue = rollValue,
resultLabel = resultLabel,
rollSuccessLimit = rollSuccessLimit,
)
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.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
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.ribbon.PlayerRibbon
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
@Composable
fun CampaignScreen(
characterDetailViewModel: CharacterDetailViewModel = koinViewModel(),
dismissedViewModel: CharacterDiminishedViewModel = koinViewModel(),
networkViewModel: NetworkViewModel = koinViewModel(),
) {
LaunchedEffect(Unit) {
networkViewModel.connect()
}
KeyHandler {
when {
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
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.items
import androidx.compose.runtime.Composable
@ -25,10 +27,19 @@ fun PlayerRibbon(
items = characters.value,
key = { it.id },
) {
PlayerPortrait(
character = it,
onCharacter = onCharacter,
)
Row {
PlayerPortrait(
character = it,
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
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.viewModelScope
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.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class PlayerRibbonViewModel(
repository: CharacterSheetRepository,
private val rollHistoryRepository: RollHistoryRepository,
characterRepository: CharacterSheetRepository,
) : ViewModel() {
val characters = repository.characterSheetFlow()
val characters: StateFlow<List<PlayerPortraitUio>> = characterRepository.characterSheetFlow()
.map { sheets ->
sheets.map { sheet ->
PlayerPortraitUio(
@ -28,4 +37,31 @@ class PlayerRibbonViewModel(
started = SharingStarted.Eagerly,
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,11 +95,14 @@ fun RollPage(
val scope = rememberCoroutineScope()
KeyHandler {
if (it.type == KeyEventType.KeyUp && it.key == Key.Escape) {
onDismissRequest()
true
} else {
false
when {
it.type == KeyEventType.KeyUp && it.key == Key.Escape -> {
onDismissRequest()
true
}
else -> {
false
}
}
}

View file

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

View file

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

View file

@ -4,12 +4,15 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
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 kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
class RollHistoryViewModel(
private val repository: RollHistoryRepository
private val characterRepository: CharacterSheetRepository,
private val rollRepository: RollHistoryRepository,
) : ViewModel() {
private val _rolls = mutableStateOf((emptyList<RollHistoryItemUio>()))
@ -17,22 +20,25 @@ class RollHistoryViewModel(
init {
viewModelScope.launch {
repository.rolls.collect {
(it.value as? RollMessage)?.let { content ->
_rolls.value = _rolls.value.toMutableList().apply {
add(
index = 0,
element = RollHistoryItemUio(
from = it.from,
skillLabel = content.skillLabel,
rollDifficulty = content.rollDifficulty,
resultLabel = content.resultLabel,
rollValue = content.rollValue,
rollSuccessLimit = content.rollSuccessLimit,
)
combine(
characterRepository.characterSheetFlow(),
rollRepository.rolls,
) { sheets: List<CharacterSheet>, content ->
_rolls.value.toMutableList().apply {
add(
index = 0,
element = RollHistoryItemUio(
character = sheets.firstOrNull { it.id == content.characterId }?.name ?: "",
skillLabel = content.skillLabel,
rollDifficulty = content.rollDifficulty,
resultLabel = content.resultLabel,
rollValue = content.rollValue,
rollSuccessLimit = content.rollSuccessLimit,
)
}
)
}
}.collect { content ->
_rolls.value = content
}
}
}

View file

@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable
@Serializable
data class Message(
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
@Serializable
data class RollMessage(
val characterId: String,
val skillLabel: String,
val resultLabel: String?,
val rollDifficulty: String?,
val rollValue: Int,
val rollSuccessLimit: Int?,
) : MessageContent
)