Add alteration status to the portrait

This commit is contained in:
Thomas Andres Gomez 2025-05-08 13:43:30 +02:00
parent 2c04559bb7
commit e81b66e725
23 changed files with 2719 additions and 210 deletions

File diff suppressed because it is too large Load diff

View file

@ -35,7 +35,7 @@ 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.toolbar.network.NetworkFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.toolbar.network.NetworkViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.CharacterRibbonFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.CharacterRibbonFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.sheet.CharacterDetailSheetFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailPanelViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.header.CharacterDetailHeaderFactory

View file

@ -1,6 +1,5 @@
package com.pixelized.desktop.lwa.repository.alteration
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.inventory.InventoryRepository
import com.pixelized.desktop.lwa.repository.item.ItemRepository
@ -23,24 +22,23 @@ class AlterationRepository(
private val alterationStore: AlterationStore,
private val inventoryRepository: InventoryRepository,
private val itemRepository: ItemRepository,
campaignRepository: CampaignRepository,
characterRepository: CharacterSheetRepository,
private val characterRepository: CharacterSheetRepository,
) {
private val scope = CoroutineScope(Dispatchers.IO + Job())
val alterationFlow get() = alterationStore.alterationsFlow
/**
* This flow transform the campaign instance (player + npc) into a
* Map<CharacterSheetId, List<AlterationId>> from the CharacterSheet Alteration
* This flow build a Map of CharacterSheetId to a list of AlterationId.
* Map<CharacterSheetId, List<AlterationId>> from the CharacterSheet Alterations.
* It is used by an other flow to build the FieldAlteration cache.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private val campaignCharacterAlterationFlow: Flow<Map<String, List<String>>> =
campaignRepository.campaignFlow()
.flatMapLatest { campaign ->
val characters = campaign.instances.map {
characterRepository.characterDetailFlow(characterSheetId = it)
private val charactersAlterationFlow: Flow<Map<String, List<String>>> =
characterRepository.characterSheetPreviewFlow()
.flatMapLatest { preview ->
val characters = preview.map {
characterRepository.characterDetailFlow(characterSheetId = it.characterSheetId)
}
combine(characters) { sheets: Array<CharacterSheet?> ->
sheets
@ -51,20 +49,20 @@ class AlterationRepository(
}
/**
* This flow transform the campaign instance (player + npc) into a
* Map<CharacterSheetId, List<AlterationId>> from the character inventory items Alteration
* This flow build a Map of CharacterSheetId to a list of AlterationId.
* Map<CharacterSheetId, List<AlterationId>> from the character inventory items Alterations.
* It is used by an other flow to build the FieldAlteration cache.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private val campaignCharacterInventoryAlterationFlow: Flow<Map<String, List<String>>> =
campaignRepository.campaignFlow()
.flatMapLatest { campaign ->
val equippedItems = campaign.instances.map { characterSheetId ->
private val inventoriesAlterationFlow: Flow<Map<String, List<String>>> =
characterRepository.characterSheetPreviewFlow()
.flatMapLatest { previews ->
val equippedItems = previews.map {
combine(
inventoryRepository.equippedItemsFlow(characterSheetId = characterSheetId),
inventoryRepository.equippedItemsFlow(characterSheetId = it.characterSheetId),
itemRepository.itemFlow(),
) { equipments, items ->
characterSheetId to equipments.flatMap { equipment ->
it.characterSheetId to equipments.flatMap { equipment ->
items[equipment.itemId]?.alterations ?: emptyList()
}
}
@ -76,18 +74,60 @@ class AlterationRepository(
}
}
private val activeAlterationMapFlow: StateFlow<Map<String, Map<String, List<FieldAlteration>>>> =
/**
* This flow build a Map of CharacterSheetId to a list of AlterationId.
* Map<CharacterSheetId, List<AlterationId>> from the character sheet + inventory items Alterations.
* It is used by an other flow to build the FieldAlteration cache.
*/
private val activeAlterationIdsFlow: StateFlow<Map<String, List<String>>> = combine(
charactersAlterationFlow,
inventoriesAlterationFlow,
) { characters, inventories ->
val characterSheetIds = characters.keys + inventories.keys
characterSheetIds.associateWith {
characters.getOrElse(it) { emptyList() } + inventories.getOrElse(it) { emptyList() }
}
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyMap(),
)
/**
* This flow build a Map of CharacterSheetId to a list of Alteration.
* Map<CharacterSheetId, List<Alteration>> from the character sheet + inventory items Alterations.
*/
private val activeAlterationsFlow: StateFlow<Map<String, List<Alteration>>> =
combine(
characterRepository.characterSheetPreviewFlow(),
alterationStore.alterationsFlow,
campaignCharacterAlterationFlow,
campaignCharacterInventoryAlterationFlow,
) { alterations, characters, inventories ->
val characterSheetIds = characters.keys + inventories.keys
characterSheetIds.associateWith {
activeAlterationIdsFlow,
) { preview, alterations, activeAlterationIds ->
preview.map { it.characterSheetId }.associateWith { characterSheetId ->
activeAlterationIds
.getOrElse(characterSheetId) { emptyList() }
.mapNotNull { alterations[it] }
}
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyMap(),
)
/**
* This flow build a Map of CharacterSheetId to a Map of AlterationId and FieldAlteration.
* Map<CharacterSheetId, Map<AlterationId, Alteration>> from the character sheet + inventory items Alterations.
*/
private val activeFieldAlterationsFlow: StateFlow<Map<String, Map<String, List<FieldAlteration>>>> =
combine(
characterRepository.characterSheetPreviewFlow(),
alterationStore.alterationsFlow,
activeAlterationIdsFlow,
) { previews, alterations, activeAlterationIds ->
previews.map { it.characterSheetId }.associateWith {
transformToAlterationFieldMap(
alterations = alterations,
actives = characters.getOrElse(it) { emptyList() } +
inventories.getOrElse(it) { emptyList() }
actives = activeAlterationIds.getOrElse(it) { emptyList() },
)
}
}.stateIn(
@ -106,16 +146,28 @@ class AlterationRepository(
return alterationFlow.value[alterationId]
}
fun fieldAlterations(
fun activeFieldAlterations(
characterSheetId: String,
): Map<String, List<FieldAlteration>> {
return activeAlterationMapFlow.value[characterSheetId] ?: emptyMap()
return activeFieldAlterationsFlow.value[characterSheetId] ?: emptyMap()
}
fun fieldAlterationsFlow(
fun activeFieldAlterationsFlow(
characterSheetId: String,
): Flow<Map<String, List<FieldAlteration>>> {
return activeAlterationMapFlow.map { it[characterSheetId] ?: emptyMap() }
return activeFieldAlterationsFlow.map { it[characterSheetId] ?: emptyMap() }
}
fun activeAlterations(
characterSheetId: String,
): List<Alteration> {
return activeAlterationsFlow.value[characterSheetId] ?: emptyList()
}
fun activeAlterationsFlow(
characterSheetId: String,
): Flow<List<Alteration>> {
return activeAlterationsFlow.map { it[characterSheetId] ?: emptyList() }
}
@Throws

View file

@ -32,7 +32,7 @@ class CharacterSheetCharacteristicDialogFactory(
if (characterSheet == null) return null
val alterations: Map<String, List<FieldAlteration>> = alterationRepository.fieldAlterations(
val alterations: Map<String, List<FieldAlteration>> = alterationRepository.activeFieldAlterations(
characterSheetId = characterSheetId,
)

View file

@ -49,7 +49,7 @@ class CharacterSheetCharacteristicDialogViewModel(
if (characterSheet == null) return
val alterations = alterationRepository.fieldAlterations(
val alterations = alterationRepository.activeFieldAlterations(
characterSheetId = characterSheetId,
)

View file

@ -29,7 +29,7 @@ class CharacterSheetDiminishedDialogFactory(
if (characterSheet == null) return null
val alterations: Map<String, List<FieldAlteration>> = alterationRepository.fieldAlterations(
val alterations: Map<String, List<FieldAlteration>> = alterationRepository.activeFieldAlterations(
characterSheetId = characterSheetId,
)

View file

@ -103,7 +103,7 @@ class RollViewModel(
if (characterSheet == null) return
val alterations = alterationRepository.fieldAlterations(
val alterations = alterationRepository.activeFieldAlterations(
characterSheetId = roll.characterSheetId,
)

View file

@ -1,41 +0,0 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player
import com.pixelized.shared.lwa.model.AlteredCharacterSheetFactory
import com.pixelized.shared.lwa.model.alteration.FieldAlteration
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet
class CharacterRibbonFactory(
private val alteredCharacterSheetFactory: AlteredCharacterSheetFactory,
) {
fun convertToPlayerPortraitUio(
characterSheet: CharacterSheet?,
alterations: Map<String, List<FieldAlteration>>,
hideOverruled: Boolean,
enableCharacterSheet: Boolean,
enableCharacterStats: Boolean,
): CharacterPortraitUio? {
if (characterSheet == null) return null
val alteredCharacterSheet = alteredCharacterSheetFactory.sheet(
characterSheet = characterSheet,
alterations = alterations,
)
return CharacterPortraitUio(
characterSheetId = characterSheet.id,
portrait = alteredCharacterSheet.thumbnail,
name = alteredCharacterSheet.name,
levelUp = alteredCharacterSheet.shouldLevelUp,
hideOverruled = hideOverruled,
enableDetail = enableCharacterSheet,
stats = takeIf { enableCharacterStats }?.let {
CharacterPortraitUio.StatsDetail(
hp = alteredCharacterSheet.maxHp - alteredCharacterSheet.damage,
maxHp = alteredCharacterSheet.maxHp,
pp = alteredCharacterSheet.maxPp - alteredCharacterSheet.fatigue,
maxPp = alteredCharacterSheet.maxPp,
)
},
)
}
}

View file

@ -51,7 +51,7 @@ class CharacterDetailHeaderFactory(
): StateFlow<CharacterDetailHeaderUio?> {
return combine(
characterSheetRepository.characterDetailFlow(characterSheetId = characterSheetId),
alterationRepository.fieldAlterationsFlow(characterSheetId = characterSheetId),
alterationRepository.activeFieldAlterationsFlow(characterSheetId = characterSheetId),
settingRepository.settingsFlow()
) { characterSheet, alterations, settings ->
convertToCharacterDetailHeaderUio(

View file

@ -48,7 +48,7 @@ class CharacterDetailSheetFactory(
): StateFlow<CharacterDetailSheetUio?> {
return combine(
characterSheetRepository.characterDetailFlow(characterSheetId = characterSheetId),
alterationRepository.fieldAlterationsFlow(characterSheetId = characterSheetId),
alterationRepository.activeFieldAlterationsFlow(characterSheetId = characterSheetId),
) { characterSheet, alterations ->
convertToCharacterDetailSheetUio(
characterSheetId = characterSheetId,

View file

@ -0,0 +1,67 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon
import com.pixelized.desktop.lwa.ui.composable.tooltip.BasicTooltipUio
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonAlterationUio
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonPortraitUio
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonUio
import com.pixelized.shared.lwa.model.AlteredCharacterSheetFactory
import com.pixelized.shared.lwa.model.alteration.Alteration
import com.pixelized.shared.lwa.model.alteration.FieldAlteration
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet
class CharacterRibbonFactory(
private val alteredCharacterSheetFactory: AlteredCharacterSheetFactory,
) {
fun convertToPlayerPortraitUio(
characterSheet: CharacterSheet?,
alterations: List<Alteration>,
fieldAlterations: Map<String, List<FieldAlteration>>,
hideOverruled: Boolean,
enableCharacterSheet: Boolean,
enableCharacterStats: Boolean,
): CharacterRibbonUio? {
if (characterSheet == null) return null
val alteredCharacterSheet = alteredCharacterSheetFactory.sheet(
characterSheet = characterSheet,
alterations = fieldAlterations,
)
val status = alterations.map { alteration ->
CharacterRibbonAlterationUio(
icon = "https://bg3.wiki/w/images/2/2d/Map_Tutorial_Map_Icon.png",
tooltips = BasicTooltipUio(
title = alteration.metadata.name,
description = alteration.metadata.description,
),
)
}.fold(
initial = mutableListOf<MutableList<CharacterRibbonAlterationUio>>(),
operation = { acc, item ->
if (acc.isEmpty() || acc.last().size == 5) acc.add(mutableListOf())
acc.last().add(item)
acc
}
)
return CharacterRibbonUio(
characterSheetId = characterSheet.id,
hideOverruled = hideOverruled,
portrait = CharacterRibbonPortraitUio(
portrait = alteredCharacterSheet.thumbnail,
name = alteredCharacterSheet.name,
levelUp = alteredCharacterSheet.shouldLevelUp,
enableDetail = enableCharacterSheet,
stats = takeIf { enableCharacterStats }?.let {
CharacterRibbonPortraitUio.StatsDetail(
hp = alteredCharacterSheet.maxHp - alteredCharacterSheet.damage,
maxHp = alteredCharacterSheet.maxHp,
pp = alteredCharacterSheet.maxPp - alteredCharacterSheet.fatigue,
maxPp = alteredCharacterSheet.maxPp,
)
},
),
status = status,
)
}
}

View file

@ -1,11 +1,13 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
@ -14,10 +16,11 @@ import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetReposit
import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.desktop.lwa.repository.settings.model.Settings
import com.pixelized.desktop.lwa.ui.screen.campaign.player.CharacterPortraitRollUio
import com.pixelized.desktop.lwa.ui.screen.campaign.player.CharacterPortraitUio
import com.pixelized.desktop.lwa.ui.screen.campaign.player.CharacterRibbonFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonRollUio
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonUio
import com.pixelized.desktop.lwa.ui.theme.lwa
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.protocol.websocket.RollEvent.Critical
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -38,7 +41,7 @@ abstract class CharacterRibbonViewModel(
private val ribbonFactory: CharacterRibbonFactory,
) : ViewModel() {
private val rolls = hashMapOf<String, MutableState<CharacterPortraitRollUio?>>()
private val rolls = hashMapOf<String, MutableState<CharacterRibbonRollUio?>>()
abstract fun fetch(
campaign: Campaign,
@ -66,7 +69,7 @@ abstract class CharacterRibbonViewModel(
* Then sort the result.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val characters: StateFlow<List<CharacterPortraitUio>> = combine(
val characters: StateFlow<List<CharacterRibbonUio>> = combine(
settingsRepository.settingsFlow(),
campaignRepository.campaignFlow(),
) { settings, campaign -> campaign to settings }
@ -76,15 +79,17 @@ abstract class CharacterRibbonViewModel(
val characterSheetIds = fetch(campaign, settings)
when (characterSheetIds.isEmpty()) {
true -> flowOf(emptyList())
else -> combine<CharacterPortraitUio?, List<CharacterPortraitUio>>(
else -> combine<CharacterRibbonUio?, List<CharacterRibbonUio>>(
flows = characterSheetIds.map { characterSheetId ->
combine(
characterRepository.characterDetailFlow(characterSheetId = characterSheetId),
alterationRepository.fieldAlterationsFlow(characterSheetId = characterSheetId),
) { sheet, alterations ->
alterationRepository.activeAlterationsFlow(characterSheetId = characterSheetId),
alterationRepository.activeFieldAlterationsFlow(characterSheetId = characterSheetId),
) { sheet, alterations, fieldAlterations ->
ribbonFactory.convertToPlayerPortraitUio(
characterSheet = sheet,
alterations = alterations,
fieldAlterations = fieldAlterations,
hideOverruled = hideOverruled,
enableCharacterSheet = enableCharacterSheet(settings = settings),
enableCharacterStats = enableCharacterStats(settings = settings),
@ -93,7 +98,7 @@ abstract class CharacterRibbonViewModel(
},
transform = { headers ->
headers.mapNotNull { it }
.sortedWith(compareBy(Collator.getInstance()) { it.name })
.sortedWith(compareBy(Collator.getInstance()) { it.portrait.name })
.toList()
}
)
@ -113,8 +118,11 @@ abstract class CharacterRibbonViewModel(
@Stable
fun roll(
characterSheetId: String,
): State<CharacterPortraitRollUio?> {
val state = rolls.getOrPut(characterSheetId) { mutableStateOf(null) }
): State<CharacterRibbonRollUio?> {
val colorScheme = MaterialTheme.lwa.colorScheme
val state = remember(characterSheetId) {
rolls.getOrPut(characterSheetId) { mutableStateOf(null) }
}
LaunchedEffect(characterSheetId) {
combine(
@ -122,10 +130,17 @@ abstract class CharacterRibbonViewModel(
rollHistoryRepository.rolls(),
) { settings, roll ->
if (settings.portrait.dynamicDice && characterSheetId == roll.characterSheetId) {
state.value = CharacterPortraitRollUio(
state.value = CharacterRibbonRollUio(
characterSheetId = characterSheetId,
value = roll.rollValue,
label = roll.resultLabel?.split(" ")?.joinToString(separator = "\n") { it }
tint = when (roll.critical) {
Critical.CRITICAL_SUCCESS -> colorScheme.portrait.criticalSuccess
Critical.SPECIAL_SUCCESS -> colorScheme.portrait.spacialSuccess
Critical.SUCCESS -> colorScheme.portrait.success
Critical.FAILURE -> colorScheme.portrait.failure
Critical.CRITICAL_FAILURE -> colorScheme.portrait.criticalFailure
null -> colorScheme.portrait.default
},
)
}
}.launchIn(this)

View file

@ -0,0 +1,39 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.EaseOutCirc
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.pixelized.desktop.lwa.ui.theme.lwa
import kotlin.math.max
import kotlin.math.min
@Composable
fun BloodOverlay(
modifier: Modifier = Modifier,
bloodColor: Color = MaterialTheme.lwa.colorScheme.portrait.blood,
maxHp: Float,
hp: Float,
) {
val animatedRatio = animateFloatAsState(
targetValue = min(maxHp, max(0f, (maxHp - hp) / maxHp)),
animationSpec = tween(durationMillis = 350, easing = EaseOutCirc)
)
val animatedColor = animateColorAsState(
targetValue = bloodColor.copy(alpha = ((maxHp - hp) / maxHp) / 4f + .25f)
)
Box(
modifier = modifier
.fillMaxWidth()
.fillMaxHeight(fraction = animatedRatio.value)
.background(color = animatedColor.value)
)
}

View file

@ -0,0 +1,94 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipPlacement
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import coil3.PlatformContext
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.pixelized.desktop.lwa.ui.composable.tooltip.BasicTooltipLayout
import com.pixelized.desktop.lwa.ui.composable.tooltip.BasicTooltipUio
import com.pixelized.desktop.lwa.ui.theme.lwa
@Stable
data class CharacterRibbonAlterationUio(
val icon: String,
val tooltips: BasicTooltipUio?,
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CharacterRibbonAlteration(
modifier: Modifier = Modifier,
size: DpSize = MaterialTheme.lwa.size.portrait.minimized,
direction: LayoutDirection,
status: List<List<CharacterRibbonAlterationUio>>,
) {
val currentDirection: LayoutDirection = LocalLayoutDirection.current
CompositionLocalProvider(
LocalLayoutDirection provides direction
) {
Row(
modifier = Modifier
.size(size = size)
.then(other = modifier),
horizontalArrangement = Arrangement.spacedBy(space = 4.dp),
) {
CompositionLocalProvider(
LocalLayoutDirection provides currentDirection
) {
status.forEach { columns ->
Column(
verticalArrangement = Arrangement.spacedBy(space = 2.dp),
) {
columns.forEach {
BasicTooltipLayout(
delayMillis = 0,
tooltip = it.tooltips,
tooltipPlacement = remember(currentDirection) {
TooltipPlacement.ComponentRect(
anchor = when(direction) {
LayoutDirection.Ltr -> Alignment.TopStart
LayoutDirection.Rtl -> Alignment.TopEnd
},
alignment = when(direction) {
LayoutDirection.Ltr -> Alignment.BottomEnd
LayoutDirection.Rtl -> Alignment.BottomStart
},
)
},
content = {
AsyncImage(
modifier = Modifier.size(24.dp),
model = ImageRequest.Builder(context = PlatformContext.INSTANCE)
.data(data = it.icon)
.size(size = 48)
.build(),
filterQuality = FilterQuality.High,
contentDescription = null,
)
}
)
}
}
}
}
}
}
}

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
@ -37,7 +37,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
@ -54,12 +53,10 @@ import kotlin.math.max
import kotlin.math.min
@Stable
data class CharacterPortraitUio(
val characterSheetId: String,
data class CharacterRibbonPortraitUio(
val portrait: String?,
val name: String,
val levelUp: Boolean,
val hideOverruled: Boolean,
val enableDetail: Boolean,
val stats: StatsDetail?,
) {
@ -74,32 +71,31 @@ data class CharacterPortraitUio(
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CharacterPortrait(
fun CharacterRibbonPortrait(
modifier: Modifier = Modifier,
size: DpSize = MaterialTheme.lwa.size.portrait.minimized,
levelUpOffset: Dp = 9.dp,
character: CharacterPortraitUio,
onCharacterLeftClick: (characterSheetId: String) -> Unit,
onCharacterRightClick: (characterSheetId: String) -> Unit,
onLevelUp: (characterSheetId: String) -> Unit,
character: CharacterRibbonPortraitUio,
onCharacterLeftClick: () -> Unit,
onCharacterRightClick: () -> Unit,
onLevelUp: () -> Unit,
) {
val colorScheme = MaterialTheme.lwa.colorScheme
Box(
modifier = modifier
.graphicsLayer { if (character.hideOverruled) this.alpha = 0.3f }
.size(size = size)
.clip(shape = MaterialTheme.lwa.shapes.portrait)
.background(color = colorScheme.elevated.base1dp)
.onClick(
matcher = PointerMatcher.mouse(PointerButton.Primary),
enabled = character.enableDetail,
onClick = { onCharacterLeftClick(character.characterSheetId) }
onClick = onCharacterLeftClick,
)
.onClick(
matcher = PointerMatcher.mouse(PointerButton.Secondary),
enabled = character.enableDetail,
onClick = { onCharacterRightClick(character.characterSheetId) }
onClick = onCharacterRightClick,
),
) {
AnimatedContent(
@ -125,7 +121,7 @@ fun CharacterPortrait(
exit = fadeOut(),
) {
IconButton(
onClick = { onLevelUp(character.characterSheetId) },
onClick = onLevelUp,
) {
ArrowShape(
color = MaterialTheme.lwa.colorScheme.portrait.levelUp,
@ -193,26 +189,4 @@ fun CharacterPortrait(
}
}
}
}
@Composable
private fun BloodOverlay(
modifier: Modifier = Modifier,
bloodColor: Color = MaterialTheme.lwa.colorScheme.portrait.blood,
maxHp: Float,
hp: Float,
) {
val animatedRatio = animateFloatAsState(
targetValue = min(maxHp, max(0f, (maxHp - hp) / maxHp)),
animationSpec = tween(durationMillis = 350, easing = EaseOutCirc)
)
val animatedColor = animateColorAsState(
targetValue = bloodColor.copy(alpha = ((maxHp - hp) / maxHp) / 4f + .25f)
)
Box(
modifier = modifier
.fillMaxWidth()
.fillMaxHeight(fraction = animatedRatio.value)
.background(color = animatedColor.value)
)
}

View file

@ -1,7 +1,8 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.Spring
@ -9,16 +10,11 @@ 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.ExperimentalFoundationApi
import androidx.compose.foundation.PointerMatcher
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.onClick
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@ -28,48 +24,43 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerButton
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
import lwacharactersheet.composeapp.generated.resources.ic_d20_24dp
import org.jetbrains.compose.resources.painterResource
@Stable
data class CharacterPortraitRollUio(
data class CharacterRibbonRollUio(
val characterSheetId: String,
val value: Int?,
val label: String?,
val tint: Color?,
)
@Stable
data class CharacterPortraitRollAnimation(
val alpha: Animatable<Float, AnimationVector1D> = Animatable(0f),
data class CharacterRibbonRollAnimation(
val rotation: Animatable<Float, AnimationVector1D> = Animatable(0f),
val scale: Animatable<Float, AnimationVector1D> = Animatable(1f),
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CharacterPortraitRoll(
fun CharacterRibbonRoll(
modifier: Modifier = Modifier,
size: DpSize = MaterialTheme.lwa.size.portrait.minimized,
value: CharacterPortraitRollUio?,
onLeftClick: (CharacterPortraitRollUio) -> Unit,
onRightClick: (CharacterPortraitRollUio) -> Unit,
value: CharacterRibbonRollUio?,
) {
AnimatedContent(
modifier = modifier
.size(size = size)
.width(width = size.width)
.aspectRatio(ratio = 1f)
.graphicsLayer { clip = false },
targetState = value,
transitionSpec = {
@ -79,6 +70,7 @@ fun CharacterPortraitRoll(
}
) {
val animation = diceIconAnimation(key = it ?: Unit)
val color = animateColorAsState(targetValue = it?.tint ?: Color.Transparent)
Box(
modifier = Modifier.graphicsLayer {
@ -91,23 +83,13 @@ fun CharacterPortraitRoll(
Icon(
modifier = Modifier
.graphicsLayer {
this.alpha = 0.8f
this.rotationZ = animation.rotation.value
}
.fillMaxWidth()
.aspectRatio(1f)
.padding(all = 8.dp)
.clip(shape = CircleShape)
.onClick(
matcher = PointerMatcher.mouse(PointerButton.Secondary),
onClick = { onRightClick(it) },
)
.clickable {
onLeftClick(it)
}
.padding(all = 8.dp),
.padding(all = 16.dp),
painter = painterResource(Res.drawable.ic_d20_24dp),
tint = MaterialTheme.colors.primary,
tint = color.value,
contentDescription = null,
)
Text(
@ -123,21 +105,15 @@ fun CharacterPortraitRoll(
color = MaterialTheme.colors.onSurface,
text = it.value.toString()
)
Text(
modifier = Modifier.padding(top = 84.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption,
text = it.label ?: "",
)
}
}
}
}
@Composable
private fun diceIconAnimation(key: Any = Unit): CharacterPortraitRollAnimation {
private fun diceIconAnimation(key: Any = Unit): CharacterRibbonRollAnimation {
val animation = remember(key) {
CharacterPortraitRollAnimation()
CharacterRibbonRollAnimation()
}
LaunchedEffect(key) {
launch {

View file

@ -0,0 +1,11 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common
import androidx.compose.runtime.Stable
@Stable
class CharacterRibbonUio(
val characterSheetId: String,
val hideOverruled: Boolean,
val portrait: CharacterRibbonPortraitUio,
val status: List<List<CharacterRibbonAlterationUio>>,
)

View file

@ -1,6 +1,7 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.npc
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.lazy.LazyColumn
@ -8,9 +9,12 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.LayoutDirection
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.CharacterPortraitRoll
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonPortrait
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonRoll
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonAlteration
import org.koin.compose.viewmodel.koinViewModel
@Composable
@ -34,23 +38,26 @@ fun NpcRibbon(
key = { it.characterSheetId },
) {
Row(
modifier = Modifier.animateItem(),
modifier = Modifier
.animateItem()
.graphicsLayer { if (it.hideOverruled) this.alpha = 0.3f },
horizontalArrangement = Arrangement.spacedBy(space = 4.dp),
) {
CharacterPortraitRoll(
value = viewModel.roll(characterSheetId = it.characterSheetId).value,
onRightClick = {
viewModel.onPortraitRollRightClick(characterSheetId = it.characterSheetId)
},
onLeftClick = {
},
)
CharacterPortrait(
character = it,
onCharacterLeftClick = onCharacterLeftClick,
onCharacterRightClick = onCharacterRightClick,
onLevelUp = onLevelUp,
CharacterRibbonAlteration(
status = it.status,
direction = LayoutDirection.Rtl,
)
Box {
CharacterRibbonPortrait(
character = it.portrait,
onCharacterLeftClick = { onCharacterLeftClick(it.characterSheetId) },
onCharacterRightClick = { onCharacterRightClick(it.characterSheetId) },
onLevelUp = { onLevelUp(it.characterSheetId) },
)
CharacterRibbonRoll(
value = viewModel.roll(characterSheetId = it.characterSheetId).value,
)
}
}
}
}

View file

@ -6,7 +6,7 @@ import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetReposit
import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.desktop.lwa.repository.settings.model.Settings
import com.pixelized.desktop.lwa.ui.screen.campaign.player.CharacterRibbonFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.CharacterRibbonFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.CharacterRibbonViewModel
import com.pixelized.shared.lwa.model.campaign.Campaign
@ -36,11 +36,11 @@ class NpcRibbonViewModel(
return !campaign.options.showNpcs && settings.isGameMaster == true
}
override fun enableCharacterSheet(settings: Settings) : Boolean {
override fun enableCharacterSheet(settings: Settings): Boolean {
return settings.isGameMaster ?: false
}
override fun enableCharacterStats(settings: Settings) : Boolean {
override fun enableCharacterStats(settings: Settings): Boolean {
return settings.isGameMaster ?: false
}
}

View file

@ -1,6 +1,7 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.player
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.lazy.LazyColumn
@ -8,9 +9,12 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.LayoutDirection
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.CharacterPortraitRoll
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonPortrait
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonRoll
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonAlteration
import org.koin.compose.viewmodel.koinViewModel
@Composable
@ -34,22 +38,25 @@ fun PlayerRibbon(
key = { it.characterSheetId },
) {
Row(
modifier = Modifier.animateItem(),
modifier = Modifier
.animateItem()
.graphicsLayer { if (it.hideOverruled) this.alpha = 0.3f },
horizontalArrangement = Arrangement.spacedBy(space = 4.dp),
) {
CharacterPortrait(
character = it,
onCharacterLeftClick = onCharacterLeftClick,
onCharacterRightClick = onCharacterRightClick,
onLevelUp = onLevelUp,
)
CharacterPortraitRoll(
value = viewModel.roll(characterSheetId = it.characterSheetId).value,
onRightClick = {
viewModel.onPortraitRollRightClick(characterSheetId = it.characterSheetId)
},
onLeftClick = {
},
Box {
CharacterRibbonPortrait(
character = it.portrait,
onCharacterLeftClick = { onCharacterLeftClick(it.characterSheetId) },
onCharacterRightClick = { onCharacterRightClick(it.characterSheetId) },
onLevelUp = { onLevelUp(it.characterSheetId) },
)
CharacterRibbonRoll(
value = viewModel.roll(characterSheetId = it.characterSheetId).value,
)
}
CharacterRibbonAlteration(
status = it.status,
direction = LayoutDirection.Ltr,
)
}
}

View file

@ -6,7 +6,7 @@ import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetReposit
import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.desktop.lwa.repository.settings.model.Settings
import com.pixelized.desktop.lwa.ui.screen.campaign.player.CharacterRibbonFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.CharacterRibbonFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.CharacterRibbonViewModel
import com.pixelized.shared.lwa.model.campaign.Campaign

View file

@ -40,7 +40,7 @@ class CharacterSheetViewModel(
val sheetFlow = combine(
characterRepository.characterDetailFlow(characterSheetId = argument.characterSheetId),
alteration.fieldAlterationsFlow(characterSheetId = argument.characterSheetId),
alteration.activeFieldAlterationsFlow(characterSheetId = argument.characterSheetId),
transform = { characterSheet, alterations ->
factory.convertToUio(
characterSheetId = argument.characterSheetId,

View file

@ -32,6 +32,12 @@ data class LwaColors(
data class Portrait(
val levelUp: Color,
val blood: Color,
val criticalSuccess: Color,
val spacialSuccess: Color,
val success: Color,
val failure: Color,
val criticalFailure: Color,
val default: Color,
)
@Stable
@ -84,15 +90,21 @@ fun darkLwaColorTheme(
portrait: LwaColors.Portrait = LwaColors.Portrait(
levelUp = LwaColorPalette.Gold,
blood = LwaColorPalette.Blood,
),
chat: LwaColors.Chat = LwaColors.Chat(
timestamp = base.secondary,
text = base.onSurface.copy(alpha = 0.7f),
criticalSuccess = LwaColorPalette.Teal400,
spacialSuccess = LwaColorPalette.Green400,
success = LwaColorPalette.LightGreen400,
failure = LwaColorPalette.Orange400,
criticalFailure = LwaColorPalette.Red400,
default = base.primary,
),
chat: LwaColors.Chat = LwaColors.Chat(
timestamp = base.secondary,
text = base.onSurface.copy(alpha = 0.7f),
criticalSuccess = portrait.criticalSuccess,
spacialSuccess = portrait.spacialSuccess,
success = portrait.success,
failure = portrait.failure,
criticalFailure = portrait.criticalFailure,
),
): LwaColors = LwaColors(
base = base,