Add GM & NPC (UI) support. Change the Id system.

This commit is contained in:
Thomas Andres Gomez 2025-03-15 17:49:12 +01:00
parent 6b86a6c075
commit 27dba5438e
54 changed files with 816 additions and 426 deletions

View file

@ -52,7 +52,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.overlay.roll.RollHostState
import com.pixelized.desktop.lwa.ui.overlay.roll.RollOverlay
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.PlayerRibbon
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
@ -108,7 +108,7 @@ fun ApplicationScope.LwaApplication() {
size = DpSize(
width = 800.dp,
height = min(
a = 56.dp + PlayerRibbon.Default.size.height * 6 + 8.dp * 7 + 40.dp,
a = 56.dp + CharacterPortraitDefault.size.height * 6 + 8.dp * 7 + 40.dp,
b = maxWindowHeight,
),
),

View file

@ -43,7 +43,7 @@ class DataSyncViewModel(
.filter { status -> status == NetworkRepository.Status.CONNECTED }
.combine(campaignRepository.campaignFlow) { _, campaign: Campaign -> campaign }
.onEach { campaign ->
campaign.characters.keys.forEach { id ->
(campaign.characters.keys + campaign.npcs.keys).forEach { id ->
characterRepository.characterDetail(
characterSheetId = id.characterSheetId,
forceUpdate = true,

View file

@ -17,13 +17,13 @@ import com.pixelized.desktop.lwa.ui.composable.character.characteristic.Characte
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterSheetCharacteristicDialogFactory
import com.pixelized.desktop.lwa.ui.overlay.roll.RollViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.toolbar.CampaignToolbarViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.CampaignChatViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.TextMessageFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.text.CampaignChatViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.text.TextMessageFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDiminishedViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.PlayerRibbonFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.PlayerRibbonViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.CharacterRibbonFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.player.PlayerRibbonViewModel
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.CharacterSheetFactory
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.CharacterSheetViewModel
import com.pixelized.desktop.lwa.ui.screen.characterSheet.edit.CharacterSheetEditFactory
@ -33,6 +33,7 @@ import com.pixelized.desktop.lwa.ui.screen.levelup.LevelUpFactory
import com.pixelized.desktop.lwa.ui.screen.levelup.LevelUpViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.network.NetworkFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.network.NetworkViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.npc.NpcRibbonViewModel
import com.pixelized.desktop.lwa.ui.screen.gamemaster.GameMasterActionUseCase
import com.pixelized.desktop.lwa.ui.screen.gamemaster.GameMasterFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.GameMasterViewModel
@ -109,7 +110,7 @@ val factoryDependencies
factoryOf(::SkillFieldFactory)
factoryOf(::SettingsFactory)
factoryOf(::CampaignJsonFactory)
factoryOf(::PlayerRibbonFactory)
factoryOf(::CharacterRibbonFactory)
factoryOf(::CharacterDetailFactory)
factoryOf(::CharacterSheetCharacteristicDialogFactory)
factoryOf(::TextMessageFactory)
@ -127,6 +128,7 @@ val viewModelDependencies
viewModelOf(::RollHistoryViewModel)
viewModelOf(::NetworkViewModel)
viewModelOf(::PlayerRibbonViewModel)
viewModelOf(::NpcRibbonViewModel)
viewModelOf(::CharacterDetailViewModel)
viewModelOf(::CharacterDiminishedViewModel)
viewModelOf(::CharacterDetailCharacteristicDialogViewModel)

View file

@ -13,23 +13,28 @@ interface LwaClient {
suspend fun updateCharacter(sheet: CharacterSheetJson)
suspend fun deleteCharacter(id: String)
suspend fun deleteCharacterSheet(id: String)
suspend fun campaign(): CampaignJson
suspend fun campaignAddCharacter(characterSheetId: String)
suspend fun campaignDeleteCharacter(characterSheetId: String, instanceId: Int)
suspend fun campaignRemoveCharacter(characterSheetId: String, instanceId: Int)
suspend fun campaignAddNpc(characterSheetId: String)
suspend fun campaignDeleteNpc(characterSheetId: String, instanceId: Int)
suspend fun campaignRemoveNpc(characterSheetId: String, instanceId: Int)
suspend fun alterations(): List<AlterationJson>
suspend fun activeAlterations(characterSheetId: String, instanceId: Int): List<String>
suspend fun activeAlterations(
prefix: Char,
characterSheetId: String,
instanceId: Int,
): List<String>
suspend fun toggleActiveAlterations(
prefix: Char,
characterSheetId: String,
instanceId: Int,
alterationId: String,

View file

@ -2,6 +2,7 @@ package com.pixelized.desktop.lwa.network
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.CampaignJson
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson
@ -25,7 +26,7 @@ class LwaClientImpl(
.body()
override suspend fun character(id: String): CharacterSheetJson = client
.get("$root/character/detail?id=$id")
.get("$root/character/detail?characterSheetId=$id")
.body()
override suspend fun updateCharacter(sheet: CharacterSheetJson) = client
@ -35,8 +36,8 @@ class LwaClientImpl(
}
.body<Unit>()
override suspend fun deleteCharacter(id: String) = client
.delete("$root/character/delete?id=$id")
override suspend fun deleteCharacterSheet(id: String) = client
.delete("$root/character/delete?characterSheetId=$id")
.body<Unit>()
override suspend fun campaign(): CampaignJson = client
@ -49,11 +50,11 @@ class LwaClientImpl(
.put("$root/campaign/character/update?characterSheetId=$characterSheetId")
.body<Unit>()
override suspend fun campaignDeleteCharacter(
override suspend fun campaignRemoveCharacter(
characterSheetId: String,
instanceId: Int,
) = client
.delete("$root/campaign/character/delete?characterSheetId=$characterSheetId&instanceId=$instanceId")
.delete("$root/campaign/character/delete?characterSheetId=$characterSheetId&instanceId=$instanceId&prefix=${Campaign.CharacterInstance.Id.PLAYER}")
.body<Unit>()
override suspend fun campaignAddNpc(
@ -62,11 +63,11 @@ class LwaClientImpl(
.put("$root/campaign/npc/update?characterSheetId=$characterSheetId")
.body<Unit>()
override suspend fun campaignDeleteNpc(
override suspend fun campaignRemoveNpc(
characterSheetId: String,
instanceId: Int,
) = client
.delete("$root/campaign/npc/delete?characterSheetId=$characterSheetId&instanceId=$instanceId")
.delete("$root/campaign/npc/delete?characterSheetId=$characterSheetId&instanceId=$instanceId&prefix=${Campaign.CharacterInstance.Id.NPC}")
.body<Unit>()
override suspend fun alterations(): List<AlterationJson> = client
@ -74,18 +75,20 @@ class LwaClientImpl(
.body()
override suspend fun activeAlterations(
prefix: Char,
characterSheetId: String,
instanceId: Int,
): List<String> = client
.get("$root/alterations/active?characterSheetId=$characterSheetId&instanceId=$instanceId")
.get("$root/alterations/active?characterSheetId=$characterSheetId&instanceId=$instanceId&prefix=$prefix")
.body()
override suspend fun toggleActiveAlterations(
prefix: Char,
characterSheetId: String,
instanceId: Int,
alterationId: String,
) = client
.put("$root/alterations/active/toggle?characterSheetId=$characterSheetId&instanceId=$instanceId") {
.put("$root/alterations/active/toggle?characterSheetId=$characterSheetId&instanceId=$instanceId&prefix=$prefix") {
contentType(ContentType.Application.Json)
setBody(alterationId)
}

View file

@ -63,7 +63,7 @@ class AlterationRepository(
) {
// alteration was active for the character toggle it off.
store.toggleActiveAlteration(
characterInstance = characterInstanceId,
characterInstanceId = characterInstanceId,
alterationId = alterationId,
)
}

View file

@ -61,6 +61,7 @@ class AlterationStore(
characterInstanceId: CharacterInstance.Id,
): List<String> {
val request = client.activeAlterations(
prefix = characterInstanceId.prefix,
characterSheetId = characterInstanceId.characterSheetId,
instanceId = characterInstanceId.instanceId,
)
@ -76,12 +77,13 @@ class AlterationStore(
}
suspend fun toggleActiveAlteration(
characterInstance: CharacterInstance.Id,
characterInstanceId: CharacterInstance.Id,
alterationId: String,
) {
client.toggleActiveAlterations(
characterSheetId = characterInstance.characterSheetId,
instanceId = characterInstance.instanceId,
prefix = characterInstanceId.prefix,
characterSheetId = characterInstanceId.characterSheetId,
instanceId = characterInstanceId.instanceId,
alterationId = alterationId,
)
}

View file

@ -21,20 +21,46 @@ class CampaignRepository(
store.update()
}
fun characterInstanceFlow(
id: Campaign.CharacterInstance.Id,
fun instanceFlow(
characterInstanceId: Campaign.CharacterInstance.Id,
): StateFlow<Campaign.CharacterInstance> {
return campaignFlow
.mapNotNull {
it.characters[id]
it.characters[characterInstanceId] ?: it.npcs[characterInstanceId]
}
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = campaignFlow.value.character(id = id),
initialValue = instance(characterInstanceId),
)
}
fun instance(
characterInstanceId: Campaign.CharacterInstance.Id,
): Campaign.CharacterInstance {
return campaignFlow.value.let {
it.characters[characterInstanceId]
?: it.npcs[characterInstanceId]
?: Campaign.CharacterInstance.empty()
}
}
@Deprecated(message = "Check if deprecated")
fun characterInstanceFlow(
characterInstanceId: Campaign.CharacterInstance.Id,
): StateFlow<Campaign.CharacterInstance> {
return campaignFlow
.mapNotNull {
it.characters[characterInstanceId]
}
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = campaignFlow.value.character(id = characterInstanceId),
)
}
@Deprecated(message = "Check if deprecated")
fun characterInstance(
characterInstanceId: Campaign.CharacterInstance.Id,
): Campaign.CharacterInstance {

View file

@ -59,7 +59,7 @@ class CampaignStore(
characterSheetId: String,
instanceId: Int,
) {
client.campaignDeleteCharacter(
client.campaignRemoveCharacter(
characterSheetId = characterSheetId,
instanceId = instanceId,
)
@ -77,7 +77,7 @@ class CampaignStore(
characterSheetId: String,
instanceId: Int,
) {
client.campaignDeleteNpc(
client.campaignRemoveNpc(
characterSheetId = characterSheetId,
instanceId = instanceId,
)
@ -93,6 +93,7 @@ class CampaignStore(
is CampaignMessage -> {
val instanceId = Campaign.CharacterInstance.Id(
prefix = payload.prefix,
characterSheetId = payload.characterSheetId,
instanceId = payload.instanceId,
)

View file

@ -86,7 +86,7 @@ class CharacterSheetStore(
characterId: String,
) {
try {
client.deleteCharacter(id = characterId)
client.deleteCharacterSheet(id = characterId)
_detailFlow.delete(characterId = characterId)
} catch (exception: Exception) {
// TODO

View file

@ -41,7 +41,7 @@ class CharacterDetailCharacteristicDialogViewModel(
val sheet: CharacterSheet? = characterSheetRepository.characterDetail(
characterSheetId = characterInstanceId.characterSheetId,
)
val characterInstance: Campaign.CharacterInstance = campaignRepository.characterInstance(
val characterInstance: Campaign.CharacterInstance = campaignRepository.instance(
characterInstanceId = characterInstanceId,
)
val alterations: Map<String, List<FieldAlteration>> = alterationRepository.alterations(
@ -80,6 +80,7 @@ class CharacterDetailCharacteristicDialogViewModel(
// share the data through the websocket.
network.share(
payload = CampaignMessage.UpdateCharacteristic(
prefix = characterInstanceId.prefix,
characterSheetId = characterInstanceId.characterSheetId,
instanceId = characterInstanceId.instanceId,
characteristic = characteristicJson,

View file

@ -14,12 +14,17 @@ object CharacterSheetDestination {
private const val ROUTE = "character.sheet"
private const val CHARACTER_SHEET_ID = "sheetId"
private const val CHARACTER_INSTANCE_ID = "instanceId"
private const val CHARACTER_PREFIX = "prefix"
fun baseRoute() = "$ROUTE?${CHARACTER_SHEET_ID.ARG}&${CHARACTER_INSTANCE_ID.ARG}"
fun baseRoute() = ROUTE +
"?${CHARACTER_PREFIX.ARG}" +
"&${CHARACTER_INSTANCE_ID.ARG}" +
"&${CHARACTER_SHEET_ID.ARG}"
fun navigationRoute(characterInstanceId: Campaign.CharacterInstance.Id) = ROUTE +
"?$CHARACTER_SHEET_ID=${characterInstanceId.characterSheetId}" +
"&$CHARACTER_INSTANCE_ID=${characterInstanceId.instanceId}"
"?$CHARACTER_PREFIX=${characterInstanceId.prefix}" +
"&$CHARACTER_INSTANCE_ID=${characterInstanceId.instanceId}" +
"&$CHARACTER_SHEET_ID=${characterInstanceId.characterSheetId}"
fun arguments() = listOf(
navArgument(CHARACTER_SHEET_ID) {
@ -30,6 +35,10 @@ object CharacterSheetDestination {
nullable = false
type = NavType.IntType
},
navArgument(CHARACTER_PREFIX) {
nullable = false
type = NavType.StringType
}
)
data class Argument(
@ -37,6 +46,7 @@ object CharacterSheetDestination {
) {
constructor(savedStateHandle: SavedStateHandle) : this(
characterInstanceId = Campaign.CharacterInstance.Id(
savedStateHandle.get<String>(CHARACTER_PREFIX)?.getOrNull(0) ?: error("missing character id"),
savedStateHandle.get<String>(CHARACTER_SHEET_ID) ?: error("missing character id"),
savedStateHandle.get<Int>(CHARACTER_INSTANCE_ID) ?: error("missing character id"),
),

View file

@ -12,14 +12,20 @@ import com.pixelized.shared.lwa.model.campaign.Campaign
object LevelUpDestination {
private const val ROUTE = "levelUp"
private const val CHARACTER_SHEET_ID = "sheetId"
private const val CHARACTER_INSTANCE_ID = "instanceId"
private const val CHARACTER_PREFIX = "prefix"
fun baseRoute() = "${ROUTE}?${CHARACTER_SHEET_ID.ARG}&${CHARACTER_INSTANCE_ID.ARG}"
fun baseRoute() = ROUTE +
"?${CHARACTER_PREFIX.ARG}" +
"&${CHARACTER_INSTANCE_ID.ARG}" +
"&${CHARACTER_SHEET_ID.ARG}"
fun navigationRoute(characterInstanceId: Campaign.CharacterInstance.Id) = ROUTE +
"?$CHARACTER_SHEET_ID=${characterInstanceId.characterSheetId}" +
"&$CHARACTER_INSTANCE_ID=${characterInstanceId.instanceId}"
"?$CHARACTER_PREFIX=${characterInstanceId.prefix}" +
"&$CHARACTER_INSTANCE_ID=${characterInstanceId.instanceId}" +
"&$CHARACTER_SHEET_ID=${characterInstanceId.characterSheetId}"
fun arguments() = listOf(
navArgument(CHARACTER_SHEET_ID) {
@ -30,6 +36,10 @@ object LevelUpDestination {
nullable = false
type = NavType.IntType
},
navArgument(CHARACTER_PREFIX) {
nullable = false
type = NavType.StringType
}
)
data class Argument(
@ -37,6 +47,7 @@ object LevelUpDestination {
) {
constructor(savedStateHandle: SavedStateHandle) : this(
characterInstanceId = Campaign.CharacterInstance.Id(
savedStateHandle.get<String>(CHARACTER_PREFIX)?.getOrNull(0) ?: error("missing character id"),
savedStateHandle.get<String>(CHARACTER_SHEET_ID) ?: error("missing character id"),
savedStateHandle.get<Int>(CHARACTER_INSTANCE_ID) ?: error("missing character id"),
),

View file

@ -41,8 +41,7 @@ class RollViewModel(
private val alteredCharacterSheetFactory: AlteredCharacterSheetFactory,
) : ViewModel() {
private var alteredCharacterSheet: AlteredCharacterSheet? = null
private var rollAction: String? = null
private var rollSuccessValue: Int? = null
private var rollAction: RollActionUio? = null
var lastRollResult: RollResult = RollResult.Dismissed
private set
@ -67,7 +66,6 @@ class RollViewModel(
suspend fun cleanRoll() {
alteredCharacterSheet = null
rollAction = null
rollSuccessValue = null
lastRollResult = RollResult.Dismissed
@ -106,10 +104,9 @@ class RollViewModel(
alterations = alterations,
)
this.rollAction = roll.rollAction
this.rollSuccessValue = roll.rollSuccessValue
this.rollAction = roll
val rollStep = rollSuccessValue?.let {
val rollStep = roll.rollSuccessValue?.let {
skillStepUseCase.computeSkillStep(skill = it)
}
@ -119,7 +116,7 @@ class RollViewModel(
label = roll.label,
value = rollStep?.success?.last
)
_rollDifficulty.value = rollSuccessValue?.let {
_rollDifficulty.value = roll.rollSuccessValue?.let {
DifficultyUio(
open = false,
difficulty = Difficulty.NORMAL,
@ -148,7 +145,7 @@ class RollViewModel(
delay(500)
_cancellable.value = false
// compute the skill critical success to critical failure ranges.
val rollStep = rollSuccessValue?.let {
val rollStep = rollAction.rollSuccessValue?.let {
skillStepUseCase.computeSkillStep(
skill = when (_rollDifficulty.value?.difficulty) {
Difficulty.EASY -> it * 2
@ -163,7 +160,7 @@ class RollViewModel(
// compute the roll (typically use the expression inside the rollAction)
val roll = skillComputation.computeRoll(
sheet = alteredCharacterSheet,
expression = rollAction,
expression = rollAction.rollAction,
)
// check where the roll fall into the rollSteps.
@ -196,7 +193,6 @@ class RollViewModel(
launch {
shareRollResult(
alteredCharacterSheet = alteredCharacterSheet,
rollTitle = rollTitle,
roll = roll,
rollStep = rollStep,
@ -219,7 +215,7 @@ class RollViewModel(
open = false,
difficulty = difficulty,
)
val rollStep = rollSuccessValue?.let {
val rollStep = rollAction?.rollSuccessValue?.let {
skillStepUseCase.computeSkillStep(
skill = when (_rollDifficulty.value?.difficulty) {
Difficulty.EASY -> it * 2
@ -263,15 +259,18 @@ class RollViewModel(
}
private suspend fun shareRollResult(
alteredCharacterSheet: AlteredCharacterSheet,
rollTitle: RollTitleUio,
roll: Int,
rollStep: SkillStepUseCase.SkillStep?,
success: String?,
) {
val rollAction = rollAction ?: return
val payload = RollMessage(
id = RollMessage.RollId.create(),
characterSheetId = alteredCharacterSheet.id,
prefix = rollAction.characterInstanceId.prefix,
characterSheetId = rollAction.characterInstanceId.characterSheetId,
instanceId = rollAction.characterInstanceId.instanceId,
skillLabel = rollTitle.label,
rollValue = roll,
resultLabel = success,

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.composable.blur.BlurContent
import com.pixelized.desktop.lwa.ui.composable.blur.rememberBlurContentController
@ -34,16 +35,17 @@ 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.screen.campaign.chat.CampaignChat
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.CampaignChatViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.text.CampaignChat
import com.pixelized.desktop.lwa.ui.screen.campaign.text.CampaignChatViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.npc.NpcRibbon
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
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.PlayerRibbon
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.rememberTransitionAnimation
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.player.PlayerRibbon
import com.pixelized.desktop.lwa.ui.screen.campaign.toolbar.CampaignToolbar
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.DiminishedStatDialog
import com.pixelized.desktop.lwa.ui.screen.campaign.network.NetworkViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.toolbar.CampaignToolbarViewModel
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.DiminishedStatDialog
import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
@ -53,11 +55,11 @@ val LocalCampaignLayoutScope = compositionLocalOf<CampaignLayoutScope> {
@Composable
fun CampaignScreen(
characterDetailViewModel: CharacterDetailViewModel = koinViewModel(),
playerDetailViewModel: CharacterDetailViewModel = koinViewModel(key = "player"),
npcDetailViewModel: CharacterDetailViewModel = koinViewModel(key = "npc"),
characteristicDialogViewModel: CharacterDetailCharacteristicDialogViewModel = koinViewModel(),
dismissedViewModel: CharacterDiminishedViewModel = koinViewModel(),
campaignViewModel: CampaignToolbarViewModel = koinViewModel(),
networkViewModel: NetworkViewModel = koinViewModel(),
campaignChatViewModel: CampaignChatViewModel = koinViewModel(),
) {
val screen = LocalScreenController.current
@ -67,7 +69,8 @@ fun CampaignScreen(
KeyHandler {
when {
it.type == KeyEventType.KeyUp && it.key == Key.Escape -> {
characterDetailViewModel.hideCharacter()
playerDetailViewModel.hideCharacter()
npcDetailViewModel.hideCharacter()
true
}
@ -82,7 +85,7 @@ fun CampaignScreen(
modifier = Modifier.fillMaxSize(),
controller = blurController
) {
CampaignScreenLayout(
CampaignLayout(
modifier = Modifier.fillMaxSize(),
top = {
CampaignToolbar(
@ -101,25 +104,50 @@ fun CampaignScreen(
chatViewModel = campaignChatViewModel,
)
},
leftOverlay = {
leftPanel = {
PlayerRibbon(
modifier = Modifier.fillMaxHeight(),
onCharacter = {
characterDetailViewModel.showCharacter(id = it)
playerDetailViewModel.showCharacter(id = it)
},
onLevelUp = {
screen.navigateToLevelScreen(characterInstanceId = it)
}
)
},
leftOverlay = {
CharacterDetailPanel(
modifier = Modifier
.padding(all = 8.dp)
.width(width = 128.dp * 4)
.fillMaxHeight(),
transitionSpec = rememberTransitionAnimation(direction = LayoutDirection.Ltr),
blurController = blurController,
detailViewModel = npcDetailViewModel,
characterDiminishedViewModel = dismissedViewModel,
characteristicDialogViewModel = characteristicDialogViewModel,
)
},
rightPanel = {
NpcRibbon(
modifier = Modifier.fillMaxHeight(),
onCharacter = {
npcDetailViewModel.showCharacter(id = it)
},
onLevelUp = {
}
)
},
rightOverlay = {
CharacterDetailPanel(
modifier = Modifier
.padding(all = 8.dp)
.width(width = 128.dp * 4)
.fillMaxHeight(),
transitionSpec = rememberTransitionAnimation(direction = LayoutDirection.Rtl),
blurController = blurController,
detailViewModel = characterDetailViewModel,
detailViewModel = playerDetailViewModel,
characterDiminishedViewModel = dismissedViewModel,
characteristicDialogViewModel = characteristicDialogViewModel,
)
@ -166,26 +194,34 @@ fun CampaignScreen(
}
@Composable
private fun CampaignScreenLayout(
private fun CampaignLayout(
modifier: Modifier = Modifier,
top: @Composable () -> Unit,
bottom: @Composable () -> Unit,
main: @Composable () -> Unit,
chat: @Composable () -> Unit,
leftPanel: @Composable () -> Unit,
rightPanel: @Composable () -> Unit,
leftOverlay: @Composable () -> Unit,
rightOverlay: @Composable () -> Unit,
) {
val density = LocalDensity.current
val leftOverlayState = remember { mutableStateOf(DpSize.Unspecified) }
val leftPanelState = remember { mutableStateOf(DpSize.Unspecified) }
val rightOverlayState = remember { mutableStateOf(DpSize.Unspecified) }
val rightPanelState = remember { mutableStateOf(DpSize.Unspecified) }
val chatOverlayState = remember { mutableStateOf(DpSize.Unspecified) }
val scope = remember {
CampaignLayoutScope(
leftOverlay = leftOverlayState,
leftPanel = leftPanelState,
rightOverlay = rightOverlayState,
rightPanel = rightPanelState,
chatOverlay = chatOverlayState,
)
}
CompositionLocalProvider(
LocalCampaignLayoutScope provides scope,
) {
@ -194,7 +230,7 @@ private fun CampaignScreenLayout(
) {
top()
Box(
modifier = Modifier.weight(1f, fill = true),
modifier = Modifier.weight(weight = 1f, fill = true),
) {
Box(
modifier = Modifier
@ -210,6 +246,13 @@ private fun CampaignScreenLayout(
) {
chat()
}
Box(
modifier = Modifier
.align(alignment = Alignment.CenterStart)
.onSizeChanged { leftPanelState.value = it.toDp(density) },
) {
leftPanel()
}
Box(
modifier = Modifier
.align(alignment = Alignment.CenterStart)
@ -217,6 +260,13 @@ private fun CampaignScreenLayout(
) {
leftOverlay()
}
Box(
modifier = Modifier
.align(alignment = Alignment.CenterEnd)
.onSizeChanged { rightPanelState.value = it.toDp(density) },
) {
rightPanel()
}
Box(
modifier = Modifier
.align(alignment = Alignment.CenterEnd)
@ -233,7 +283,9 @@ private fun CampaignScreenLayout(
@Stable
data class CampaignLayoutScope(
val leftOverlay: State<DpSize>,
val leftPanel: State<DpSize>,
val rightOverlay: State<DpSize>,
val rightPanel: State<DpSize>,
val chatOverlay: State<DpSize>,
)

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon
package com.pixelized.desktop.lwa.ui.screen.campaign.player
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
@ -53,22 +53,33 @@ import kotlin.math.max
import kotlin.math.min
@Stable
data class PlayerPortraitUio(
object CharacterPortraitDefault {
val size = DpSize(96.dp, 128.dp)
}
@Stable
data class CharacterPortraitUio(
val id: Campaign.CharacterInstance.Id,
val portrait: String?,
val name: String,
val hp: Int,
val maxHp: Int,
val pp: Int,
val maxPp: Int,
val levelUp: Boolean,
)
val enableDetail: Boolean,
val stats: StatsDetail?,
) {
@Stable
data class StatsDetail(
val hp: Int,
val maxHp: Int,
val pp: Int,
val maxPp: Int,
)
}
@Composable
fun PlayerPortrait(
fun CharacterPortrait(
modifier: Modifier = Modifier,
size: DpSize,
character: PlayerPortraitUio,
size: DpSize = CharacterPortraitDefault.size,
character: CharacterPortraitUio,
onCharacter: (id: Campaign.CharacterInstance.Id) -> Unit,
onLevelUp: (id: Campaign.CharacterInstance.Id) -> Unit,
) {
@ -77,9 +88,9 @@ fun PlayerPortrait(
Box(
modifier = modifier
.size(size = size)
.clip(shape = remember { RoundedCornerShape(8.dp) })
.clip(shape = MaterialTheme.lwa.shapes.portrait)
.background(color = colorScheme.elevated.base1dp)
.clickable { onCharacter(character.id) },
.clickable(character.enableDetail) { onCharacter(character.id) },
) {
AnimatedContent(
targetState = character.portrait,
@ -95,11 +106,6 @@ fun PlayerPortrait(
)
}
BloodOverlay(
maxHp = character.maxHp.toFloat(),
hp = character.hp.toFloat(),
)
AnimatedVisibility(
modifier = Modifier.offset(x = (-8).dp, y = (-8).dp),
visible = character.levelUp,
@ -115,59 +121,67 @@ fun PlayerPortrait(
}
}
Column(
modifier = Modifier
.fillMaxSize()
.drawWithContent {
drawRect(brush = colorScheme.portraitBackgroundBrush)
drawContent()
character.stats?.let { stats ->
BloodOverlay(
maxHp = stats.maxHp.toFloat(),
hp = stats.hp.toFloat(),
)
Column(
modifier = Modifier
.fillMaxSize()
.drawWithContent {
drawRect(brush = colorScheme.portraitBackgroundBrush)
drawContent()
}
.padding(vertical = 2.dp, horizontal = 4.dp),
verticalArrangement = Arrangement.aligned(alignment = Alignment.Bottom),
) {
Row {
Icon(
modifier = Modifier.size(12.dp).offset(y = 3.dp),
painter = painterResource(Res.drawable.ic_heart_24dp),
contentDescription = null
)
Spacer(
modifier = Modifier.width(width = 2.dp),
)
Text(
modifier = Modifier.alignByBaseline(),
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
text = "${stats.hp}",
)
Text(
modifier = Modifier.alignByBaseline(),
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Light,
text = "/${stats.maxHp}",
)
}
Row {
Icon(
modifier = Modifier.size(12.dp).offset(y = 2.dp),
painter = painterResource(Res.drawable.ic_water_drop_24dp),
contentDescription = null
)
Spacer(
modifier = Modifier.width(width = 2.dp),
)
Text(
modifier = Modifier.alignByBaseline(),
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
text = "${stats.pp}",
)
Text(
modifier = Modifier.alignByBaseline(),
fontWeight = FontWeight.Light,
style = MaterialTheme.typography.caption,
text = "/${stats.maxPp}",
)
}
.padding(vertical = 2.dp, horizontal = 4.dp),
verticalArrangement = Arrangement.aligned(alignment = Alignment.Bottom),
) {
Row {
Icon(
modifier = Modifier.size(12.dp).offset(y = 3.dp),
painter = painterResource(Res.drawable.ic_heart_24dp),
contentDescription = null
)
Spacer(
modifier = Modifier.width(width = 2.dp),
)
Text(
modifier = Modifier.alignByBaseline(),
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
text = "${character.hp}",
)
Text(
modifier = Modifier.alignByBaseline(),
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Light,
text = "/${character.maxHp}",
)
}
Row {
Icon(
modifier = Modifier.size(12.dp).offset(y = 2.dp),
painter = painterResource(Res.drawable.ic_water_drop_24dp),
contentDescription = null
)
Spacer(
modifier = Modifier.width(width = 2.dp),
)
Text(
modifier = Modifier.alignByBaseline(),
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
text = "${character.pp}",
)
Text(
modifier = Modifier.alignByBaseline(),
fontWeight = FontWeight.Light,
style = MaterialTheme.typography.caption,
text = "/${character.maxPp}",
)
}
}
}
@ -184,7 +198,7 @@ private fun BloodOverlay(
targetValue = min(maxHp, max(0f, (maxHp - hp) / maxHp)),
animationSpec = tween(durationMillis = 350, easing = EaseOutCirc)
)
val animatedColor = animateColorAsState(
val animatedColor = animateColorAsState(
targetValue = bloodColor.copy(alpha = ((maxHp - hp) / maxHp) / 4f + .25f)
)
Box(

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon
package com.pixelized.desktop.lwa.ui.screen.campaign.player
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
@ -37,20 +37,21 @@ 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.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 PlayerPortraitRollUio(
val characterId: String,
data class CharacterPortraitRollUio(
val characterId: Campaign.CharacterInstance.Id,
val value: Int?,
val label: String?,
)
@Stable
data class PlayerPortraitRollAnimation(
data class CharacterPortraitRollAnimation(
val alpha: Animatable<Float, AnimationVector1D> = Animatable(0f),
val rotation: Animatable<Float, AnimationVector1D> = Animatable(0f),
val scale: Animatable<Float, AnimationVector1D> = Animatable(1f),
@ -58,12 +59,12 @@ data class PlayerPortraitRollAnimation(
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PlayerPortraitRoll(
fun CharacterPortraitRoll(
modifier: Modifier = Modifier,
size: DpSize,
value: PlayerPortraitRollUio?,
onLeftClick: (PlayerPortraitRollUio) -> Unit,
onRightClick: (PlayerPortraitRollUio) -> Unit,
size: DpSize = CharacterPortraitDefault.size,
value: CharacterPortraitRollUio?,
onLeftClick: (CharacterPortraitRollUio) -> Unit,
onRightClick: (CharacterPortraitRollUio) -> Unit,
) {
AnimatedContent(
modifier = modifier
@ -133,9 +134,9 @@ fun PlayerPortraitRoll(
}
@Composable
private fun diceIconAnimation(key: Any = Unit): PlayerPortraitRollAnimation {
private fun diceIconAnimation(key: Any = Unit): CharacterPortraitRollAnimation {
val animation = remember(key) {
PlayerPortraitRollAnimation()
CharacterPortraitRollAnimation()
}
LaunchedEffect(key) {
launch {

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon
package com.pixelized.desktop.lwa.ui.screen.campaign.player
import com.pixelized.shared.lwa.model.AlteredCharacterSheetFactory
import com.pixelized.shared.lwa.model.alteration.FieldAlteration
@ -7,15 +7,17 @@ import com.pixelized.shared.lwa.model.campaign.damage
import com.pixelized.shared.lwa.model.campaign.power
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet
class PlayerRibbonFactory(
class CharacterRibbonFactory(
private val alteredCharacterSheetFactory: AlteredCharacterSheetFactory,
) {
fun convertToPlayerPortraitUio(
characterSheet: CharacterSheet?,
alterations: Map<String, List<FieldAlteration>>,
characterInstanceId: Campaign.CharacterInstance.Id,
characterInstance: Campaign.CharacterInstance,
alterations: Map<String, List<FieldAlteration>>,
): PlayerPortraitUio? {
enableDetail: Boolean,
displayCharacterStats: Boolean,
): CharacterPortraitUio? {
if (characterSheet == null) return null
val alteredCharacterSheet = alteredCharacterSheetFactory.sheet(
@ -23,15 +25,20 @@ class PlayerRibbonFactory(
alterations = alterations,
)
return PlayerPortraitUio(
return CharacterPortraitUio(
id = characterInstanceId,
portrait = alteredCharacterSheet.thumbnail,
name = alteredCharacterSheet.name,
hp = alteredCharacterSheet.maxHp - characterInstance.damage,
maxHp = alteredCharacterSheet.maxHp,
pp = alteredCharacterSheet.maxPp - characterInstance.power,
maxPp = alteredCharacterSheet.maxPp,
levelUp = alteredCharacterSheet.shouldLevelUp,
enableDetail = enableDetail,
stats = takeIf { displayCharacterStats }?.let {
CharacterPortraitUio.StatsDetail(
hp = alteredCharacterSheet.maxHp - characterInstance.damage,
maxHp = alteredCharacterSheet.maxHp,
pp = alteredCharacterSheet.maxPp - characterInstance.power,
maxPp = alteredCharacterSheet.maxPp,
)
},
)
}
}

View file

@ -1,6 +1,8 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.detail
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
@ -22,9 +24,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.LocalRollHostState
import com.pixelized.desktop.lwa.ui.composable.blur.BlurContentController
@ -52,6 +56,7 @@ data class CharacterDetailPanelUio(
fun CharacterDetailPanel(
modifier: Modifier = Modifier,
blurController: BlurContentController,
transitionSpec: AnimatedContentTransitionScope<CharacterDetailPanelUio>.() -> ContentTransform = rememberTransitionAnimation(),
detailViewModel: CharacterDetailViewModel,
characteristicDialogViewModel: CharacterDetailCharacteristicDialogViewModel,
characterDiminishedViewModel: CharacterDiminishedViewModel,
@ -63,6 +68,7 @@ fun CharacterDetailPanel(
CharacterDetailAnimatedPanel(
modifier = modifier,
detail = detail,
transitionSpec = transitionSpec,
onDismissRequest = {
detailViewModel.hideCharacter()
},
@ -125,6 +131,7 @@ fun CharacterDetailPanel(
fun CharacterDetailAnimatedPanel(
modifier: Modifier = Modifier,
detail: State<CharacterDetailPanelUio>,
transitionSpec: AnimatedContentTransitionScope<CharacterDetailPanelUio>.() -> ContentTransform,
onDismissRequest: (id: Campaign.CharacterInstance.Id) -> Unit,
onDiminished: (id: Campaign.CharacterInstance.Id) -> Unit,
onHp: (id: Campaign.CharacterInstance.Id) -> Unit,
@ -140,15 +147,7 @@ fun CharacterDetailAnimatedPanel(
AnimatedContent(
modifier = Modifier.matchParentSize(),
targetState = detail.value,
transitionSpec = {
if (initialState.characterInstanceId != targetState.characterInstanceId) {
val enter = fadeIn() + slideInHorizontally { it / 2 }
val exit = fadeOut() + slideOutHorizontally { it / 2 }
enter togetherWith exit
} else {
EnterTransition.None togetherWith ExitTransition.None
}
}
transitionSpec = transitionSpec,
) {
when {
it.characterInstanceId == null -> Box(
@ -225,4 +224,23 @@ fun CharacterDetailContent(
)
}
}
}
@Composable
@Stable
fun rememberTransitionAnimation(
direction: LayoutDirection = LayoutDirection.Rtl,
) : AnimatedContentTransitionScope<CharacterDetailPanelUio>.() -> ContentTransform {
return remember {
val mul = if (direction == LayoutDirection.Rtl) 1 else -1
{
if (initialState.characterInstanceId != targetState.characterInstanceId) {
val enter = fadeIn() + slideInHorizontally { mul * it / 2 }
val exit = fadeOut() + slideOutHorizontally { mul * it / 2 }
enter togetherWith exit
} else {
EnterTransition.None togetherWith ExitTransition.None
}
}
}
}

View file

@ -31,7 +31,7 @@ class CharacterDetailViewModel(
CharacterDetailPanelUio(
characterInstanceId = characterInstanceId,
header = combine(
campaignRepository.characterInstanceFlow(id = characterInstanceId),
campaignRepository.instanceFlow(characterInstanceId = characterInstanceId),
characterSheetRepository.characterDetailFlow(characterSheetId = characterInstanceId.characterSheetId),
alterationRepository.alterationsFlow(characterInstanceId = characterInstanceId),
) { characterInstance, characterSheet, alterations ->
@ -47,7 +47,7 @@ class CharacterDetailViewModel(
initialValue = null,
),
sheet = combine(
campaignRepository.characterInstanceFlow(id = characterInstanceId),
campaignRepository.instanceFlow(characterInstanceId = characterInstanceId),
characterSheetRepository.characterDetailFlow(characterSheetId = characterInstanceId.characterSheetId),
alterationRepository.alterationsFlow(characterInstanceId = characterInstanceId),
) { characterInstance, characterSheet, alterations ->

View file

@ -57,6 +57,7 @@ class CharacterDiminishedViewModel(
val diminished = dialog.value().text.toIntOrNull() ?: 0
networkRepository.share(
payload = CampaignMessage.UpdateDiminished(
prefix = dialog.characterInstanceId.prefix,
characterSheetId = dialog.characterInstanceId.characterSheetId,
instanceId = dialog.characterInstanceId.instanceId,
diminished = diminished,

View file

@ -1,88 +0,0 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon
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.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.stateIn
import java.text.Collator
class PlayerRibbonViewModel(
private val rollHistoryRepository: RollHistoryRepository,
private val settingsRepository: SettingsRepository,
characterRepository: CharacterSheetRepository,
alterationRepository: AlterationRepository,
private val ribbonFactory: PlayerRibbonFactory,
campaignRepository: CampaignRepository,
) : ViewModel() {
@OptIn(ExperimentalCoroutinesApi::class)
val characters: StateFlow<List<PlayerPortraitUio>> = campaignRepository.campaignFlow
.flatMapMerge { campaign ->
combine<PlayerPortraitUio?, List<PlayerPortraitUio>>(
flows = campaign.characters.map { entry ->
combine(
characterRepository.characterDetailFlow(characterSheetId = entry.key.characterSheetId),
alterationRepository.alterationsFlow(characterInstanceId = entry.key),
) { sheet, alterations ->
ribbonFactory.convertToPlayerPortraitUio(
characterSheet = sheet,
characterInstanceId = entry.key,
characterInstance = entry.value,
alterations = alterations,
)
}
},
transform = { headers ->
headers.mapNotNull { it }
.sortedWith(compareBy(Collator.getInstance()) { it.name })
.toList()
}
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
private val rolls = hashMapOf<String, MutableState<PlayerPortraitRollUio?>>()
@Composable
@Stable
fun roll(characterSheetId: String): State<PlayerPortraitRollUio?> {
val state = rolls.getOrPut(characterSheetId) { mutableStateOf(null) }
LaunchedEffect(characterSheetId) {
rollHistoryRepository.rolls.collect { roll ->
if (settingsRepository.settings().dynamicDice) {
if (roll.characterSheetId == characterSheetId) {
state.value = PlayerPortraitRollUio(
characterId = characterSheetId,
value = roll.rollValue,
label = roll.resultLabel?.split(" ")?.joinToString(separator = "\n") { it }
)
}
}
}
}
return state
}
fun onPortraitRollRightClick(characterId: String) {
rolls[characterId]?.value = null
}
}

View file

@ -0,0 +1,57 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.npc
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.lazy.LazyColumn
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.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
@Composable
fun NpcRibbon(
modifier: Modifier = Modifier,
viewModel: NpcRibbonViewModel = koinViewModel(),
padding: PaddingValues = PaddingValues(all = 8.dp),
onCharacter: (id: Campaign.CharacterInstance.Id) -> Unit,
onLevelUp: (id: Campaign.CharacterInstance.Id) -> Unit,
) {
val characters = viewModel.characters.collectAsState()
LazyColumn(
modifier = modifier,
contentPadding = padding,
verticalArrangement = Arrangement.spacedBy(space = 8.dp)
) {
items(
items = characters.value,
key = { it.id },
) {
Row {
CharacterPortraitRoll(
size = CharacterPortraitDefault.size,
value = viewModel.roll(characterId = it.id).value,
onRightClick = {
viewModel.onPortraitRollRightClick(characterId = it.characterId)
},
onLeftClick = {
},
)
CharacterPortrait(
size = CharacterPortraitDefault.size,
character = it,
onCharacter = onCharacter,
onLevelUp = onLevelUp,
)
}
}
}
}

View file

@ -0,0 +1,114 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.npc
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.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
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.shared.lwa.model.campaign.Campaign
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.stateIn
import java.text.Collator
private typealias CharacterId = Campaign.CharacterInstance.Id
class NpcRibbonViewModel(
private val rollHistoryRepository: RollHistoryRepository,
private val settingsRepository: SettingsRepository,
characterRepository: CharacterSheetRepository,
alterationRepository: AlterationRepository,
campaignRepository: CampaignRepository,
private val ribbonFactory: CharacterRibbonFactory,
) : ViewModel() {
private val rolls = hashMapOf<CharacterId, MutableState<CharacterPortraitRollUio?>>()
@OptIn(ExperimentalCoroutinesApi::class)
val characters: StateFlow<List<CharacterPortraitUio>> = campaignRepository.campaignFlow
.flatMapMerge { campaign ->
if (campaign.npcs.isEmpty()) {
flowOf(emptyList())
} else {
combine<CharacterPortraitUio?, List<CharacterPortraitUio>>(
flows = campaign.npcs.map { entry ->
combine(
characterRepository.characterDetailFlow(characterSheetId = entry.key.characterSheetId),
alterationRepository.alterationsFlow(characterInstanceId = entry.key),
) { sheet, alterations ->
ribbonFactory.convertToPlayerPortraitUio(
characterSheet = sheet,
alterations = alterations,
characterInstanceId = entry.key,
characterInstance = entry.value,
enableDetail = settingsRepository.settings().isGM,
displayCharacterStats = settingsRepository.settings().isGM,
)
}
},
transform = { headers ->
headers.mapNotNull { it }
.sortedWith(compareBy(Collator.getInstance()) { it.name })
.toList()
}
)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
@Composable
@Stable
fun roll(
characterId: Campaign.CharacterInstance.Id,
): State<CharacterPortraitRollUio?> {
val state = rolls.getOrPut(characterId) { mutableStateOf(null) }
LaunchedEffect(characterId) {
combine(
settingsRepository.settingsFlow(),
rollHistoryRepository.rolls,
) { settings, roll ->
if (settings.dynamicDice &&
characterId.equals(roll.prefix, roll.characterSheetId, roll.instanceId)
) {
state.value = CharacterPortraitRollUio(
characterId = Campaign.CharacterInstance.Id(
prefix = characterId.prefix,
characterSheetId = characterId.characterSheetId,
instanceId = characterId.instanceId,
),
value = roll.rollValue,
label = roll.resultLabel?.split(" ")?.joinToString(separator = "\n") { it }
)
}
}.launchIn(this)
}
return state
}
fun onPortraitRollRightClick(
characterId: Campaign.CharacterInstance.Id,
) {
rolls[characterId]?.value = null
}
}

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.player
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
@ -8,26 +8,21 @@ 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.unit.DpSize
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.shared.lwa.model.campaign.Campaign
import org.koin.compose.viewmodel.koinViewModel
object PlayerRibbon {
object Default {
val size = DpSize(96.dp, 128.dp)
}
}
@Composable
fun PlayerRibbon(
modifier: Modifier = Modifier,
playerRibbonViewModel: PlayerRibbonViewModel = koinViewModel(),
viewModel: PlayerRibbonViewModel = koinViewModel(),
padding: PaddingValues = PaddingValues(all = 8.dp),
onCharacter: (id: Campaign.CharacterInstance.Id) -> Unit,
onLevelUp: (id: Campaign.CharacterInstance.Id) -> Unit,
) {
val characters = playerRibbonViewModel.characters.collectAsState()
val characters = viewModel.characters.collectAsState()
LazyColumn(
modifier = modifier,
@ -39,17 +34,15 @@ fun PlayerRibbon(
key = { it.id },
) {
Row {
PlayerPortrait(
size = PlayerRibbon.Default.size,
CharacterPortrait(
character = it,
onCharacter = onCharacter,
onLevelUp = onLevelUp,
)
PlayerPortraitRoll(
size = PlayerRibbon.Default.size,
value = playerRibbonViewModel.roll(characterSheetId = it.id.characterSheetId).value,
CharacterPortraitRoll(
value = viewModel.roll(characterId = it.id).value,
onRightClick = {
playerRibbonViewModel.onPortraitRollRightClick(characterId = it.characterId)
viewModel.onPortraitRollRightClick(characterId = it.characterId)
},
onLeftClick = {

View file

@ -0,0 +1,115 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.player
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.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
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.shared.lwa.model.campaign.Campaign
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.stateIn
import java.text.Collator
private typealias CharacterId = Campaign.CharacterInstance.Id
class PlayerRibbonViewModel(
private val rollHistoryRepository: RollHistoryRepository,
private val settingsRepository: SettingsRepository,
characterRepository: CharacterSheetRepository,
alterationRepository: AlterationRepository,
campaignRepository: CampaignRepository,
private val ribbonFactory: CharacterRibbonFactory,
) : ViewModel() {
private val rolls = hashMapOf<CharacterId, MutableState<CharacterPortraitRollUio?>>()
@OptIn(ExperimentalCoroutinesApi::class)
val characters: StateFlow<List<CharacterPortraitUio>> = campaignRepository.campaignFlow
.flatMapMerge { campaign ->
if (campaign.characters.isEmpty()) {
flowOf(emptyList())
} else {
combine<CharacterPortraitUio?, List<CharacterPortraitUio>>(
flows = campaign.characters.map { entry ->
combine(
characterRepository.characterDetailFlow(characterSheetId = entry.key.characterSheetId),
alterationRepository.alterationsFlow(characterInstanceId = entry.key),
) { sheet, alterations ->
ribbonFactory.convertToPlayerPortraitUio(
characterSheet = sheet,
alterations = alterations,
characterInstanceId = entry.key,
characterInstance = entry.value,
enableDetail = true,
displayCharacterStats = true,
)
}
},
transform = { headers ->
headers.mapNotNull { it }
.sortedWith(compareBy(Collator.getInstance()) { it.name })
.toList()
}
)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
@Composable
@Stable
fun roll(
characterId: Campaign.CharacterInstance.Id,
): State<CharacterPortraitRollUio?> {
val state = rolls.getOrPut(characterId) { mutableStateOf(null) }
LaunchedEffect(characterId) {
combine(
settingsRepository.settingsFlow(),
rollHistoryRepository.rolls,
) { settings, roll ->
if (settings.dynamicDice &&
characterId.equals(roll.prefix, roll.characterSheetId, roll.instanceId)
) {
state.value = CharacterPortraitRollUio(
characterId = Campaign.CharacterInstance.Id(
prefix = characterId.prefix,
characterSheetId = characterId.characterSheetId,
instanceId = characterId.instanceId,
),
value = roll.rollValue,
label = roll.resultLabel?.split(" ")?.joinToString(separator = "\n") { it }
)
}
}.launchIn(this)
}
return state
}
fun onPortraitRollRightClick(
characterId: Campaign.CharacterInstance.Id,
) {
rolls[characterId]?.value = null
}
}

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.chat
package com.pixelized.desktop.lwa.ui.screen.campaign.text
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
@ -36,14 +36,14 @@ import com.pixelized.desktop.lwa.repository.settings.model.Settings
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.chat.text.CharacteristicTextMessage
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.text.CharacteristicTextMessageUio
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.text.DiminishedTextMessage
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.text.DiminishedTextMessageUio
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.text.RollTextMessage
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.text.RollTextMessageUio
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.text.TextMessage
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.PlayerRibbon
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
import com.pixelized.desktop.lwa.ui.screen.campaign.text.messages.DiminishedTextMessageUio
import com.pixelized.desktop.lwa.ui.screen.campaign.text.messages.RollTextMessage
import com.pixelized.desktop.lwa.ui.screen.campaign.text.messages.RollTextMessageUio
import com.pixelized.desktop.lwa.ui.screen.campaign.text.messages.TextMessage
import com.pixelized.desktop.lwa.ui.theme.lwa
import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
@ -73,7 +73,7 @@ fun CampaignChat(
modifier = modifier
.size(
width = animatedChatWidth.value,
height = PlayerRibbon.Default.size.height * 2 + 8.dp,
height = CharacterPortraitDefault.size.height * 2 + 8.dp,
)
.graphicsLayer {
alpha = chatViewModel.chatAnimatedVisibility.value
@ -154,7 +154,7 @@ private fun rememberAnimatedChatWidth(
val maxChatWidth = 64.dp * 12
val windowWidth = windowsState.size.width
if (windowWidth != Dp.Unspecified) {
val width = windowWidth - campaignScreenScope.leftOverlay.value.width - 16.dp
val width = windowWidth - campaignScreenScope.leftPanel.value.width - 16.dp
min(max(width, minChatWidth), maxChatWidth)
} else {
minChatWidth

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.chat
package com.pixelized.desktop.lwa.ui.screen.campaign.text
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
@ -6,7 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.text.TextMessage
import com.pixelized.desktop.lwa.ui.screen.campaign.text.messages.TextMessage
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.mapNotNull

View file

@ -1,11 +1,11 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.chat
package com.pixelized.desktop.lwa.ui.screen.campaign.text
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.text.CharacteristicTextMessageUio
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.text.DiminishedTextMessageUio
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.text.RollTextMessageUio
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.text.TextMessage
import com.pixelized.desktop.lwa.ui.screen.campaign.text.messages.CharacteristicTextMessageUio
import com.pixelized.desktop.lwa.ui.screen.campaign.text.messages.DiminishedTextMessageUio
import com.pixelized.desktop.lwa.ui.screen.campaign.text.messages.RollTextMessageUio
import com.pixelized.desktop.lwa.ui.screen.campaign.text.messages.TextMessage
import com.pixelized.shared.lwa.model.AlteredCharacterSheetFactory
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.CampaignJsonV1.CharacterInstanceJsonV1.CharacteristicV1.Damage
@ -79,6 +79,7 @@ class TextMessageFactory(
characterSheetId = payload.characterSheetId,
) ?: return null
val characterInstanceId = Campaign.CharacterInstance.Id(
prefix = payload.prefix,
characterSheetId = payload.characterSheetId,
instanceId = payload.instanceId,
)

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.chat.text
package com.pixelized.desktop.lwa.ui.screen.campaign.text.messages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.chat.text
package com.pixelized.desktop.lwa.ui.screen.campaign.text.messages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.chat.text
package com.pixelized.desktop.lwa.ui.screen.campaign.text.messages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.chat.text
package com.pixelized.desktop.lwa.ui.screen.campaign.text.messages
sealed interface TextMessage {
val id : String

View file

@ -51,7 +51,7 @@ class CharacterSheetViewModel(
val diminishedDialog: State<DiminishedStatDialogUio?> get() = _diminishedDialog
val diminishedValueFlow: StateFlow<Int?> = campaignRepository
.characterInstanceFlow(id = argument.characterInstanceId)
.characterInstanceFlow(characterInstanceId = argument.characterInstanceId)
.map { instance -> instance.diminished.takeIf { it > 0 } }
.stateIn(scope = viewModelScope, SharingStarted.Lazily, null)
@ -144,6 +144,7 @@ class CharacterSheetViewModel(
val diminished = dialog.value().text.toIntOrNull() ?: 0
network.share(
payload = CampaignMessage.UpdateDiminished(
prefix = dialog.characterInstanceId.prefix,
characterSheetId = dialog.characterInstanceId.characterSheetId,
instanceId = dialog.characterInstanceId.instanceId,
diminished = diminished,

View file

@ -6,7 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackUio
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.CharacterSheetDestination
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.LevelUpDestination
import com.pixelized.desktop.lwa.ui.overlay.roll.RollResult
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId
import kotlinx.coroutines.flow.MutableSharedFlow
@ -26,7 +26,7 @@ class LevelUpViewModel(
private val levelUpFactory: LevelUpFactory,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val argument = CharacterSheetDestination.Argument(savedStateHandle)
private val argument = LevelUpDestination.Argument(savedStateHandle)
private val _errors = MutableSharedFlow<ErrorSnackUio>()
val error: SharedFlow<ErrorSnackUio> = _errors

View file

@ -9,6 +9,7 @@ import androidx.compose.ui.unit.dp
@Stable
data class LwaShapes(
val portrait: Shape,
val panel: Shape,
val settings: Shape,
)
@ -16,10 +17,12 @@ data class LwaShapes(
@Stable
@Composable
fun lwaShapes(
panel: Shape = RoundedCornerShape(16.dp),
portrait: Shape = RoundedCornerShape(8.dp),
panel: Shape = RoundedCornerShape(8.dp),
settings: Shape = RoundedCornerShape(8.dp),
): LwaShapes = remember {
LwaShapes(
portrait = portrait,
panel = panel,
settings = settings,
)