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(
@ -226,3 +225,22 @@ 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,
)

View file

@ -30,15 +30,15 @@ class CharacterSheetService(
return sheets.map { factory.convertToPreviewJson(sheet = it.value) }
}
fun character(id: String): CharacterSheetJson? {
fun characterSheet(id: String): CharacterSheetJson? {
return sheets[id]?.let(factory::convertToJson)
}
suspend fun updateCharacter(character: CharacterSheetJson) {
suspend fun updateCharacterSheet(character: CharacterSheetJson) {
return store.save(sheet = factory.convertFromJson(character))
}
fun deleteCharacter(characterId: String): Boolean {
fun deleteCharacterSheet(characterId: String): Boolean {
return store.delete(id = characterId)
}

View file

@ -27,6 +27,7 @@ class Engine(
is CampaignMessage -> {
val instanceId = Campaign.CharacterInstance.Id(
prefix = data.prefix,
characterSheetId = data.characterSheetId,
instanceId = data.instanceId,
)

View file

@ -4,7 +4,7 @@ package com.pixelized.server.lwa.server
import com.pixelized.server.lwa.server.rest.alteration.getActiveAlteration
import com.pixelized.server.lwa.server.rest.alteration.getAlteration
import com.pixelized.server.lwa.server.rest.alteration.putActiveAlteration
import com.pixelized.server.lwa.server.rest.campaign.deleteCampaignCharacter
import com.pixelized.server.lwa.server.rest.campaign.removeCampaignCharacter
import com.pixelized.server.lwa.server.rest.campaign.deleteCampaignNpc
import com.pixelized.server.lwa.server.rest.campaign.getCampaign
import com.pixelized.server.lwa.server.rest.campaign.putCampaignCharacter
@ -142,7 +142,7 @@ class LocalServer {
)
delete(
path = "/delete",
body = engine.deleteCampaignCharacter(),
body = engine.removeCampaignCharacter(),
)
}
route(path = "/npc") {

View file

@ -1,26 +1,25 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.server.lwa.utils.extentions.characterInstanceId
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
fun Engine.getActiveAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
// get the query parameter
val characterSheetId = call.queryParameters["characterSheetId"]
val instanceId = call.queryParameters["instanceId"]?.toIntOrNull()
// build the character instance id.
val id = if (characterSheetId != null && instanceId != null) {
Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId
try {
// get the query parameter
val characterInstanceId = call.queryParameters.characterInstanceId
// fetch the data from the service
val data = alterationService.active(characterInstanceId = characterInstanceId)
// respond to the client.
call.respond(data)
} catch (exception: Exception) {
call.respondText(
text = exception.localizedMessage,
status = HttpStatusCode.UnprocessableEntity,
)
} else {
null
}
// fetch the data from the service
val data = id?.let { alterationService.active(it) } ?: emptyList()
// respond to the client.
call.respond(data)
}
}

View file

@ -1,7 +1,7 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.server.lwa.utils.extentions.characterInstanceId
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.RestSynchronisation
import io.ktor.http.HttpStatusCode
@ -10,54 +10,45 @@ import io.ktor.server.response.respondText
fun Engine.putActiveAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
// fetch the query parameters
val characterSheetId = call.queryParameters["characterSheetId"]
val instanceId = call.queryParameters["instanceId"]?.toIntOrNull()
val alterationId = call.receive<String>()
try {
// get the query parameter
val characterInstanceId = call.queryParameters.characterInstanceId
// build the characterInstanceId from the parameters
val characterInstanceId = if (characterSheetId != null && instanceId != null) {
Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId
)
} else {
null
}
// fetch the query parameters
val alterationId = call.receive<String>()
// Update the alteration
val updated = characterInstanceId?.let {
alterationService.toggleActiveAlteration(
characterInstanceId = it,
// Update the alteration
val updated = alterationService.toggleActiveAlteration(
characterInstanceId = characterInstanceId,
alterationId = alterationId,
)
} ?: false
// build the Http response & send it
val code = when (updated) {
true -> HttpStatusCode.Accepted
else -> HttpStatusCode.UnprocessableEntity
}
call.respondText(
text = "$code",
status = code,
)
// share the modification to all client through the websocket.
characterInstanceId?.let {
if (!updated) {
error("Unexpected error occurred when toggling the alteration (id:$alterationId) for the character (id:$characterInstanceId)")
}
// build the Http response & send it
call.respondText(
text = "$HttpStatusCode.Accepted",
status = HttpStatusCode.Accepted,
)
// share the modification to all client through the websocket.
webSocket.emit(
Message(
from = "Server",
value = RestSynchronisation.ToggleActiveAlteration(
characterId = campaignJsonFactory.convertToJson(id = it),
characterId = campaignJsonFactory.convertToJson(id = characterInstanceId),
alterationId = alterationId,
active = alterationService.isAlterationActive(
characterInstanceId = it,
characterInstanceId = characterInstanceId,
alterationId = alterationId
),
),
)
)
} catch (exception: Exception) {
call.respondText(
text = exception.localizedMessage,
status = HttpStatusCode.UnprocessableEntity,
)
}
}
}

View file

@ -1,38 +1,39 @@
package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.server.lwa.utils.extentions.characterInstanceId
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.RestSynchronisation
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
fun Engine.deleteCampaignCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.removeCampaignCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
val characterSheetId = call.queryParameters["characterSheetId"]
val instanceId = call.queryParameters["instanceId"]?.toIntOrNull()
val id = if (characterSheetId != null && instanceId != null) {
Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId
try {
// get the query parameter
val characterInstanceId = call.queryParameters.characterInstanceId
// remove the character form the party
val updated = campaignService.removeCharacter(characterInstanceId = characterInstanceId)
// error case
if (!updated) {
error("Unexpected error when removing character (id:$characterInstanceId) from party.")
}
// API & WebSocket responses
call.respondText(
text = "$HttpStatusCode.Accepted",
status = HttpStatusCode.Accepted,
)
} else {
null
}
val updated = id?.let { campaignService.removeCharacter(it) } ?: false
val code = when (updated) {
true -> HttpStatusCode.Accepted
else -> HttpStatusCode.UnprocessableEntity
}
call.respondText(
text = "$code",
status = code,
)
webSocket.emit(
Message(
from = "Server",
value = RestSynchronisation.Campaign,
webSocket.emit(
Message(
from = "Server",
value = RestSynchronisation.Campaign,
)
)
)
} catch (exception: Exception) {
call.respondText(
text = exception.localizedMessage,
status = HttpStatusCode.UnprocessableEntity,
)
}
}
}

View file

@ -1,7 +1,7 @@
package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.server.lwa.utils.extentions.characterInstanceId
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.RestSynchronisation
import io.ktor.http.HttpStatusCode
@ -9,30 +9,31 @@ import io.ktor.server.response.respondText
fun Engine.deleteCampaignNpc(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
val characterSheetId = call.queryParameters["characterSheetId"]
val instanceId = call.queryParameters["instanceId"]?.toIntOrNull()
val id = if (characterSheetId != null && instanceId != null) {
Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId
try {
// get the query parameter
val characterInstanceId = call.queryParameters.characterInstanceId
// remove the character form the party
val updated = campaignService.removeNpc(npcInstanceId = characterInstanceId)
// error case
if (!updated) {
error("Unexpected error when removing character (id:$characterInstanceId) from npcs.")
}
// API & WebSocket responses
call.respondText(
text = "$HttpStatusCode.Accepted",
status = HttpStatusCode.Accepted,
)
} else {
null
}
val updated = id?.let { campaignService.removeNpc(it) } ?: false
val code = when (updated) {
true -> HttpStatusCode.Accepted
else -> HttpStatusCode.UnprocessableEntity
}
call.respondText(
text = "$code",
status = code,
)
webSocket.emit(
Message(
from = "Server",
value = RestSynchronisation.Campaign,
webSocket.emit(
Message(
from = "Server",
value = RestSynchronisation.Campaign,
)
)
)
} catch (exception: Exception) {
call.respondText(
text = exception.localizedMessage,
status = HttpStatusCode.UnprocessableEntity,
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.RestSynchronisation
@ -10,25 +11,26 @@ import io.ktor.server.response.respondText
fun Engine.putCampaignCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
try {
val characterSheetId = call.queryParameters["characterSheetId"]
?: error("missing character sheet id")
// get the query parameter
val characterSheetId = call.queryParameters.characterSheetId
// check if the character is already in the party.
val instanceId = campaignService.campaign().characters.keys
.firstOrNull { key -> key.characterSheetId == characterSheetId }
// handle the error case.
if (instanceId != null) {
error("Character Already in party")
error("Character (characterSheetId:$characterSheetId) Already in party")
}
// create the instance id for the character.
val id = Campaign.CharacterInstance.Id(
prefix = Campaign.CharacterInstance.Id.PLAYER,
characterSheetId = characterSheetId,
instanceId = 0,
)
// add the character to the party.
if (campaignService.addCharacter(id).not()) {
error("Unexpected error occurred when the character instance was added to the party")
}
// API & WebSocket responses.
call.respondText(
text = "Character $characterSheetId successfully added to the party",
status = HttpStatusCode.Accepted,

View file

@ -1,6 +1,7 @@
package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.RestSynchronisation
@ -10,9 +11,9 @@ import io.ktor.server.response.respondText
fun Engine.putCampaignNpc(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
try {
val characterSheetId = call.queryParameters["characterSheetId"]
?: error("missing character sheet id")
// get the query parameter
val characterSheetId = call.queryParameters.characterSheetId
// compute the npc id base on similar character sheets.
val instanceId = campaignService.campaign().npcs.keys
.filter { it.characterSheetId == characterSheetId }
.reduceOrNull { acc, id ->
@ -22,16 +23,17 @@ fun Engine.putCampaignNpc(): suspend io.ktor.server.routing.RoutingContext.() ->
acc
}
}
// create the instance id for the character.
val id = Campaign.CharacterInstance.Id(
prefix = Campaign.CharacterInstance.Id.NPC,
characterSheetId = characterSheetId,
instanceId = instanceId?.let { it.instanceId + 1 } ?: 0,
)
// add the character to the npcs.
if (campaignService.addNpc(id).not()) {
error("Unexpected error occurred when the character instance was added to the npcs")
}
// API & WebSocket responses.
call.respondText(
text = "Character $characterSheetId successfully added to the npcs",
status = HttpStatusCode.Accepted,

View file

@ -1,6 +1,7 @@
package com.pixelized.server.lwa.server.rest.character
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.RestSynchronisation
import io.ktor.http.HttpStatusCode
@ -8,10 +9,10 @@ import io.ktor.server.response.respondText
fun Engine.deleteCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
val id = call.parameters["id"]
val deleted = id?.let(characterService::deleteCharacter) ?: false
val characterSheetId = call.parameters.characterSheetId
val deleted = characterService.deleteCharacterSheet(characterId = characterSheetId)
if (deleted && id != null) {
if (deleted) {
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
@ -19,7 +20,7 @@ fun Engine.deleteCharacter(): suspend io.ktor.server.routing.RoutingContext.() -
webSocket.emit(
Message(
from = "Server",
value = RestSynchronisation.CharacterDelete(characterId = id),
value = RestSynchronisation.CharacterDelete(characterId = characterSheetId),
)
)
} else {

View file

@ -1,15 +1,16 @@
package com.pixelized.server.lwa.server.rest.character
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
fun Engine.getCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
val id = call.queryParameters["id"]
val body: CharacterSheetJson? = id?.let(characterService::character)
val id = call.queryParameters.characterSheetId
val body = characterService.characterSheet(id)
if (body != null) {
call.respond(body)
} else {

View file

@ -11,7 +11,7 @@ import io.ktor.server.response.respondText
fun Engine.putCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
val form = call.receive<CharacterSheetJson>()
characterService.updateCharacter(
characterService.updateCharacterSheet(
character = form
)
call.respondText(

View file

@ -0,0 +1,20 @@
package com.pixelized.server.lwa.utils.extentions
import com.pixelized.shared.lwa.model.campaign.Campaign
import io.ktor.http.Parameters
val Parameters.characterInstanceId: Campaign.CharacterInstance.Id
get() = Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId,
prefix = prefix,
)
val Parameters.characterSheetId
get() = this["characterSheetId"] ?: error("Missing character sheet id.")
val Parameters.instanceId: Int
get() = this["instanceId"]?.toIntOrNull() ?: error("Missing character instance id.")
val Parameters.prefix: Char
get() = this["prefix"]?.get(0) ?: error("Missing character prefix.")

View file

@ -10,9 +10,25 @@ data class Campaign(
val diminished: Int,
) {
data class Id(
val prefix: Char,
val characterSheetId: String,
val instanceId: Int,
)
) {
fun equals(
prefix: Char,
characterSheetId: String?,
instanceId: Int?,
): Boolean {
return this.prefix == prefix &&
this.characterSheetId == characterSheetId &&
this.instanceId == instanceId
}
companion object {
const val PLAYER = 'c'
const val NPC = 'n'
}
}
enum class Characteristic {
Damage,

View file

@ -65,7 +65,7 @@ class CampaignJsonFactory(
fun convertToJson(
id: Campaign.CharacterInstance.Id,
): String {
return "${String.format("%03d", id.instanceId)}-${id.characterSheetId}"
return "${id.prefix}-${String.format("%03d", id.instanceId)}-${id.characterSheetId}"
}
fun convertToJson(

View file

@ -33,8 +33,9 @@ class CampaignJsonV1Factory {
characterInstanceIdJson: String,
): Campaign.CharacterInstance.Id {
return Campaign.CharacterInstance.Id(
characterSheetId = characterInstanceIdJson.drop(4), // drop first 3 number then the -
instanceId = characterInstanceIdJson.take(3).toIntOrNull() ?: 0,
prefix = characterInstanceIdJson.take(1)[0],
characterSheetId = characterInstanceIdJson.drop(2 + 4), // drop the char then the - then the first 3 number then the -
instanceId = characterInstanceIdJson.drop(2).take(3).toIntOrNull() ?: 0,
)
}

View file

@ -5,11 +5,13 @@ import kotlinx.serialization.Serializable
@Serializable
sealed interface CampaignMessage : MessagePayload {
val prefix: Char
val characterSheetId: String
val instanceId: Int
@Serializable
data class UpdateCharacteristic(
override val prefix: Char,
override val characterSheetId: String,
override val instanceId: Int,
val characteristic: CampaignJsonV1.CharacterInstanceJsonV1.CharacteristicV1,
@ -18,6 +20,7 @@ sealed interface CampaignMessage : MessagePayload {
@Serializable
data class UpdateDiminished(
override val prefix: Char,
override val characterSheetId: String,
override val instanceId: Int,
val diminished: Int,

View file

@ -6,7 +6,9 @@ import java.util.UUID
@Serializable
data class RollMessage(
val id: RollId,
val prefix: Char,
val characterSheetId: String,
val instanceId: Int?,
val skillLabel: String,
val rollValue: Int,
val resultLabel: String? = null,