Add character portrait display feature.

This commit is contained in:
Thomas Andres Gomez 2025-03-20 23:22:56 +01:00
parent 50c34c8520
commit 67e154ed4a
18 changed files with 214 additions and 20 deletions

View file

@ -52,11 +52,11 @@ 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.overlay.roll.RollHostState
import com.pixelized.desktop.lwa.ui.overlay.roll.RollOverlay
import com.pixelized.desktop.lwa.ui.screen.campaign.player.CharacterPortraitDefault
import com.pixelized.desktop.lwa.ui.screen.characterSheet.CharacterSheetMainNavHost
import com.pixelized.desktop.lwa.ui.screen.gamemaster.GameMasterScreen
import com.pixelized.desktop.lwa.ui.screen.rollhistory.RollHistoryPage
import com.pixelized.desktop.lwa.ui.theme.LwaTheme
import com.pixelized.desktop.lwa.ui.theme.lwa
import com.pixelized.desktop.lwa.utils.InstallCoil
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ -108,7 +108,7 @@ fun ApplicationScope.LwaApplication() {
size = DpSize(
width = 800.dp,
height = min(
a = 56.dp + CharacterPortraitDefault.size.height * 6 + 8.dp * 7 + 40.dp,
a = 56.dp + 128.dp * 6 + 8.dp * 7 + 40.dp, // 128 is the height of a minimized portrait.
b = maxWindowHeight,
),
),

View file

@ -15,6 +15,7 @@ import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsStore
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterDetailCharacteristicDialogViewModel
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterSheetCharacteristicDialogFactory
import com.pixelized.desktop.lwa.ui.overlay.portrait.PortraitOverlayViewModel
import com.pixelized.desktop.lwa.ui.overlay.roll.RollViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.network.NetworkFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.network.NetworkViewModel
@ -138,6 +139,7 @@ val viewModelDependencies
viewModelOf(::SettingsViewModel)
viewModelOf(::LevelUpViewModel)
viewModelOf(::GameMasterViewModel)
viewModelOf(::PortraitOverlayViewModel)
}
val useCaseDependencies

View file

@ -59,8 +59,8 @@ class CampaignRepository(
initialValue = campaignFlow.value.character(id = characterInstanceId),
)
}
@Deprecated(message = "Check if deprecated")
@Deprecated(message = "Check if deprecated")
fun characterInstance(
characterInstanceId: Campaign.CharacterInstance.Id,
): Campaign.CharacterInstance {

View file

@ -6,10 +6,10 @@ import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.character
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory
import com.pixelized.shared.lwa.model.campaign.npc
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
import com.pixelized.shared.lwa.protocol.websocket.CampaignMessage
import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent
import com.pixelized.shared.lwa.protocol.websocket.RestSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
import com.pixelized.shared.lwa.usecase.CampaignUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers

View file

@ -0,0 +1,100 @@
package com.pixelized.desktop.lwa.ui.overlay.portrait
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
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.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.FilterQuality
import coil3.compose.AsyncImage
import com.pixelized.desktop.lwa.ui.theme.lwa
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.ic_cancel_24dp
import org.jetbrains.compose.resources.painterResource
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun PortraitOverlay(
modifier: Modifier = Modifier,
viewModel: PortraitOverlayViewModel = koinViewModel(),
) {
val scope = rememberCoroutineScope()
val portrait = viewModel.portrait.collectAsState()
val isGameMaster = viewModel.isGameMaster.collectAsState()
PortraitContent(
modifier = modifier,
portrait = portrait,
isGameMaster = isGameMaster,
onGameMaster = {
scope.launch {
viewModel.dismissPortrait()
}
}
)
}
@Composable
private fun PortraitContent(
modifier: Modifier = Modifier,
portrait: State<String?>,
isGameMaster: State<Boolean>,
onGameMaster: () -> Unit,
) {
AnimatedContent(
modifier = Modifier
.clip(shape = MaterialTheme.lwa.shapes.portrait)
.then(other = modifier),
targetState = portrait.value,
transitionSpec = {
fadeIn() togetherWith fadeOut()
}
) {
when (it) {
null -> Box(
modifier = Modifier.size(MaterialTheme.lwa.size.portrait.maximized)
)
else -> Box(
modifier = Modifier.size(MaterialTheme.lwa.size.portrait.maximized)
) {
AsyncImage(
modifier = Modifier.matchParentSize(),
model = it,
filterQuality = FilterQuality.High,
contentDescription = null
)
AnimatedVisibility(
modifier = Modifier.align(alignment = Alignment.TopEnd),
visible = isGameMaster.value,
enter = fadeIn(),
exit = fadeOut(),
) {
IconButton(
onClick = onGameMaster,
) {
Icon(
painter = painterResource(Res.drawable.ic_cancel_24dp),
tint = MaterialTheme.lwa.colorScheme.base.primary,
contentDescription = null
)
}
}
}
}
}
}

View file

@ -0,0 +1,51 @@
package com.pixelized.desktop.lwa.ui.overlay.portrait
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.shared.lwa.protocol.websocket.GameEvent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
class PortraitOverlayViewModel(
private val networkRepository: NetworkRepository,
settingsRepository: SettingsRepository,
characterSheetRepository: CharacterSheetRepository,
) : ViewModel() {
@OptIn(ExperimentalCoroutinesApi::class)
val portrait = networkRepository.data
.mapNotNull { it as? GameEvent.DisplayPortrait }
.flatMapLatest { characterSheetRepository.characterDetailFlow(characterSheetId = it.characterSheetId) }
.map { it?.portrait }
.distinctUntilChanged()
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = null
)
val isGameMaster = settingsRepository.settingsFlow()
.map { settings -> settings.isGameMaster ?: false }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = false
)
suspend fun dismissPortrait() {
networkRepository.share(
GameEvent.DisplayPortrait(
timestamp = System.currentTimeMillis(),
characterSheetId = null,
)
)
}
}

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
@ -35,6 +36,7 @@ import com.pixelized.desktop.lwa.ui.composable.character.characteristic.Characte
import com.pixelized.desktop.lwa.ui.composable.key.KeyHandler
import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.navigateToLevelScreen
import com.pixelized.desktop.lwa.ui.overlay.portrait.PortraitOverlay
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailPanel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDiminishedViewModel
@ -84,7 +86,14 @@ fun CampaignScreen(
},
main = {
Box(
modifier = Modifier.matchParentSize(),
contentAlignment = Alignment.BottomCenter,
) {
PortraitOverlay(
modifier = Modifier.padding(all = 8.dp)
)
}
},
chat = {
CampaignChat(

View file

@ -54,11 +54,6 @@ import org.jetbrains.compose.resources.painterResource
import kotlin.math.max
import kotlin.math.min
@Stable
object CharacterPortraitDefault {
val size = DpSize(96.dp, 128.dp)
}
@Stable
data class CharacterPortraitUio(
val id: Campaign.CharacterInstance.Id,
@ -82,7 +77,7 @@ data class CharacterPortraitUio(
@Composable
fun CharacterPortrait(
modifier: Modifier = Modifier,
size: DpSize = CharacterPortraitDefault.size,
size: DpSize = MaterialTheme.lwa.size.portrait.minimized,
levelUpOffset: Dp = 9.dp,
character: CharacterPortraitUio,
onCharacter: (id: Campaign.CharacterInstance.Id) -> Unit,

View file

@ -37,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.theme.lwa
import com.pixelized.shared.lwa.model.campaign.Campaign
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
@ -61,7 +62,7 @@ data class CharacterPortraitRollAnimation(
@Composable
fun CharacterPortraitRoll(
modifier: Modifier = Modifier,
size: DpSize = CharacterPortraitDefault.size,
size: DpSize = MaterialTheme.lwa.size.portrait.minimized,
value: CharacterPortraitRollUio?,
onLeftClick: (CharacterPortraitRollUio) -> Unit,
onRightClick: (CharacterPortraitRollUio) -> Unit,

View file

@ -10,7 +10,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.screen.campaign.player.CharacterPortrait
import com.pixelized.desktop.lwa.ui.screen.campaign.player.CharacterPortraitDefault
import com.pixelized.desktop.lwa.ui.screen.campaign.player.CharacterPortraitRoll
import com.pixelized.shared.lwa.model.campaign.Campaign
import org.koin.compose.viewmodel.koinViewModel
@ -38,7 +37,6 @@ fun NpcRibbon(
modifier = Modifier.animateItem(),
) {
CharacterPortraitRoll(
size = CharacterPortraitDefault.size,
value = viewModel.roll(characterId = it.id).value,
onRightClick = {
viewModel.onPortraitRollRightClick(characterId = it.characterId)
@ -48,7 +46,6 @@ fun NpcRibbon(
},
)
CharacterPortrait(
size = CharacterPortraitDefault.size,
character = it,
onCharacter = onCharacter,
onLevelUp = onLevelUp,

View file

@ -35,7 +35,6 @@ import androidx.compose.ui.window.WindowState
import com.pixelized.desktop.lwa.ui.navigation.window.LocalWindowState
import com.pixelized.desktop.lwa.ui.screen.campaign.CampaignLayoutScope
import com.pixelized.desktop.lwa.ui.screen.campaign.LocalCampaignLayoutScope
import com.pixelized.desktop.lwa.ui.screen.campaign.player.CharacterPortraitDefault
import com.pixelized.desktop.lwa.ui.screen.campaign.text.messages.CharacteristicTextMessage
import com.pixelized.desktop.lwa.ui.screen.campaign.text.messages.CharacteristicTextMessageUio
import com.pixelized.desktop.lwa.ui.screen.campaign.text.messages.DiminishedTextMessage
@ -90,7 +89,7 @@ fun CampaignChat(
modifier = modifier
.size(
width = animatedChatWidth.value,
height = CharacterPortraitDefault.size.height * 2 + 8.dp,
height = MaterialTheme.lwa.size.portrait.minimized.height * 2 + 8.dp,
)
.graphicsLayer {
alpha = chatViewModel.chatAnimatedVisibility.value

View file

@ -9,6 +9,7 @@ import com.pixelized.desktop.lwa.ui.screen.campaign.text.messages.TextMessage
import com.pixelized.shared.lwa.model.campaign.CampaignJsonV1.CharacterInstanceJsonV1.CharacteristicV1.Damage
import com.pixelized.shared.lwa.model.campaign.CampaignJsonV1.CharacterInstanceJsonV1.CharacteristicV1.Power
import com.pixelized.shared.lwa.protocol.websocket.CampaignMessage
import com.pixelized.shared.lwa.protocol.websocket.GameEvent
import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent
import com.pixelized.shared.lwa.protocol.websocket.RestSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.RollMessage
@ -105,6 +106,7 @@ class TextMessageFactory(
is ToggleActiveAlteration -> null
is UpdateSkillUsageMessage -> null
is GameMasterEvent -> null
is GameEvent.DisplayPortrait -> null
}
}
}

View file

@ -1,17 +1,25 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterUio.Action
import com.pixelized.shared.lwa.protocol.websocket.GameEvent
class GameMasterActionUseCase(
private val campaignRepository: CampaignRepository,
private val networkRepository: NetworkRepository,
) {
suspend fun handleAction(
characterSheetId: String,
action: Action,
) {
when (action) {
Action.DisplayPortrait -> TODO()
Action.DisplayPortrait -> networkRepository.share(
GameEvent.DisplayPortrait(
timestamp = System.currentTimeMillis(),
characterSheetId = characterSheetId,
)
)
Action.AddToGroup -> campaignRepository.addCharacter(
characterSheetId = characterSheetId,

View file

@ -92,6 +92,7 @@ class GameMasterFactory {
}
// build the cell action list
val actions = buildList {
add(Action.DisplayPortrait)
add(
when (characterInstanceId) {
null -> Action.AddToGroup

View file

@ -17,7 +17,6 @@ import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable

View file

@ -9,8 +9,15 @@ import androidx.compose.ui.unit.dp
data class LwaSize(
val portrait: Portrait,
val sheet: Sheet,
) {
@Stable
data class Portrait(
val maximized: DpSize,
val minimized: DpSize,
)
@Stable
data class Sheet(
val subCategory: Dp,
@ -21,12 +28,17 @@ data class LwaSize(
@Composable
@Stable
fun lwaSize(
portrait: LwaSize.Portrait = LwaSize.Portrait(
minimized = DpSize(width = 96.dp, height = 128.dp),
maximized = DpSize(width = 512.dp, height = 512.dp),
),
sheet: LwaSize.Sheet = LwaSize.Sheet(
subCategory = 14.dp,
characteristic = DpSize(width = 76.dp, height = 110.dp),
),
) = remember {
LwaSize(
portrait = portrait,
sheet = sheet,
)
}

View file

@ -5,11 +5,12 @@ import com.pixelized.server.lwa.model.campaign.CampaignService
import com.pixelized.server.lwa.model.character.CharacterSheetService
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
import com.pixelized.shared.lwa.protocol.websocket.CampaignMessage
import com.pixelized.shared.lwa.protocol.websocket.GameEvent
import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent
import com.pixelized.shared.lwa.protocol.websocket.RestSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.RollMessage
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
import com.pixelized.shared.lwa.protocol.websocket.ToggleActiveAlteration
import com.pixelized.shared.lwa.protocol.websocket.UpdateSkillUsageMessage
import kotlinx.coroutines.flow.MutableSharedFlow
@ -65,6 +66,10 @@ class Engine(
is GameMasterEvent.TogglePlayer -> campaignService.updateToggleParty()
is GameMasterEvent.ToggleNpc -> campaignService.updateToggleNpc()
}
is GameEvent -> when (message) {
is GameEvent.DisplayPortrait -> Unit // Nothing to do here.
}
}
}
}

View file

@ -0,0 +1,13 @@
package com.pixelized.shared.lwa.protocol.websocket
import kotlinx.serialization.Serializable
@Serializable
sealed interface GameEvent : SocketMessage {
@Serializable
data class DisplayPortrait(
override val timestamp: Long,
val characterSheetId: String?,
) : GameEvent
}