diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 0bd91d4..0cc425a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -34,6 +34,7 @@ kotlin { // injection implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) + implementation(libs.engawapg.zoomable) // composable component. implementation(libs.coil.compose) implementation(libs.coil.network.ktor) diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_center_focus_weak_24dp.xml b/composeApp/src/commonMain/composeResources/drawable/ic_center_focus_weak_24dp.xml new file mode 100644 index 0000000..a92214c --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_center_focus_weak_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_zoom_in_map_24dp.xml b/composeApp/src/commonMain/composeResources/drawable/ic_zoom_in_map_24dp.xml new file mode 100644 index 0000000..144dc6c --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_zoom_in_map_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_zoom_out_map_24dp.xml b/composeApp/src/commonMain/composeResources/drawable/ic_zoom_out_map_24dp.xml new file mode 100644 index 0000000..6cdc80e --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_zoom_out_map_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/image_dahome_maps.webp b/composeApp/src/commonMain/composeResources/drawable/image_dahome_maps.webp new file mode 100644 index 0000000..28f7f73 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/image_dahome_maps.webp differ diff --git a/composeApp/src/commonMain/composeResources/drawable/image_dahome_regions.webp b/composeApp/src/commonMain/composeResources/drawable/image_dahome_regions.webp new file mode 100644 index 0000000..4927211 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/image_dahome_regions.webp differ diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/Module.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/Module.kt index 888e447..650a4cf 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/Module.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/Module.kt @@ -40,8 +40,8 @@ import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.sheet.Characte import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailPanelViewModel import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.header.CharacterDetailHeaderFactory import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.inventory.CharacterDetailInventoryFactory -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.npc.NpcRibbonViewModel -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.player.PlayerRibbonViewModel +import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.NpcRibbonViewModel +import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.PlayerRibbonViewModel 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.toolbar.CampaignToolbarViewModel diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/scene/Camera.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/scene/Camera.kt new file mode 100644 index 0000000..c9eae2f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/scene/Camera.kt @@ -0,0 +1,101 @@ +package com.pixelized.desktop.lwa.ui.composable.scene + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.util.fastRoundToInt + +@Stable +class Camera( + private val initialZoom: Float = 2f, + private val initialOffset: IntOffset = IntOffset.Zero, +) { + private var _zoom = Animatable( + initialValue = initialZoom, + typeConverter = Float.VectorConverter, + ) + val zoom: Float get() = _zoom.value + + private var _offset = Animatable( + initialValue = initialOffset, + typeConverter = IntOffset.VectorConverter, + ) + val offset: IntOffset by derivedStateOf { + _offset.value + IntOffset( + x = (_sceneSize.width - cameraSizeZoomed.width) / 2, + y = (_sceneSize.height - cameraSizeZoomed.height) / 2, + ) + } + + private var _sceneSize: IntSize by mutableStateOf(IntSize.Zero) + private var _cameraSize: IntSize by mutableStateOf(IntSize.Zero) + val cameraSize: IntSize get() = _cameraSize + val cameraSizeZoomed: IntSize by derivedStateOf { + IntSize( + width = (cameraSize.width * zoom).fastRoundToInt(), + height = (cameraSize.height * zoom).fastRoundToInt(), + ) + } + + fun changeSizes( + sceneSize: IntSize, + cameraSize: IntSize, + ) { + _cameraSize = cameraSize + _sceneSize = sceneSize + } + + suspend fun handlePanning( + delta: Offset, + snap: Boolean, + ) { + val value = _offset.value - IntOffset( + x = (delta.x * zoom).fastRoundToInt(), + y = (delta.y * zoom).fastRoundToInt(), + ) + when { + snap -> _offset.snapTo(targetValue = value) + else -> _offset.animateTo(targetValue = value) + } + } + + suspend fun handleZoom( + zoomIn: Boolean, + power: Float, + snap: Boolean = false, + ) { + val value = _zoom.value * when { + zoomIn -> 1f - power + else -> 1f + power + } + when { + snap -> _zoom.snapTo(targetValue = value) + else -> _zoom.animateTo(targetValue = value) + } + } + + suspend fun resetPosition( + snap: Boolean = false, + ) { + when (snap) { + true -> _offset.snapTo(targetValue = initialOffset) + else -> _offset.animateTo(targetValue = initialOffset) + } + } + + suspend fun resetZoom( + snap: Boolean = false, + ) { + when (snap) { + true -> _zoom.snapTo(targetValue = initialZoom) + else -> _zoom.animateTo(targetValue = initialZoom) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/scene/FogOfWar.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/scene/FogOfWar.kt new file mode 100644 index 0000000..31e9e09 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/scene/FogOfWar.kt @@ -0,0 +1,9 @@ +package com.pixelized.desktop.lwa.ui.composable.scene + +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color + +@Stable +data class FogOfWar( + val color: Color = Color.Black.copy(alpha = 0.5f), +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/scene/Layout.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/scene/Layout.kt new file mode 100644 index 0000000..e211690 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/scene/Layout.kt @@ -0,0 +1,41 @@ +package com.pixelized.desktop.lwa.ui.composable.scene + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize + +@Stable +class Layout( + val texture: ImageBitmap, + val offset: IntOffset = IntOffset.Zero, + val size: IntSize = IntSize(texture.width, texture.height), + private val initialAlpha: Float = 1f, +) { + private val _alpha = Animatable( + initialValue = initialAlpha, + typeConverter = Float.VectorConverter, + ) + val alpha get() = _alpha.value + + suspend fun alpha( + alpha: Float, + snap: Boolean = false, + ) { + when (snap) { + true -> _alpha.snapTo(targetValue = alpha) + else -> _alpha.animateTo(targetValue = alpha) + } + } + + suspend fun resetAlpha( + snap: Boolean = false, + ) { + when (snap) { + true -> _alpha.snapTo(targetValue = initialAlpha) + else -> _alpha.animateTo(targetValue = initialAlpha) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/scene/Scene.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/scene/Scene.kt new file mode 100644 index 0000000..9e2ed21 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/scene/Scene.kt @@ -0,0 +1,278 @@ +package com.pixelized.desktop.lwa.ui.composable.scene + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isAltPressed +import androidx.compose.ui.input.pointer.isCtrlPressed +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.pixelized.desktop.lwa.ui.screen.campaign.LocalCampaignLayoutScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import lwacharactersheet.composeapp.generated.resources.Res +import lwacharactersheet.composeapp.generated.resources.ic_center_focus_weak_24dp +import lwacharactersheet.composeapp.generated.resources.ic_zoom_in_map_24dp +import lwacharactersheet.composeapp.generated.resources.ic_zoom_out_map_24dp +import lwacharactersheet.composeapp.generated.resources.ic_visibility_24dp +import lwacharactersheet.composeapp.generated.resources.icon_d100 +import lwacharactersheet.composeapp.generated.resources.image_dahome_maps +import lwacharactersheet.composeapp.generated.resources.image_dahome_regions +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.imageResource +import org.jetbrains.compose.resources.painterResource +import kotlin.math.sign + +@Stable +data class Scene( + val camera: Camera, + val layouts: List, + val fogOfWar: FogOfWar, +) { + val size: IntSize = IntSize( + width = layouts.maxOf { it.size.width }, + height = layouts.maxOf { it.size.height }, + ) +} + +@Composable +fun Scene( + modifier: Modifier, +) { + val campaign = LocalCampaignLayoutScope.current + val scope = rememberCoroutineScope() + val scene = rememberScene( + camera = Camera( + initialZoom = 1f, + initialOffset = IntOffset(x = -150, y = -120), + ), + fogOfWar = FogOfWar(), + rememberLayoutFromResource( + resource = Res.drawable.image_dahome_maps, + ), + rememberLayoutFromResource( + resource = Res.drawable.image_dahome_regions, + ), + rememberLayoutFromResource( + resource = Res.drawable.icon_d100, + offset = IntOffset(x = 1740, y = 910), + ), + ) + Box( + modifier = modifier + .graphicsLayer { clip = true } + .onCameraControl(scope = scope, scene = scene) + .drawScene(scene = scene) + .fogOfWar(scene = scene) + ) { + Column( + modifier = Modifier + .align(alignment = Alignment.BottomEnd) + .padding(end = campaign.rightPanel.value.width) + .padding(all = 8.dp) + ) { + IconButton( + onClick = { + scope.launch { + scene.camera.handleZoom( + zoomIn = true, + power = 0.3f, + ) + } + } + ) { + Icon( + painter = painterResource(Res.drawable.ic_zoom_in_map_24dp), + contentDescription = null + ) + } + IconButton( + onClick = { + scope.launch { + scene.camera.handleZoom( + zoomIn = false, + power = 0.3f, + ) + } + } + ) { + Icon( + painter = painterResource(Res.drawable.ic_zoom_out_map_24dp), + contentDescription = null + ) + } + IconButton( + onClick = { + scope.launch { + scene.camera.resetPosition() + } + scope.launch { + scene.camera.resetZoom() + } + } + ) { + Icon( + painter = painterResource(Res.drawable.ic_center_focus_weak_24dp), + contentDescription = null + ) + } + IconButton( + onClick = { + scope.launch { + scene.layouts.getOrNull(1)?.let { + it.alpha(alpha = if (it.alpha == 0f) 1f else 0f) + } + } + } + ) { + Icon( + painter = painterResource(Res.drawable.ic_visibility_24dp), + contentDescription = null + ) + } + } + } +} + +@Composable +@Stable +fun rememberLayoutFromResource( + resource: DrawableResource, + offset: IntOffset = IntOffset.Zero, +): Layout { + val texture = imageResource( + resource = resource, + ) + return remember(resource) { + Layout( + texture = texture, + offset = offset, + ) + } +} + +@Composable +@Stable +fun rememberScene( + camera: Camera, + fogOfWar: FogOfWar, + vararg layouts: Layout, +): Scene { + return remember { + Scene( + camera = camera, + layouts = layouts.toList(), + fogOfWar = fogOfWar, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +fun Modifier.onCameraControl( + scope: CoroutineScope, + scene: Scene, +): Modifier { + val offsetDelta = CursorDelta() + return this + .onSizeChanged { + scene.camera.changeSizes( + sceneSize = scene.size, + cameraSize = it, + ) + } + .onPointerEvent(PointerEventType.Move) { event: PointerEvent -> + scope.launch { + offsetDelta.handlePositionChange( + event = event, + ) { delta -> + when { + event.buttons.isTertiaryPressed || (event.keyboardModifiers.isCtrlPressed && event.buttons.isPrimaryPressed) -> scene.camera.handlePanning( + delta = delta, + snap = true, + ) + + event.keyboardModifiers.isAltPressed -> scene.camera.handleZoom( + zoomIn = delta.y.sign < 0f, + power = 0.025f, + snap = true, + ) + } + } + } + } + .onPointerEvent(PointerEventType.Scroll) { event: PointerEvent -> + scope.launch { + scene.camera.handleZoom( + zoomIn = event.changes.first().scrollDelta.y.sign < 0f, + power = 0.15f, + snap = false, + ) + } + } +} + +fun Modifier.drawScene( + scene: Scene, +): Modifier = this.drawWithCache { + onDrawBehind { + scene.layouts.forEach { layout -> + drawImage( + image = layout.texture, + srcOffset = scene.camera.offset - layout.offset, + srcSize = scene.camera.cameraSizeZoomed, + dstSize = scene.camera.cameraSize, + alpha = layout.alpha, + ) + } + } +} + +fun Modifier.fogOfWar( + scene: Scene, +): Modifier = this.drawWithCache { + onDrawBehind { + drawRect(color = scene.fogOfWar.color) + } +} + +private data class CursorDelta( + var lastDeltaTimestamp: Long = System.currentTimeMillis(), + var previousPosition: Offset = Offset.Zero, + var currentPosition: Offset = Offset.Zero, +) { + suspend inline fun handlePositionChange( + event: PointerEvent, + delay: Float = 10f, + crossinline block: suspend (delta: Offset) -> Unit, + ) { + val currentTimestamp = System.currentTimeMillis() + if (currentTimestamp - lastDeltaTimestamp > delay) { + lastDeltaTimestamp = currentTimestamp + previousPosition = currentPosition + currentPosition = event.changes.first().position + block(currentPosition - previousPosition) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/tooltip/BasicTooltipLayout.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/tooltip/BasicTooltipLayout.kt index f5da12e..f5e6fee 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/tooltip/BasicTooltipLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/tooltip/BasicTooltipLayout.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.TooltipPlacement import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface @@ -56,7 +56,7 @@ fun BasicTooltipLayout( tips = tooltip, tooltip = { BasicTooltip( - modifier = Modifier.width(width = 448.dp), + modifier = Modifier.widthIn(max = 448.dp), elevation = elevation, tooltip = it, ) @@ -72,7 +72,9 @@ private fun BasicTooltip( tooltip: BasicTooltipUio, ) { Surface( - modifier = Modifier.padding(16.dp).then(other = modifier), + modifier = Modifier + .padding(16.dp) + .then(other = modifier), color = MaterialTheme.colors.surface, elevation = elevation, shape = remember { RoundedCornerShape(4.dp) } diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/CampaignScreen.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/CampaignScreen.kt index de9e574..bd773ae 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/CampaignScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/CampaignScreen.kt @@ -2,11 +2,11 @@ package com.pixelized.desktop.lwa.ui.screen.campaign import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -20,6 +20,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isAltPressed import androidx.compose.ui.input.key.isCtrlPressed @@ -42,6 +43,7 @@ import com.pixelized.desktop.lwa.ui.composable.character.diminished.CharacterShe import com.pixelized.desktop.lwa.ui.composable.character.diminished.CharacterSheetDiminishedDialogViewModel import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackHandler import com.pixelized.desktop.lwa.ui.composable.key.KeyHandler +import com.pixelized.desktop.lwa.ui.composable.scene.Scene import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController import com.pixelized.desktop.lwa.ui.navigation.screen.destination.navigateToLevelScreen import com.pixelized.desktop.lwa.ui.overlay.portrait.PortraitOverlay @@ -49,13 +51,14 @@ import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetai import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailPanelViewModel import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.DetailPanelUio import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.rememberTransitionAnimation -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.npc.NpcRibbon -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.player.PlayerRibbon -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.player.PlayerRibbonViewModel +import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.CharacterRibbon +import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.NpcRibbonViewModel +import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.PlayerRibbonViewModel 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.toolbar.CampaignToolbar import com.pixelized.desktop.lwa.ui.screen.campaign.toolbar.CampaignToolbarViewModel +import com.pixelized.desktop.lwa.ui.theme.lwa import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel @@ -67,6 +70,7 @@ val LocalCampaignLayoutScope = compositionLocalOf { fun CampaignScreen( playerRibbonViewModel: PlayerRibbonViewModel = koinViewModel(), playerDetailViewModel: CharacterDetailPanelViewModel = koinViewModel(key = "player"), + npcRibbonViewModel: NpcRibbonViewModel = koinViewModel(), npcDetailViewModel: CharacterDetailPanelViewModel = koinViewModel(key = "npc"), characteristicDialogViewModel: CharacterSheetCharacteristicDialogViewModel = koinViewModel(), dismissedViewModel: CharacterSheetDiminishedDialogViewModel = koinViewModel(), @@ -96,7 +100,9 @@ fun CampaignScreen( }, main = { - + Scene( + modifier = Modifier.matchParentSize(), + ) }, chat = { CampaignChat( @@ -115,9 +121,10 @@ fun CampaignScreen( } }, leftPanel = { - PlayerRibbon( + CharacterRibbon( modifier = Modifier.fillMaxHeight(), viewModel = playerRibbonViewModel, + layoutDirection = LayoutDirection.Ltr, onCharacterLeftClick = { scope.launch { playerDetailViewModel.showCharacter( @@ -140,8 +147,10 @@ fun CampaignScreen( ) }, rightPanel = { - NpcRibbon( + CharacterRibbon( modifier = Modifier.fillMaxHeight(), + viewModel = npcRibbonViewModel, + layoutDirection = LayoutDirection.Rtl, onCharacterLeftClick = { scope.launch { npcDetailViewModel.showCharacter( @@ -167,10 +176,11 @@ fun CampaignScreen( CharacterDetailPanel( modifier = Modifier .padding(all = 8.dp) + .padding(start = MaterialTheme.lwa.size.portrait.minimized.width + 8.dp) .width(width = 128.dp * 4) .fillMaxHeight(), transitionSpec = rememberTransitionAnimation(direction = LayoutDirection.Ltr), - detailPanelViewModel = npcDetailViewModel, + detailPanelViewModel = playerDetailViewModel, characterDiminishedViewModel = dismissedViewModel, characteristicDialogViewModel = characteristicDialogViewModel, alterationViewModel = alterationViewModel, @@ -183,10 +193,11 @@ fun CampaignScreen( CharacterDetailPanel( modifier = Modifier .padding(all = 8.dp) + .padding(end = MaterialTheme.lwa.size.portrait.minimized.width + 8.dp) .width(width = 128.dp * 4) .fillMaxHeight(), transitionSpec = rememberTransitionAnimation(direction = LayoutDirection.Rtl), - detailPanelViewModel = playerDetailViewModel, + detailPanelViewModel = npcDetailViewModel, characterDiminishedViewModel = dismissedViewModel, characteristicDialogViewModel = characteristicDialogViewModel, alterationViewModel = alterationViewModel, @@ -205,7 +216,7 @@ fun CampaignScreen( characteristicDialogViewModel.changeSubCharacteristic( characterSheetId = dialog.characterSheetId, characteristic = dialog.characteristic, - useArmor= dialog.enableArmor?.checked?.value == true, + useArmor = dialog.enableArmor?.checked?.value == true, value = dialog.value.valueFlow.value.toIntOrNull() ?: 0, ) characteristicDialogViewModel.hideSubCharacteristicDialog() @@ -307,6 +318,7 @@ private fun CampaignLayout( ) { val density = LocalDensity.current + val mainState = remember { mutableStateOf(DpSize.Unspecified) } val leftOverlayState = remember { mutableStateOf(DpSize.Unspecified) } val leftPanelState = remember { mutableStateOf(DpSize.Unspecified) } val rightOverlayState = remember { mutableStateOf(DpSize.Unspecified) } @@ -314,6 +326,7 @@ private fun CampaignLayout( val chatOverlayState = remember { mutableStateOf(DpSize.Unspecified) } val scope = remember { CampaignLayoutScope( + main = mainState, leftOverlay = leftOverlayState, leftPanel = leftPanelState, rightOverlay = rightOverlayState, @@ -334,30 +347,35 @@ private fun CampaignLayout( ) { Box( modifier = Modifier - .align(alignment = Alignment.Center) - .fillMaxSize(), + .onSizeChanged { mainState.value = it.toDp(density) } + .matchParentSize(), ) { main() } - Row { - Box( - modifier = Modifier.onSizeChanged { leftPanelState.value = it.toDp(density) }, - ) { - leftPanel() - } - Box( - modifier = Modifier - .align(alignment = Alignment.Bottom) - .weight(weight = 1f) - .onSizeChanged { chatOverlayState.value = it.toDp(density) }, - ) { - chat() - } - Box( - modifier = Modifier.onSizeChanged { rightPanelState.value = it.toDp(density) }, - ) { - rightPanel() - } + Box( + modifier = Modifier + .align(alignment = Alignment.BottomStart) + .padding( + start = MaterialTheme.lwa.size.portrait.minimized.width * 2 + 20.dp, + end = MaterialTheme.lwa.size.portrait.minimized.width * 2 + 20.dp + 56.dp, + ) + .onSizeChanged { chatOverlayState.value = it.toDp(density) }, + ) { + chat() + } + Box( + modifier = Modifier + .align(alignment = Alignment.CenterStart) + .onSizeChanged { leftPanelState.value = it.toDp(density) }, + ) { + leftPanel() + } + Box( + modifier = Modifier + .align(alignment = Alignment.CenterEnd) + .onSizeChanged { rightPanelState.value = it.toDp(density) }, + ) { + rightPanel() } Box( modifier = Modifier @@ -392,24 +410,31 @@ private fun CampaignKeyHandler( onPlayerNumber: (index: Int) -> Unit, onAltPLayerNumber: (index: Int) -> Unit, ) { + fun KeyEvent.callback(index: Int) { + if (isAltPressed) onAltPLayerNumber(index) else onPlayerNumber(index) + } KeyHandler { - if (it.type != KeyEventType.KeyDown) return@KeyHandler false + if (it.type != KeyEventType.KeyDown) { + return@KeyHandler false + } if (it.key == Key.Escape) { onDismissRequest() return@KeyHandler true } - if (it.isCtrlPressed.not()) return@KeyHandler false + if (it.isCtrlPressed.not()) { + return@KeyHandler false + } when (it.key) { Key.Escape -> onDismissRequest() - Key.One, Key.NumPad1 -> if (it.isAltPressed) onAltPLayerNumber(0) else onPlayerNumber(0) - Key.Two, Key.NumPad2 -> if (it.isAltPressed) onAltPLayerNumber(1) else onPlayerNumber(1) - Key.Three, Key.NumPad3 -> if (it.isAltPressed) onAltPLayerNumber(2) else onPlayerNumber(2) - Key.Four, Key.NumPad4 -> if (it.isAltPressed) onAltPLayerNumber(3) else onPlayerNumber(3) - Key.Five, Key.NumPad5 -> if (it.isAltPressed) onAltPLayerNumber(4) else onPlayerNumber(4) - Key.Six, Key.NumPad6 -> if (it.isAltPressed) onAltPLayerNumber(5) else onPlayerNumber(5) - Key.Seven, Key.NumPad7 -> if (it.isAltPressed) onAltPLayerNumber(6) else onPlayerNumber(6) - Key.Eight, Key.NumPad8 -> if (it.isAltPressed) onAltPLayerNumber(7) else onPlayerNumber(7) - Key.Nine, Key.NumPad9 -> if (it.isAltPressed) onAltPLayerNumber(8) else onPlayerNumber(8) + Key.One, Key.NumPad1 -> it.callback(index = 0) + Key.Two, Key.NumPad2 -> it.callback(index = 1) + Key.Three, Key.NumPad3 -> it.callback(index = 2) + Key.Four, Key.NumPad4 -> it.callback(index = 3) + Key.Five, Key.NumPad5 -> it.callback(index = 4) + Key.Six, Key.NumPad6 -> it.callback(index = 5) + Key.Seven, Key.NumPad7 -> it.callback(index = 6) + Key.Eight, Key.NumPad8 -> it.callback(index = 7) + Key.Nine, Key.NumPad9 -> it.callback(index = 8) else -> return@KeyHandler false } return@KeyHandler true @@ -425,6 +450,7 @@ private fun IntSize.toDp(density: Density) = with(density) { @Stable data class CampaignLayoutScope( + val main: State, val leftOverlay: State, val leftPanel: State, val rightOverlay: State, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/CharacterRibbon.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/CharacterRibbon.kt index 3e0b752..c67da46 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/CharacterRibbon.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/CharacterRibbon.kt @@ -17,14 +17,12 @@ import androidx.compose.ui.unit.dp import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonPortrait import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonRoll import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonAlteration -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.player.PlayerRibbonViewModel -import org.koin.compose.viewmodel.koinViewModel @Composable fun CharacterRibbon( modifier: Modifier = Modifier, layoutDirection: LayoutDirection, - viewModel: PlayerRibbonViewModel = koinViewModel(), + viewModel: CharacterRibbonViewModel, padding: PaddingValues = PaddingValues(all = 8.dp), onCharacterLeftClick: (characterSheetId: String) -> Unit, onCharacterRightClick: (characterSheetId: String) -> Unit, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/npc/NpcRibbonViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/NpcRibbonViewModel.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/npc/NpcRibbonViewModel.kt rename to composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/NpcRibbonViewModel.kt index 3dd6c5b..e029293 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/npc/NpcRibbonViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/NpcRibbonViewModel.kt @@ -1,4 +1,4 @@ -package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.npc +package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository @@ -6,8 +6,6 @@ import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetReposit import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository import com.pixelized.desktop.lwa.repository.settings.SettingsRepository import com.pixelized.desktop.lwa.repository.settings.model.Settings -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.CharacterRibbonFactory -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.CharacterRibbonViewModel import com.pixelized.shared.lwa.model.campaign.Campaign class NpcRibbonViewModel( diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/player/PlayerRibbonViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/PlayerRibbonViewModel.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/player/PlayerRibbonViewModel.kt rename to composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/PlayerRibbonViewModel.kt index e80b095..df81d36 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/player/PlayerRibbonViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/PlayerRibbonViewModel.kt @@ -1,4 +1,4 @@ -package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.player +package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository @@ -6,8 +6,6 @@ import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetReposit import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository import com.pixelized.desktop.lwa.repository.settings.SettingsRepository import com.pixelized.desktop.lwa.repository.settings.model.Settings -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.CharacterRibbonFactory -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.CharacterRibbonViewModel import com.pixelized.shared.lwa.model.campaign.Campaign class PlayerRibbonViewModel( diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/common/CharacterRibbonAlteration.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/common/CharacterRibbonAlteration.kt index 838967d..bdb8729 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/common/CharacterRibbonAlteration.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/common/CharacterRibbonAlteration.kt @@ -32,6 +32,7 @@ import com.pixelized.desktop.lwa.ui.theme.lwa @Stable data class CharacterRibbonAlterationUio( + val icon: String, val tooltips: BasicTooltipUio?, ) @@ -44,60 +45,56 @@ fun CharacterRibbonAlteration( direction: LayoutDirection, status: List>, ) { - val currentDirection: LayoutDirection = LocalLayoutDirection.current + val currentDirection = LocalLayoutDirection.current - CompositionLocalProvider( - LocalLayoutDirection provides direction + Row( + modifier = Modifier + .animateContentSize() + .size(size = size) + .then(other = modifier), + horizontalArrangement = Arrangement.spacedBy(space = 4.dp), ) { - Row( - modifier = Modifier - .animateContentSize() - .size(size = size) - .then(other = modifier), - horizontalArrangement = Arrangement.spacedBy(space = 4.dp), - ) { - CompositionLocalProvider( - LocalLayoutDirection provides currentDirection + status.forEach { columns -> + Column( + modifier = Modifier.animateContentSize(), + verticalArrangement = Arrangement.spacedBy(space = 2.dp), ) { - status.forEach { columns -> - Column( - modifier = Modifier.animateContentSize(), - verticalArrangement = Arrangement.spacedBy(space = 2.dp), - ) { - columns.forEach { - BasicTooltipLayout( - delayMillis = 0, - tooltip = it.tooltips, - tooltipPlacement = remember(currentDirection) { - TooltipPlacement.ComponentRect( - anchor = when (direction) { - LayoutDirection.Ltr -> Alignment.TopStart - LayoutDirection.Rtl -> Alignment.TopEnd - }, - alignment = when (direction) { - LayoutDirection.Ltr -> Alignment.BottomEnd - LayoutDirection.Rtl -> Alignment.BottomStart - }, + CompositionLocalProvider( + LocalLayoutDirection provides LayoutDirection.Ltr + ) { + columns.forEach { + BasicTooltipLayout( + delayMillis = 0, + tooltip = it.tooltips, + tooltipPlacement = remember(currentDirection) { + TooltipPlacement.ComponentRect( + anchor = when (direction) { + LayoutDirection.Ltr -> Alignment.CenterEnd + LayoutDirection.Rtl -> Alignment.CenterStart + }, + alignment = when (direction) { + LayoutDirection.Ltr -> Alignment.CenterEnd + LayoutDirection.Rtl -> Alignment.CenterStart + }, + ) + }, + content = { + AnimatedContent( + targetState = it.icon, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + ) { icon -> + AsyncImage( + modifier = Modifier.size(24.dp), + model = ImageRequest.Builder(context = PlatformContext.INSTANCE) + .data(data = icon) + .size(size = 48) + .build(), + filterQuality = FilterQuality.High, + contentDescription = null, ) - }, - content = { - AnimatedContent( - targetState = it.icon, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - ) { icon -> - AsyncImage( - modifier = Modifier.size(24.dp), - model = ImageRequest.Builder(context = PlatformContext.INSTANCE) - .data(data = icon) - .size(size = 48) - .build(), - filterQuality = FilterQuality.High, - contentDescription = null, - ) - } } - ) - } + } + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/npc/NpcRibbon.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/npc/NpcRibbon.kt deleted file mode 100644 index a9fbfcb..0000000 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/npc/NpcRibbon.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.npc - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonPortrait -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonRoll -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonAlteration -import org.koin.compose.viewmodel.koinViewModel - -@Composable -fun NpcRibbon( - modifier: Modifier = Modifier, - viewModel: NpcRibbonViewModel = koinViewModel(), - padding: PaddingValues = PaddingValues(all = 8.dp), - onCharacterLeftClick: (characterSheetId: String) -> Unit, - onCharacterRightClick: (characterSheetId: String) -> Unit, - onLevelUp: (characterSheetId: String) -> Unit, -) { - val characters = viewModel.characters.collectAsState() - - LazyColumn( - modifier = modifier, - contentPadding = padding, - verticalArrangement = Arrangement.spacedBy(space = 8.dp) - ) { - items( - items = characters.value, - key = { it.characterSheetId }, - ) { - Row( - modifier = Modifier - .animateItem() - .graphicsLayer { if (it.hideOverruled) this.alpha = 0.3f }, - horizontalArrangement = Arrangement.spacedBy(space = 4.dp), - ) { - CharacterRibbonAlteration( - status = it.status, - direction = LayoutDirection.Rtl, - ) - Box { - CharacterRibbonPortrait( - character = it.portrait, - onCharacterLeftClick = { onCharacterLeftClick(it.characterSheetId) }, - onCharacterRightClick = { onCharacterRightClick(it.characterSheetId) }, - onLevelUp = { onLevelUp(it.characterSheetId) }, - ) - CharacterRibbonRoll( - value = viewModel.roll(characterSheetId = it.characterSheetId).value, - ) - } - } - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/player/PlayerRibbon.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/player/PlayerRibbon.kt deleted file mode 100644 index 37554ad..0000000 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/ribbon/player/PlayerRibbon.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.player - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonPortrait -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonRoll -import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.common.CharacterRibbonAlteration -import org.koin.compose.viewmodel.koinViewModel - -@Composable -fun PlayerRibbon( - modifier: Modifier = Modifier, - viewModel: PlayerRibbonViewModel = koinViewModel(), - padding: PaddingValues = PaddingValues(all = 8.dp), - onCharacterLeftClick: (characterSheetId: String) -> Unit, - onCharacterRightClick: (characterSheetId: String) -> Unit, - onLevelUp: (characterSheetId: String) -> Unit, -) { - val characters = viewModel.characters.collectAsState() - - LazyColumn( - modifier = modifier, - contentPadding = padding, - verticalArrangement = Arrangement.spacedBy(space = 8.dp) - ) { - items( - items = characters.value, - key = { it.characterSheetId }, - ) { - Row( - modifier = Modifier - .animateItem() - .graphicsLayer { if (it.hideOverruled) this.alpha = 0.3f }, - horizontalArrangement = Arrangement.spacedBy(space = 4.dp), - ) { - Box { - CharacterRibbonPortrait( - character = it.portrait, - onCharacterLeftClick = { onCharacterLeftClick(it.characterSheetId) }, - onCharacterRightClick = { onCharacterRightClick(it.characterSheetId) }, - onLevelUp = { onLevelUp(it.characterSheetId) }, - ) - CharacterRibbonRoll( - value = viewModel.roll(characterSheetId = it.characterSheetId).value, - ) - } - CharacterRibbonAlteration( - status = it.status, - direction = LayoutDirection.Ltr, - ) - } - } - } -} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6d32363..fb24483 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ koin = "4.0.0" turtle = "0.10.0" logback = "1.5.17" coil = "3.1.0" +zoomable = "2.7.0" ui-graphics-android = "1.7.8" buildkonfig = "0.17.0" @@ -35,6 +36,7 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx- # UI. coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } +engawapg-zoomable = { module = "net.engawapg.lib:zoomable", version.ref = "zoomable" } # Injection with Koin koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" }