Add map management to the server (REST+WS)

This commit is contained in:
Thomas Andres Gomez 2025-12-07 10:18:57 +01:00
parent 3485b8a9fd
commit 03dbd7aad6
62 changed files with 1226 additions and 144 deletions

View file

@ -6,6 +6,7 @@ import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.inventory.InventoryRepository
import com.pixelized.desktop.lwa.repository.item.ItemRepository
import com.pixelized.desktop.lwa.repository.map.MapRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.desktop.lwa.repository.tag.TagRepository
@ -23,6 +24,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
class DataSyncViewModel(
private val mapRepository: MapRepository,
private val characterRepository: CharacterSheetRepository,
private val inventoryRepository: InventoryRepository,
private val alterationRepository: AlterationRepository,
@ -88,6 +90,7 @@ class DataSyncViewModel(
campaignRepository.updateCampaign()
tagRepository.updateItemTags()
itemRepository.updateItemFlow()
mapRepository.updateMapFlow()
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
}

View file

@ -12,7 +12,10 @@ import com.pixelized.desktop.lwa.repository.inventory.InventoryRepository
import com.pixelized.desktop.lwa.repository.inventory.InventoryStore
import com.pixelized.desktop.lwa.repository.item.ItemRepository
import com.pixelized.desktop.lwa.repository.item.ItemStore
import com.pixelized.desktop.lwa.repository.map.MapRepository
import com.pixelized.desktop.lwa.repository.map.MapStore
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.repository.resources.ImageResourcesRepository
import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsFactory
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
@ -65,6 +68,8 @@ import com.pixelized.desktop.lwa.ui.screen.admin.item.edit.GMItemEditFactory
import com.pixelized.desktop.lwa.ui.screen.admin.item.edit.GMItemEditViewModel
import com.pixelized.desktop.lwa.ui.screen.admin.item.list.GMItemFactory
import com.pixelized.desktop.lwa.ui.screen.admin.item.list.GMItemViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.map.MapSceneFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.map.MapSceneViewModel
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.settings.SettingsViewModel
@ -125,6 +130,7 @@ val storeDependencies
singleOf(::TagStore)
singleOf(::ItemStore)
singleOf(::InventoryStore)
singleOf(::MapStore)
}
val repositoryDependencies
@ -138,6 +144,8 @@ val repositoryDependencies
singleOf(::TagRepository)
singleOf(::ItemRepository)
singleOf(::InventoryRepository)
singleOf(::ImageResourcesRepository)
singleOf(::MapRepository)
}
val factoryDependencies
@ -145,6 +153,7 @@ val factoryDependencies
factoryOf(::NetworkFactory)
factoryOf(::SettingsFactory)
factoryOf(::CampaignJsonFactory)
factoryOf(::MapSceneFactory)
factoryOf(::CharacterRibbonFactory)
factoryOf(::CharacterDetailHeaderFactory)
factoryOf(::CharacterDetailSheetFactory)
@ -175,6 +184,7 @@ val viewModelDependencies
viewModelOf(::RollViewModel)
viewModelOf(::NetworkViewModel)
viewModelOf(::PlayerRibbonViewModel)
viewModelOf(::MapSceneViewModel)
viewModelOf(::NpcRibbonViewModel)
viewModelOf(::CharacterDetailPanelViewModel)
viewModelOf(::CharacterSheetDiminishedDialogViewModel)

View file

@ -5,6 +5,7 @@ import com.pixelized.shared.lwa.model.campaign.CampaignJson
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.model.inventory.InventoryJson
import com.pixelized.shared.lwa.model.item.ItemJson
import com.pixelized.shared.lwa.model.map.MapJson
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.rest.ApiPurseJson
@ -165,6 +166,17 @@ interface LwaClient {
suspend fun getItemTags(): APIResponse<List<TagJson>>
suspend fun getMaps(): APIResponse<List<MapJson>>
suspend fun getMap(
mapId: String,
): APIResponse<MapJson>
suspend fun putMap(
mapJson: MapJson,
create: Boolean,
): APIResponse<Unit>
companion object {
fun error(error: APIResponse<*>): Nothing = throw LwaNetworkException(error)
}

View file

@ -6,6 +6,7 @@ import com.pixelized.shared.lwa.model.campaign.CampaignJson
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.model.inventory.InventoryJson
import com.pixelized.shared.lwa.model.item.ItemJson
import com.pixelized.shared.lwa.model.map.MapJson
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.rest.ApiPurseJson
@ -282,4 +283,25 @@ class LwaClientImpl(
override suspend fun getItemTags(): APIResponse<List<TagJson>> = client
.get("$root/tag/item")
.body()
@Throws
override suspend fun getMaps(): APIResponse<List<MapJson>> = client
.get("$root/map/all")
.body()
@Throws
override suspend fun getMap(mapId: String): APIResponse<MapJson> = client
.get("$root/map/detail?mapId=$mapId")
.body()
@Throws
override suspend fun putMap(
mapJson: MapJson,
create: Boolean,
): APIResponse<Unit> = client
.put("$root/map/update") {
contentType(ContentType.Application.Json)
setBody(mapJson)
}
.body<APIResponse<Unit>>()
}

View file

@ -3,6 +3,7 @@ package com.pixelized.desktop.lwa.repository.campaign
import com.pixelized.desktop.lwa.network.LwaClient
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.Campaign.*
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory
import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent
import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent
@ -141,11 +142,21 @@ class CampaignStore(
)
}
is CampaignEvent.UpdateScene -> campaignFlow.update { campaign ->
is CampaignEvent.SceneUpdated -> campaignFlow.update { campaign ->
campaign.copy(
scene = Campaign.Scene(name = message.name)
scene = Scene(name = message.name)
)
}
is CampaignEvent.MapUpdated -> campaignFlow.update { campaign ->
campaign.copy(
map = factory.convertFromJson(message.map),
)
}
is CampaignEvent.MapDeleted -> campaignFlow.update { campaign ->
campaign.copy(map = null,)
}
}
}

View file

@ -0,0 +1,38 @@
package com.pixelized.desktop.lwa.repository.map
import com.pixelized.desktop.lwa.repository.campaign.CampaignStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import com.pixelized.shared.lwa.model.map.Map as MiniMap
class MapRepository(
private val mapStore: MapStore,
private val campaignStore: CampaignStore,
) {
fun mapsFlow(): StateFlow<Map<String, MiniMap>> = mapStore.mapsFlow()
fun maps() = mapStore.mapsFlow().value
fun currentMap(): Flow<MiniMap?> = combine(
campaignStore.campaignFlow(),
mapStore.mapsFlow(),
) { campaign, maps ->
maps[campaign.map?.id]
}
suspend fun updateMapFlow() {
mapStore.updateMapFlow()
}
@Throws
suspend fun updateMap(
map: MiniMap,
create: Boolean,
) {
mapStore.putMap(
map = map,
create = create,
)
}
}

View file

@ -0,0 +1,107 @@
package com.pixelized.desktop.lwa.repository.map
import com.pixelized.desktop.lwa.network.LwaClient
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.shared.lwa.model.map.factory.MapJsonFactory
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import com.pixelized.shared.lwa.model.map.Map as MiniMap
class MapStore(
private val network: NetworkRepository,
private val factory: MapJsonFactory,
private val client: LwaClient,
) {
private val mapsFlow = MutableStateFlow<Map<String, MiniMap>>(emptyMap())
init {
val scope = CoroutineScope(Dispatchers.IO + Job())
// data update through WebSocket.
scope.launch {
network.data.collect(::handleMessage)
}
}
fun mapsFlow(): StateFlow<Map<String, MiniMap>> = mapsFlow
suspend fun updateMapFlow() {
val map = try {
getMaps()
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
null
}
// guard case if getItem failed
if (map == null) return
// update the flow with the new item.
mapsFlow.update { map }
}
@Throws
private suspend fun getMaps(): Map<String, MiniMap> {
val request = client.getMaps()
return when (request.success) {
true -> request.data
?.map { factory.convertFromJson(it) }
?.associateBy { it.id }
?: emptyMap()
else -> LwaClient.error(error = request)
}
}
@Throws
private suspend fun getMap(id: String): MiniMap? {
val request = client.getMap(mapId = id)
return when (request.success) {
true -> request.data?.let { factory.convertFromJson(it) }
else -> LwaClient.error(error = request)
}
}
@Throws
suspend fun putMap(
map: MiniMap,
create: Boolean,
) {
val request = client.putMap(
mapJson = factory.convertToJson(item = map),
create = create,
)
if (request.success.not()) {
LwaClient.error(error = request)
}
}
// region: WebSocket & data update.
private suspend fun handleMessage(message: SocketMessage) {
when (message) {
is ApiSynchronisation.MapApiSynchronisation -> when (message) {
is ApiSynchronisation.MapUpdate -> mapsFlow.update { maps ->
maps.toMutableMap().also {
when (val map = getMap(id = message.id)) {
null -> it.remove(key = message.id)
else -> it[message.id] = map
}
}
}
is ApiSynchronisation.MapDelete -> mapsFlow.update { maps ->
maps.toMutableMap().also { it.remove(message.id) }
}
}
else -> Unit
}
}
// endregion
}

View file

@ -0,0 +1,23 @@
package com.pixelized.desktop.lwa.repository.resources
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.readRawBytes
import org.jetbrains.skia.Image
class ImageResourcesRepository(
private val httpClient: HttpClient,
) {
suspend fun load(url: String): ImageBitmap {
try {
val byteArray = httpClient.get(url).readRawBytes()
val skiaImage = Image.makeFromEncoded(byteArray)
return skiaImage.toComposeImageBitmap()
} catch (_: Exception) {
// TODO proper exception handling (error bus ?)
return ImageBitmap(width = 0, height = 0)
}
}
}

View file

@ -17,15 +17,10 @@ import com.pixelized.desktop.lwa.ui.composable.scene.drawables.SceneLayer
@Stable
data class Scene(
val size: IntSize,
val layers: List<SceneLayer>,
val elements: List<SceneElement>,
) {
val size: IntSize = IntSize(
width = layers.maxOf { it.size.width },
height = layers.maxOf { it.size.height },
)
}
)
@Composable
fun Scene(

View file

@ -23,6 +23,7 @@ class Camera(
val offset: IntOffset by _offset
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 {
@ -40,7 +41,13 @@ class Camera(
_sceneSize = sceneSize
}
fun handlePanning(
fun move(
offset: IntOffset,
) {
_offset.value = offset
}
fun pan(
delta: Offset,
) {
val value = _offset.value - IntOffset(
@ -50,7 +57,7 @@ class Camera(
_offset.value = value
}
fun handleZoom(
fun zoom(
power: Float,
target: IntOffset? = null,
) {

View file

@ -33,7 +33,7 @@ fun Modifier.onCameraControl(
event = event,
) { delta ->
when {
event.buttons.isTertiaryPressed || (event.keyboardModifiers.isCtrlPressed && event.buttons.isPrimaryPressed) -> camera.handlePanning(
event.buttons.isTertiaryPressed || (event.keyboardModifiers.isCtrlPressed && event.buttons.isPrimaryPressed) -> camera.pan(
delta = delta,
)
}
@ -41,7 +41,7 @@ fun Modifier.onCameraControl(
}
.onPointerEvent(PointerEventType.Scroll) { event: PointerEvent ->
val change = event.changes.first()
camera.handleZoom(
camera.zoom(
power = -change.scrollDelta.y.sign * 0.15f,
target = change.position.round(),
)

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import com.pixelized.desktop.lwa.ui.composable.scene.camera.Camera
import com.pixelized.desktop.lwa.ui.composable.scene.cursor.Cursor
import com.pixelized.desktop.lwa.ui.composable.scene.utils.local
@ -62,7 +63,7 @@ fun CursorDebugPanel(
style = MaterialTheme.lwa.typography.debug.propertyValue,
text = buildAnnotatedString {
withStyle(style.propertyIdSpan) { append("local: ") }
append(cursor.offset.local(camera).toString())
append(cursor.offset.local(camera).round().toString())
},
)
}

View file

@ -16,7 +16,7 @@ open class SceneDrawable(
) {
private val _alpha = Animatable(
initialValue = initialAlpha,
typeConverter = Float.Companion.VectorConverter,
typeConverter = Float.VectorConverter,
)
val alpha get() = _alpha.value

View file

@ -44,7 +44,6 @@ 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.screen.campaign.map.MapScene
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
@ -94,7 +93,7 @@ fun CampaignScreen(
modifier = Modifier.fillMaxSize(),
top = {
CampaignToolbar(
viewModel = campaignViewModel,
toolBarViewModel = campaignViewModel,
)
},
bottom = {

View file

@ -142,9 +142,11 @@ class TextMessageFactory(
is CampaignEvent -> when (message) {
is CampaignEvent.CharacterAdded -> null
is CampaignEvent.CharacterRemoved -> null
is CampaignEvent.UpdateScene -> null
is CampaignEvent.NpcAdded -> null
is CampaignEvent.NpcRemoved -> null
is CampaignEvent.SceneUpdated -> null
is CampaignEvent.MapUpdated -> null
is CampaignEvent.MapDeleted -> null
}
is GameMasterEvent -> null

View file

@ -1,6 +1,8 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.map
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -12,21 +14,19 @@ import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.pixelized.desktop.lwa.ui.composable.scene.Scene
import com.pixelized.desktop.lwa.ui.composable.scene.camera.Camera
import com.pixelized.desktop.lwa.ui.composable.scene.cursor.Cursor
import com.pixelized.desktop.lwa.ui.composable.scene.cursor.onCursorControl
import com.pixelized.desktop.lwa.ui.composable.scene.debug.CameraDebugPanel
import com.pixelized.desktop.lwa.ui.composable.scene.debug.CursorDebugPanel
import com.pixelized.desktop.lwa.ui.composable.scene.debug.SceneDebugPanel
import com.pixelized.desktop.lwa.ui.composable.scene.drawables.rememberLayoutFromResource
import com.pixelized.desktop.lwa.ui.theme.lwa
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
@ -35,46 +35,25 @@ import lwacharactersheet.composeapp.generated.resources.ic_frame_bug_24dp
import lwacharactersheet.composeapp.generated.resources.ic_visibility_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.image_dahome_maps
import lwacharactersheet.composeapp.generated.resources.image_dahome_regions
import org.jetbrains.compose.resources.painterResource
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun MapScene(
modifier: Modifier = Modifier,
enableDebug: Boolean,
viewModel: MapSceneViewModel = koinViewModel(),
) {
val scope = rememberCoroutineScope()
val map = rememberLayoutFromResource(
name = "Dahomé",
resource = Res.drawable.image_dahome_maps,
)
val mapRegionOverlay = rememberLayoutFromResource(
name = "Région",
resource = Res.drawable.image_dahome_regions,
)
val camera = remember {
Camera(
initialZoom = 1f,
initialOffset = IntOffset(x = 1407, y = 1520),
)
}
val cursor = remember {
Cursor()
}
val scene = remember(map, mapRegionOverlay) {
Scene(
layers = listOf(map, mapRegionOverlay),
elements = emptyList(),
)
}
val openDebugMenu = remember {
mutableStateOf(false)
}
val camera by viewModel.camera.collectAsStateWithLifecycle()
val scene by viewModel.scene.collectAsStateWithLifecycle()
val openDebugMenu = viewModel.displayDebugMenu.collectAsStateWithLifecycle()
val enableDebug = viewModel.enableDebug.collectAsStateWithLifecycle()
val enableLayersToggle = viewModel.enableLayersToggle.collectAsStateWithLifecycle()
Scene(
modifier = Modifier
.onCursorControl(camera = camera, cursor = cursor)
.onCursorControl(camera = camera, cursor = viewModel.cursor)
.then(other = modifier),
scene = scene,
camera = camera,
@ -84,11 +63,12 @@ fun MapScene(
.align(alignment = Alignment.BottomEnd)
.padding(paddingValues = MaterialTheme.lwa.dimen.paddingValues),
enableDebug = enableDebug,
enableLayersToggle = enableLayersToggle,
onZoomIn = {
camera.handleZoom(power = 0.15f)
camera.zoom(power = 0.15f)
},
onZoomOut = {
camera.handleZoom(power = -0.15f)
camera.zoom(power = -0.15f)
},
onResetCamera = {
camera.resetPosition()
@ -96,14 +76,12 @@ fun MapScene(
},
onToggleLayer = {
scope.launch {
scene.layers.getOrNull(1)?.let {
scene.layers.drop(1).forEach {
it.alpha(alpha = if (it.alpha == 0f) 1f else 0f)
}
}
},
onToggleDebug = {
openDebugMenu.value = openDebugMenu.value.not()
},
onToggleDebug = viewModel::toggleDebugMenu,
)
AnimatedVisibility(
@ -128,7 +106,7 @@ fun MapScene(
)
CursorDebugPanel(
camera = camera,
cursors = remember { listOf(cursor) },
cursors = remember { listOf(viewModel.cursor) },
)
}
}
@ -139,7 +117,8 @@ fun MapScene(
@Composable
private fun MapActions(
modifier: Modifier = Modifier,
enableDebug: Boolean,
enableDebug: State<Boolean>,
enableLayersToggle: State<Boolean>,
onZoomIn: () -> Unit,
onZoomOut: () -> Unit,
onResetCamera: () -> Unit,
@ -173,16 +152,24 @@ private fun MapActions(
contentDescription = null
)
}
IconButton(
onClick = onToggleLayer,
AnimatedVisibility(
visible = enableLayersToggle.value,
enter = fadeIn(),
exit = fadeOut(),
) {
Icon(
painter = painterResource(Res.drawable.ic_visibility_24dp),
contentDescription = null
)
IconButton(
onClick = onToggleLayer,
) {
Icon(
painter = painterResource(Res.drawable.ic_visibility_24dp),
contentDescription = null
)
}
}
AnimatedVisibility(
visible = enableDebug,
visible = enableDebug.value,
enter = fadeIn(),
exit = fadeOut(),
) {
IconButton(
onClick = onToggleDebug,

View file

@ -0,0 +1,44 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.map
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import com.pixelized.desktop.lwa.ui.composable.scene.Scene
import com.pixelized.desktop.lwa.ui.composable.scene.camera.Camera
import com.pixelized.desktop.lwa.ui.composable.scene.drawables.SceneLayer
import com.pixelized.shared.lwa.model.map.Map
class MapSceneFactory {
suspend fun convertToScene(
map: Map,
image: suspend (String) -> ImageBitmap,
): Scene {
return Scene(
size = IntSize(
width = map.size.width,
height = map.size.height,
),
layers = map.resources.map { resource ->
SceneLayer(
id = resource.id,
name = resource.name,
texture = image(resource.uri),
)
},
elements = emptyList(),
)
}
fun convertToCamera(
camera: Map.Camera
) : Camera {
return Camera(
initialZoom = camera.zoom,
initialOffset = IntOffset(
x = camera.offsetX,
y = camera.offsetY,
)
)
}
}

View file

@ -0,0 +1,96 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.map
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.map.MapRepository
import com.pixelized.desktop.lwa.repository.resources.ImageResourcesRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.desktop.lwa.ui.composable.scene.Scene
import com.pixelized.desktop.lwa.ui.composable.scene.camera.Camera
import com.pixelized.desktop.lwa.ui.composable.scene.cursor.Cursor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
class MapSceneViewModel(
resourcesRepository: ImageResourcesRepository,
settingsRepository: SettingsRepository,
campaignRepository: CampaignRepository,
mapRepository: MapRepository,
factory: MapSceneFactory,
) : ViewModel() {
private val _displayDebugMenu = MutableStateFlow(false)
val displayDebugMenu: StateFlow<Boolean> get() = _displayDebugMenu
val enableDebug = settingsRepository.settingsFlow()
.map { it.isGameMaster ?: false }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = false,
)
private val _camera = MutableStateFlow(defaultCamera())
val camera: StateFlow<Camera> = _camera
private val _scene = MutableStateFlow(defaultScene())
val scene: StateFlow<Scene> = _scene
val enableLayersToggle: StateFlow<Boolean> = scene
.map { it.layers.size > 1 }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = false,
)
val cursor = Cursor()
init {
combine(
campaignRepository.campaignFlow(),
mapRepository.currentMap(),
) { campaign, map ->
when (map) {
null -> null
else -> (campaign.map?.camera ?: map.camera) to map
}
}
.mapNotNull { it }
.onEach { (camera, map) ->
_scene.value = factory.convertToScene(
map = map,
image = { resourcesRepository.load(it) },
)
_camera.value = factory.convertToCamera(
camera = camera
)
}
.launchIn(scope = viewModelScope)
}
fun toggleDebugMenu() {
_displayDebugMenu.value = _displayDebugMenu.value.not()
}
private fun defaultCamera() = Camera(
initialZoom = 1f,
initialOffset = IntOffset.Zero,
)
private fun defaultScene() = Scene(
size = IntSize(width = 3840, height = 2160),
layers = emptyList(),
elements = emptyList()
)
}

View file

@ -26,6 +26,7 @@ import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.navigateToSettings
import com.pixelized.desktop.lwa.ui.navigation.window.destination.openGameMasterWindow
import com.pixelized.desktop.lwa.ui.screen.campaign.map.MapScene
import com.pixelized.desktop.lwa.ui.screen.campaign.map.MapSceneViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.toolbar.links.ResourcesMenu
import com.pixelized.desktop.lwa.ui.screen.campaign.toolbar.network.NetworkMenu
import com.pixelized.desktop.lwa.ui.theme.lwa
@ -49,20 +50,23 @@ data class CampaignMenuStateUio(
@Composable
fun CampaignToolbar(
viewModel: CampaignToolbarViewModel = koinViewModel(),
toolBarViewModel: CampaignToolbarViewModel = koinViewModel(),
mapViewModel: MapSceneViewModel = koinViewModel(),
) {
val windows = LocalWindowController.current
val screen = LocalScreenController.current
val title = viewModel.title.collectAsStateWithLifecycle()
val status = viewModel.status.collectAsStateWithLifecycle()
val isAdmin = viewModel.isAdmin.collectAsStateWithLifecycle()
val menusState = viewModel.menusState.collectAsStateWithLifecycle()
val title = toolBarViewModel.title.collectAsStateWithLifecycle()
val status = toolBarViewModel.status.collectAsStateWithLifecycle()
val isAdmin = toolBarViewModel.isAdmin.collectAsStateWithLifecycle()
val isMapEnable = toolBarViewModel.isMapEnable.collectAsStateWithLifecycle()
val menusState = toolBarViewModel.menusState.collectAsStateWithLifecycle()
CampaignToolbarContent(
title = title,
status = status,
isAdmin = isAdmin,
displayMapButton = isMapEnable,
menusState = menusState,
onAdmin = {
windows.openGameMasterWindow()
@ -70,12 +74,18 @@ fun CampaignToolbar(
onSettings = {
screen.navigateToSettings()
},
onNetwork = viewModel::onNetwork,
onResources = viewModel::onResources,
onMap = viewModel::onMap,
onDismissNetworkMenu = viewModel::onDismissNetworkMenu,
onDismissResourcesMenu = viewModel::onDismissResourcesMenu,
onDismissMapMenu = viewModel::onDismissMapMenu,
onNetwork = toolBarViewModel::onNetwork,
onResources = toolBarViewModel::onResources,
onMap = toolBarViewModel::onMap,
onDismissNetworkMenu = toolBarViewModel::onDismissNetworkMenu,
onDismissResourcesMenu = toolBarViewModel::onDismissResourcesMenu,
onDismissMapMenu = toolBarViewModel::onDismissMapMenu,
map = {
MapScene(
modifier = Modifier.size(640.dp),
viewModel = mapViewModel,
)
}
)
}
@ -86,6 +96,7 @@ private fun CampaignToolbarContent(
title: State<String>,
status: State<NetworkRepository.Status>,
isAdmin: State<Boolean>,
displayMapButton: State<Boolean>,
menusState: State<CampaignMenuStateUio>,
onAdmin: () -> Unit,
onNetwork: () -> Unit,
@ -95,6 +106,7 @@ private fun CampaignToolbarContent(
onDismissNetworkMenu: () -> Unit,
onDismissResourcesMenu: () -> Unit,
onDismissMapMenu: () -> Unit,
map: @Composable () -> Unit,
) {
TopAppBar(
modifier = modifier,
@ -118,25 +130,27 @@ private fun CampaignToolbarContent(
)
}
}
IconButton(
onClick = onMap,
AnimatedVisibility(
visible = displayMapButton.value,
enter = fadeIn(),
exit = fadeOut(),
) {
Icon(
painter = painterResource(Res.drawable.ic_map_24dp),
tint = MaterialTheme.colors.primary,
contentDescription = null,
)
DropdownMenu(
expanded = menusState.value.isMapMenuOpen,
onDismissRequest = onDismissMapMenu,
content = {
MapScene(
modifier = Modifier.size(640.dp),
enableDebug = isAdmin.value,
)
},
)
IconButton(
onClick = onMap,
) {
Icon(
painter = painterResource(Res.drawable.ic_map_24dp),
tint = MaterialTheme.colors.primary,
contentDescription = null,
)
DropdownMenu(
expanded = menusState.value.isMapMenuOpen,
onDismissRequest = onDismissMapMenu,
content = { map() },
)
}
}
IconButton(
onClick = onResources,
) {

View file

@ -3,6 +3,7 @@ package com.pixelized.desktop.lwa.ui.screen.campaign.toolbar
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.map.MapRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import kotlinx.coroutines.flow.MutableStateFlow
@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.update
class CampaignToolbarViewModel(
campaignRepository: CampaignRepository,
mapRepository: MapRepository,
networkRepository: NetworkRepository,
settingsRepository: SettingsRepository,
) : ViewModel() {
@ -45,6 +47,14 @@ class CampaignToolbarViewModel(
initialValue = false,
)
val isMapEnable = mapRepository.currentMap()
.map { it != null }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = false,
)
val isGameMaster = settingsRepository.settingsFlow()
.map { it.isGameMaster ?: false }
.stateIn(