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,6 +152,11 @@ private fun MapActions(
contentDescription = null
)
}
AnimatedVisibility(
visible = enableLayersToggle.value,
enter = fadeIn(),
exit = fadeOut(),
) {
IconButton(
onClick = onToggleLayer,
) {
@ -181,8 +165,11 @@ private fun MapActions(
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,6 +130,11 @@ private fun CampaignToolbarContent(
)
}
}
AnimatedVisibility(
visible = displayMapButton.value,
enter = fadeIn(),
exit = fadeOut(),
) {
IconButton(
onClick = onMap,
) {
@ -129,14 +146,11 @@ private fun CampaignToolbarContent(
DropdownMenu(
expanded = menusState.value.isMapMenuOpen,
onDismissRequest = onDismissMapMenu,
content = {
MapScene(
modifier = Modifier.size(640.dp),
enableDebug = isAdmin.value,
)
},
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(

View file

@ -1,17 +1,19 @@
import com.pixelized.server.lwa.logics.ItemUsageLogic
import com.pixelized.server.lwa.model.alteration.AlterationService
import com.pixelized.server.lwa.model.alteration.AlterationStore
import com.pixelized.server.lwa.model.campaign.CampaignService
import com.pixelized.server.lwa.model.campaign.CampaignStore
import com.pixelized.server.lwa.model.character.CharacterSheetService
import com.pixelized.server.lwa.model.character.CharacterSheetStore
import com.pixelized.server.lwa.model.inventory.InventoryService
import com.pixelized.server.lwa.model.inventory.InventoryStore
import com.pixelized.server.lwa.model.item.ItemService
import com.pixelized.server.lwa.model.item.ItemStore
import com.pixelized.server.lwa.model.tag.TagService
import com.pixelized.server.lwa.model.tag.TagStore
import com.pixelized.server.lwa.services.alteration.AlterationService
import com.pixelized.server.lwa.services.alteration.AlterationStore
import com.pixelized.server.lwa.services.campaign.CampaignService
import com.pixelized.server.lwa.services.campaign.CampaignStore
import com.pixelized.server.lwa.services.character.CharacterSheetService
import com.pixelized.server.lwa.services.character.CharacterSheetStore
import com.pixelized.server.lwa.services.inventory.InventoryService
import com.pixelized.server.lwa.services.inventory.InventoryStore
import com.pixelized.server.lwa.services.item.ItemService
import com.pixelized.server.lwa.services.item.ItemStore
import com.pixelized.server.lwa.services.tag.TagService
import com.pixelized.server.lwa.services.tag.TagStore
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.services.map.MapService
import com.pixelized.server.lwa.services.map.MapStore
import com.pixelized.shared.lwa.utils.PathProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@ -51,6 +53,7 @@ val storeDependencies
singleOf(::InventoryStore)
singleOf(::ItemStore)
singleOf(::TagStore)
singleOf(::MapStore)
}
val serviceDependencies
@ -61,6 +64,7 @@ val serviceDependencies
singleOf(::InventoryService)
singleOf(::ItemService)
singleOf(::TagService)
singleOf(::MapService)
}
val logicsDependencies

View file

@ -1,7 +1,7 @@
package com.pixelized.server.lwa.logics
import com.pixelized.server.lwa.model.character.CharacterSheetService
import com.pixelized.server.lwa.model.inventory.InventoryService
import com.pixelized.server.lwa.services.character.CharacterSheetService
import com.pixelized.server.lwa.services.inventory.InventoryService
class ItemUsageLogic(
private val characterSheetService: CharacterSheetService,

View file

@ -1,18 +1,20 @@
package com.pixelized.server.lwa.server
import com.pixelized.server.lwa.logics.ItemUsageLogic
import com.pixelized.server.lwa.model.alteration.AlterationService
import com.pixelized.server.lwa.model.alteration.AlterationStore
import com.pixelized.server.lwa.model.campaign.CampaignService
import com.pixelized.server.lwa.model.campaign.CampaignStore
import com.pixelized.server.lwa.model.character.CharacterSheetService
import com.pixelized.server.lwa.model.character.CharacterSheetStore
import com.pixelized.server.lwa.model.inventory.InventoryService
import com.pixelized.server.lwa.model.inventory.InventoryStore
import com.pixelized.server.lwa.model.item.ItemService
import com.pixelized.server.lwa.model.item.ItemStore
import com.pixelized.server.lwa.model.tag.TagService
import com.pixelized.server.lwa.model.tag.TagStore
import com.pixelized.server.lwa.services.alteration.AlterationService
import com.pixelized.server.lwa.services.alteration.AlterationStore
import com.pixelized.server.lwa.services.campaign.CampaignService
import com.pixelized.server.lwa.services.campaign.CampaignStore
import com.pixelized.server.lwa.services.character.CharacterSheetService
import com.pixelized.server.lwa.services.character.CharacterSheetStore
import com.pixelized.server.lwa.services.inventory.InventoryService
import com.pixelized.server.lwa.services.inventory.InventoryStore
import com.pixelized.server.lwa.services.item.ItemService
import com.pixelized.server.lwa.services.item.ItemStore
import com.pixelized.server.lwa.services.map.MapService
import com.pixelized.server.lwa.services.map.MapStore
import com.pixelized.server.lwa.services.tag.TagService
import com.pixelized.server.lwa.services.tag.TagStore
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent
@ -30,6 +32,7 @@ class Engine(
val itemService: ItemService,
val inventoryService: InventoryService,
val tagService: TagService,
val mapService: MapService,
val campaignJsonFactory: CampaignJsonFactory,
val itemUsageLogic: ItemUsageLogic,
private val campaignStore: CampaignStore,
@ -38,6 +41,7 @@ class Engine(
private val itemStore: ItemStore,
private val inventoryStore: InventoryStore,
private val tagStore: TagStore,
private val mapStore: MapStore,
) {
val webSocket = MutableSharedFlow<SocketMessage>()
@ -106,7 +110,19 @@ class Engine(
characterSheetId = message.characterSheetId,
)
is CampaignEvent.UpdateScene -> Unit
is CampaignEvent.MapUpdated -> {
// convert the map into the a usable data model.
val map = campaignJsonFactory.convertFromJson(json = message.map)
// update the map
campaignService.setMap(map = map)
}
is CampaignEvent.MapDeleted -> {
// update the map
campaignService.setMap(map = null)
}
is CampaignEvent.SceneUpdated -> Unit
}
is GameAdminEvent -> when (message) {
@ -117,6 +133,7 @@ class Engine(
itemStore.updateItemsFlow()
inventoryStore.updateInventoryFlow()
tagStore.updateTagFlow()
mapStore.updateMapFlow()
webSocket.emit(
value = GameAdminEvent.ServerSynchronization(

View file

@ -5,7 +5,9 @@ import com.pixelized.server.lwa.server.rest.alteration.deleteAlteration
import com.pixelized.server.lwa.server.rest.alteration.getAlteration
import com.pixelized.server.lwa.server.rest.alteration.getAlterations
import com.pixelized.server.lwa.server.rest.alteration.putAlteration
import com.pixelized.server.lwa.server.rest.campaign.deleteCampaignMap
import com.pixelized.server.lwa.server.rest.campaign.getCampaign
import com.pixelized.server.lwa.server.rest.campaign.putCampaignMap
import com.pixelized.server.lwa.server.rest.campaign.putCampaignCharacter
import com.pixelized.server.lwa.server.rest.campaign.putCampaignNpc
import com.pixelized.server.lwa.server.rest.campaign.putCampaignScene
@ -23,9 +25,9 @@ import com.pixelized.server.lwa.server.rest.character.putCharacterFatigue
import com.pixelized.server.lwa.server.rest.inventory.changeInventoryItemCount
import com.pixelized.server.lwa.server.rest.inventory.consumeInventoryItem
import com.pixelized.server.lwa.server.rest.inventory.createInventoryItem
import com.pixelized.server.lwa.server.rest.inventory.equipInventoryItem
import com.pixelized.server.lwa.server.rest.inventory.deleteInventory
import com.pixelized.server.lwa.server.rest.inventory.deleteInventoryItem
import com.pixelized.server.lwa.server.rest.inventory.equipInventoryItem
import com.pixelized.server.lwa.server.rest.inventory.getInventory
import com.pixelized.server.lwa.server.rest.inventory.putInventory
import com.pixelized.server.lwa.server.rest.inventory.putPurse
@ -33,6 +35,10 @@ import com.pixelized.server.lwa.server.rest.item.deleteItem
import com.pixelized.server.lwa.server.rest.item.getItem
import com.pixelized.server.lwa.server.rest.item.getItems
import com.pixelized.server.lwa.server.rest.item.putItem
import com.pixelized.server.lwa.server.rest.map.deleteMap
import com.pixelized.server.lwa.server.rest.map.getMap
import com.pixelized.server.lwa.server.rest.map.getMaps
import com.pixelized.server.lwa.server.rest.map.putMap
import com.pixelized.server.lwa.server.rest.tag.getAlterationTags
import com.pixelized.server.lwa.server.rest.tag.getCharacterTags
import com.pixelized.server.lwa.server.rest.tag.getItemTags
@ -163,6 +169,18 @@ class LocalServer {
path = "/scene",
body = engine.putCampaignScene(),
)
route(
path = "/map",
) {
put(
path = "/update",
body = engine.putCampaignMap(),
)
put(
path = "/delete",
body = engine.deleteCampaignMap(),
)
}
}
route(
path = "/character",
@ -306,6 +324,24 @@ class LocalServer {
)
}
}
route(path = "/map") {
get(
path = "/all",
body = engine.getMaps(),
)
get(
path = "/detail",
body = engine.getMap(),
)
put(
path = "/update",
body = engine.putMap(),
)
delete(
path = "/delete",
body = engine.deleteMap(),
)
}
}
}
)

View file

@ -0,0 +1,25 @@
{
"id": "DAHOME_432_PU",
"name": "Dahomé",
"camera": {
"zoom": 1.52,
"offsetX": 1594,
"offsetY": 1149
},
"size": {
"width": 3840,
"height": 2160
},
"resources": [
{
"id": "ROOT",
"name": "Root",
"uri": "https://i.ibb.co/7dvDJ50L/image-dahome-maps.webp"
},
{
"id": "REGION",
"name": "Frontières",
"uri": "https://i.ibb.co/mC4jw8Yj/image-dahome-regions.webp"
}
]
}

View file

@ -0,0 +1,32 @@
package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.deleteCampaignMap(): suspend RoutingContext.() -> Unit {
return {
try {
// update the campaign.
campaignService.setMap(
map = null,
)
// API & WebSocket responses
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = CampaignEvent.MapDeleted(
timestamp = System.currentTimeMillis(),
)
)
} catch (exception: Exception) {
call.exception(
exception = exception
)
}
}
}

View file

@ -0,0 +1,39 @@
package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.model.campaign.CampaignJsonV2
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.putCampaignMap(): suspend RoutingContext.() -> Unit {
return {
try {
// Get the map json from the body of the request
val form = call.receive<CampaignJsonV2.MiniMapJson>()
// convert the map into the a usable data model.
val map = campaignJsonFactory.convertFromJson(json = form)
// update the campaign.
campaignService.setMap(
map = map,
)
// API & WebSocket responses
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = CampaignEvent.MapUpdated(
timestamp = System.currentTimeMillis(),
map = form,
)
)
} catch (exception: Exception) {
call.exception(
exception = exception
)
}
}
}

View file

@ -25,7 +25,7 @@ fun Engine.putCampaignScene(): suspend RoutingContext.() -> Unit {
message = APIResponse.success(),
)
webSocket.emit(
value = CampaignEvent.UpdateScene(
value = CampaignEvent.SceneUpdated(
timestamp = System.currentTimeMillis(),
name = scene.name,
)

View file

@ -0,0 +1,36 @@
package com.pixelized.server.lwa.server.rest.map
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.server.lwa.utils.extentions.mapId
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.deleteMap(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val mapId = call.queryParameters.mapId
// delete the map.
mapService.delete(
mapId = mapId,
)
// API & WebSocket responses.
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = ApiSynchronisation.MapDelete(
timestamp = System.currentTimeMillis(),
id = mapId,
),
)
} catch (exception: Exception) {
call.exception(
exception = exception,
)
}
}
}

View file

@ -0,0 +1,29 @@
package com.pixelized.server.lwa.server.rest.map
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.server.lwa.utils.extentions.mapId
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.getMap(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val mapId = call.queryParameters.mapId
// fetch the map
val map = mapService.map(id = mapId)
// respond to the request
call.respond(
message = APIResponse.success(
data = map,
),
)
} catch (exception: Exception) {
call.exception(
exception = exception
)
}
}
}

View file

@ -0,0 +1,24 @@
package com.pixelized.server.lwa.server.rest.map
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.getMaps(): suspend RoutingContext.() -> Unit {
return {
try {
call.respond(
message = APIResponse.success(
data = mapService.maps(),
),
)
} catch (exception: Exception) {
call.exception(
exception = exception
)
}
}
}

View file

@ -0,0 +1,37 @@
package com.pixelized.server.lwa.server.rest.map
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.create
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.map.MapJson
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.putMap(): suspend RoutingContext.() -> Unit {
return {
try {
val form = call.receive<MapJson>()
mapService.save(
json = form,
)
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = ApiSynchronisation.MapUpdate(
timestamp = System.currentTimeMillis(),
id = form.id,
),
)
} catch (exception: Exception) {
call.exception(
exception = exception,
)
}
}
}

View file

@ -1,4 +1,4 @@
package com.pixelized.server.lwa.model.alteration
package com.pixelized.server.lwa.services.alteration
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.alteration.AlterationJsonFactory

View file

@ -1,4 +1,4 @@
package com.pixelized.server.lwa.model.alteration
package com.pixelized.server.lwa.services.alteration
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.server.lwa.server.exception.FileReadException

View file

@ -1,4 +1,4 @@
package com.pixelized.server.lwa.model.campaign
package com.pixelized.server.lwa.services.campaign
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.shared.lwa.model.campaign.Campaign
@ -120,6 +120,16 @@ class CampaignService(
)
}
@Throws
suspend fun setMap(
map: Campaign.MiniMap?,
) {
// save the campaign to the disk + update the flow.
store.save(
campaign = campaign.copy(map = map)
)
}
// Data manipulation through WebSocket.
suspend fun updateToggleParty() {

View file

@ -1,4 +1,4 @@
package com.pixelized.server.lwa.model.campaign
package com.pixelized.server.lwa.services.campaign
import com.pixelized.server.lwa.server.exception.FileReadException
import com.pixelized.server.lwa.server.exception.FileWriteException
@ -10,7 +10,6 @@ import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory
import com.pixelized.shared.lwa.utils.PathProvider
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

View file

@ -1,4 +1,4 @@
package com.pixelized.server.lwa.model.character
package com.pixelized.server.lwa.services.character
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson

View file

@ -1,4 +1,4 @@
package com.pixelized.server.lwa.model.character
package com.pixelized.server.lwa.services.character
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.server.lwa.server.exception.FileReadException

View file

@ -1,6 +1,6 @@
package com.pixelized.server.lwa.model.inventory
package com.pixelized.server.lwa.services.inventory
import com.pixelized.server.lwa.model.item.ItemStore
import com.pixelized.server.lwa.services.item.ItemStore
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.shared.lwa.model.inventory.Inventory
import com.pixelized.shared.lwa.model.inventory.InventoryJson

View file

@ -1,4 +1,4 @@
package com.pixelized.server.lwa.model.inventory
package com.pixelized.server.lwa.services.inventory
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.server.lwa.server.exception.FileReadException

View file

@ -1,4 +1,4 @@
package com.pixelized.server.lwa.model.item
package com.pixelized.server.lwa.services.item
import com.pixelized.shared.lwa.model.item.ItemJson
import com.pixelized.shared.lwa.model.item.factory.ItemJsonFactory

View file

@ -1,4 +1,4 @@
package com.pixelized.server.lwa.model.item
package com.pixelized.server.lwa.services.item
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.server.lwa.server.exception.FileReadException

View file

@ -0,0 +1,32 @@
package com.pixelized.server.lwa.services.map
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.shared.lwa.model.map.MapJson
class MapService(
private val mapStore: MapStore,
) {
fun maps(): List<MapJson> {
return mapStore.maps().value.values.toList()
}
@Throws
fun map(id: String): MapJson {
return mapStore.maps().value[id]
?: throw BusinessException("Map with id:$id is not found")
}
@Throws
suspend fun save(
json: MapJson,
) {
mapStore.save(
map = json,
)
}
@Throws
fun delete(mapId: String) {
mapStore.delete(id = mapId)
}
}

View file

@ -0,0 +1,126 @@
package com.pixelized.server.lwa.services.map
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.server.lwa.server.exception.FileReadException
import com.pixelized.server.lwa.server.exception.FileWriteException
import com.pixelized.server.lwa.server.exception.JsonCodingException
import com.pixelized.server.lwa.server.exception.JsonConversionException
import com.pixelized.shared.lwa.model.map.MapJson
import com.pixelized.shared.lwa.utils.PathProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import java.text.Collator
class MapStore(
private val pathProvider: PathProvider,
private val jsonSerializer: Json,
scope: CoroutineScope,
) {
private val directory = File(pathProvider.mapPath()).also { it.mkdirs() }
private val mapsFlow = MutableStateFlow<Map<String, MapJson>>(emptyMap())
init {
// make the file path.
File(pathProvider.mapPath()).mkdirs()
// load the initial data
scope.launch {
updateMapFlow()
}
}
fun maps(): StateFlow<Map<String, MapJson>> = mapsFlow
suspend fun updateMapFlow() {
mapsFlow.value = try {
load().associateBy { it.id }
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
emptyMap()
}
}
private suspend fun load(
directory: File = this.directory,
): List<MapJson> {
return withContext(Dispatchers.IO) {
directory
.listFiles()
?.mapNotNull { file ->
val json = try {
file.readText(charset = Charsets.UTF_8)
} catch (exception: Exception) {
throw FileReadException(root = exception)
}
// Guard, if the json is blank no character have been save, ignore this file.
if (json.isBlank()) {
return@mapNotNull null
}
// decode the file
try {
jsonSerializer.decodeFromString<MapJson>(json)
} catch (exception: Exception) {
throw JsonCodingException(root = exception)
}
}
?: emptyList()
}
}
@Throws(JsonConversionException::class, FileWriteException::class)
suspend fun save(map: MapJson) {
// convert the data to json format
val json = try {
this.jsonSerializer.encodeToString(map)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
// write the file
try {
val file = mapFile(id = map.id)
file.writeText(
text = json,
charset = Charsets.UTF_8,
)
} catch (exception: Exception) {
throw FileWriteException(root = exception)
}
// Update the dataflow.
mapsFlow.update { maps ->
maps.toMutableMap().also {
it[map.id] = map
}
}
}
@Throws(BusinessException::class)
fun delete(id: String) {
val file = mapFile(id = id)
// Guard case on the file existence.
if (file.exists().not()) {
throw BusinessException(
message = "Alteration doesn't not exist, deletion is impossible.",
)
}
// Guard case on the file deletion
if (file.delete().not()) {
throw BusinessException(
message = "Alteration file have not been deleted for unknown reason.",
)
}
// Update the data model with the deleted alteration.
mapsFlow.update { maps ->
maps.toMutableMap().also {
it.remove(key = id)
}
}
}
private fun mapFile(id: String) = File("${pathProvider.mapPath()}$id.json")
}

View file

@ -1,4 +1,4 @@
package com.pixelized.server.lwa.model.tag
package com.pixelized.server.lwa.services.tag
import com.pixelized.shared.lwa.model.tag.TagJson

View file

@ -1,4 +1,4 @@
package com.pixelized.server.lwa.model.tag
package com.pixelized.server.lwa.services.tag
import com.pixelized.server.lwa.server.exception.FileReadException
import com.pixelized.server.lwa.server.exception.FileWriteException
@ -6,8 +6,6 @@ import com.pixelized.server.lwa.server.exception.JsonConversionException
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.utils.PathProvider
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.launch

View file

@ -43,6 +43,12 @@ val Parameters.itemId: String
code = APIResponse.ErrorCode.ItemId,
)
val Parameters.mapId: String
get() = param(
name = "mapId",
code = APIResponse.ErrorCode.ItemId,
)
val Parameters.count: Float
get() = param(
name = "count",

View file

@ -11,6 +11,8 @@ import com.pixelized.shared.lwa.model.inventory.factory.InventoryJsonFactory
import com.pixelized.shared.lwa.model.inventory.factory.InventoryJsonFactoryV1
import com.pixelized.shared.lwa.model.item.factory.ItemJsonFactory
import com.pixelized.shared.lwa.model.item.factory.ItemJsonFactoryV1
import com.pixelized.shared.lwa.model.map.factory.MapJsonFactory
import com.pixelized.shared.lwa.model.map.factory.MapJsonFactoryV1
import com.pixelized.shared.lwa.model.tag.TagJsonFactory
import com.pixelized.shared.lwa.parser.dice.DiceParser
import com.pixelized.shared.lwa.parser.expression.ExpressionParser
@ -58,6 +60,8 @@ val factoryDependencies
factoryOf(::ItemJsonFactoryV1)
factoryOf(::InventoryJsonFactory)
factoryOf(::InventoryJsonFactoryV1)
factoryOf(::MapJsonFactory)
factoryOf(::MapJsonFactoryV1)
}
val parserDependencies

View file

@ -1,13 +1,25 @@
package com.pixelized.shared.lwa.model.campaign
import com.pixelized.shared.lwa.model.map.Map
data class Campaign(
val characters: Set<String>,
val npcs: Set<String>,
val map: MiniMap?,
val scene: Scene,
val options: Options,
) {
val instances = characters + npcs
data class MiniMap(
val id: String,
val camera: Map.Camera?,
) {
companion object {
fun empty(): MiniMap? = null
}
}
data class Scene(
val name: String,
) {
@ -34,6 +46,7 @@ data class Campaign(
fun empty() = Campaign(
characters = emptySet(),
npcs = emptySet(),
map = null,
scene = Scene.empty(),
options = Options.empty(),
)

View file

@ -12,6 +12,9 @@ sealed interface CampaignJson {
sealed interface CharacteristicJson
}
@Serializable
sealed interface MiniMap
@Serializable
sealed interface SceneJson

View file

@ -6,10 +6,24 @@ import kotlinx.serialization.Serializable
data class CampaignJsonV2(
val characters: Set<String>,
val npcs: Set<String>,
val map: MiniMapJson?,
val scene: SceneJsonV2?,
val options: OptionsJsonV2?,
) : CampaignJson {
@Serializable
data class MiniMapJson(
val id: String,
val camera: CameraJson?,
): CampaignJson.MiniMap {
@Serializable
data class CameraJson(
val zoom: Float,
val offsetX: Int,
val offsetY: Int,
)
}
@Serializable
data class SceneJsonV2(
val name: String,

View file

@ -27,6 +27,14 @@ class CampaignJsonFactory(
}
}
fun convertFromJson(
json: CampaignJson.MiniMap
): Campaign.MiniMap {
return when (json) {
is CampaignJsonV2.MiniMapJson -> v2.convertFromJson(mapJson = json)
}
}
// Json conversion.
fun createScene(
@ -43,6 +51,18 @@ class CampaignJsonFactory(
return CampaignJsonV2(
characters = campaign.characters,
npcs = campaign.npcs,
map = campaign.map?.let { campaign ->
CampaignJsonV2.MiniMapJson(
id = campaign.id,
camera = campaign.camera?.let { camera ->
CampaignJsonV2.MiniMapJson.CameraJson(
zoom = camera.zoom,
offsetX = camera.offsetX,
offsetY = camera.offsetY,
)
}
)
},
scene = CampaignJsonV2.SceneJsonV2(
name = campaign.scene.name,
),

View file

@ -14,6 +14,7 @@ class CampaignJsonV1Factory {
return Campaign(
characters = emptySet(),
npcs = emptySet(),
map = null,
scene = campaignJson.scene
?.let { convertFromJson(it) }
?: Campaign.Scene.empty(),

View file

@ -2,6 +2,7 @@ package com.pixelized.shared.lwa.model.campaign.factory
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.CampaignJsonV2
import com.pixelized.shared.lwa.model.map.Map
class CampaignJsonV2Factory {
@ -11,15 +12,33 @@ class CampaignJsonV2Factory {
return Campaign(
characters = campaignJson.characters,
npcs = campaignJson.npcs,
map = campaignJson.map
?.let { convertFromJson(mapJson = it) }
?: Campaign.MiniMap.empty(),
scene = campaignJson.scene
?.let { convertFromJson(it) }
?.let { convertFromJson(sceneJson = it) }
?: Campaign.Scene.empty(),
options = campaignJson.options
?.let { convertFromJson(it) }
?.let { convertFromJson(optionsJson = it) }
?: Campaign.Options.empty()
)
}
fun convertFromJson(
mapJson: CampaignJsonV2.MiniMapJson,
): Campaign.MiniMap {
return Campaign.MiniMap(
id = mapJson.id,
camera = mapJson.camera?.let {
Map.Camera(
zoom = it.zoom,
offsetX = it.offsetX,
offsetY = it.offsetY,
)
}
)
}
fun convertFromJson(
sceneJson: CampaignJsonV2.SceneJsonV2,
): Campaign.Scene {

View file

@ -0,0 +1,26 @@
package com.pixelized.shared.lwa.model.map
data class Map(
val id: String,
val name : String,
val camera: Camera,
val size: Size,
val resources: List<Resource>,
) {
data class Camera(
val zoom: Float,
val offsetX: Int,
val offsetY: Int,
)
data class Size(
val width: Int,
val height: Int,
)
data class Resource(
val id: String,
val name: String,
val uri: String,
)
}

View file

@ -0,0 +1,8 @@
package com.pixelized.shared.lwa.model.map
import kotlinx.serialization.Serializable
@Serializable
sealed interface MapJson {
val id: String
}

View file

@ -0,0 +1,32 @@
package com.pixelized.shared.lwa.model.map
import kotlinx.serialization.Serializable
@Serializable
data class MapJsonV1(
override val id: String,
val name: String,
val camera: Camera,
val size: Size,
val resources: List<Resource>,
) : MapJson {
@Serializable
data class Camera(
val zoom: Float,
val offsetX: Int,
val offsetY: Int,
)
@Serializable
data class Size(
val width: Int,
val height: Int,
)
@Serializable
data class Resource(
val id: String,
val name: String,
val uri: String,
)
}

View file

@ -0,0 +1,43 @@
package com.pixelized.shared.lwa.model.map.factory
import com.pixelized.shared.lwa.model.map.Map
import com.pixelized.shared.lwa.model.map.MapJson
import com.pixelized.shared.lwa.model.map.MapJsonV1
class MapJsonFactory(
private val v1: MapJsonFactoryV1,
) {
fun convertFromJson(
json: MapJson,
): Map {
return when (json) {
is MapJsonV1 -> v1.convertFromJson(json = json)
}
}
fun convertToJson(
item: Map,
): MapJson {
return MapJsonV1(
id = item.id,
name = item.name,
camera = MapJsonV1.Camera(
zoom = item.camera.zoom,
offsetX = item.camera.offsetX,
offsetY = item.camera.offsetY,
),
size = MapJsonV1.Size(
width = item.size.width,
height = item.size.height,
),
resources = item.resources.map {
MapJsonV1.Resource(
id = it.id,
name = it.name,
uri = it.uri,
)
},
)
}
}

View file

@ -0,0 +1,31 @@
package com.pixelized.shared.lwa.model.map.factory
import com.pixelized.shared.lwa.model.map.Map
import com.pixelized.shared.lwa.model.map.MapJsonV1
class MapJsonFactoryV1 {
fun convertFromJson(
json: MapJsonV1,
): Map {
return Map(
id = json.id,
name = json.name,
camera = Map.Camera(
zoom = json.camera.zoom,
offsetX = json.camera.offsetX,
offsetY = json.camera.offsetY,
),
size = Map.Size(
width = json.size.width,
height = json.size.height,
),
resources = json.resources.map {
Map.Resource(
id = it.id,
name = it.name,
uri = it.uri,
)
},
)
}
}

View file

@ -74,4 +74,19 @@ sealed interface ApiSynchronisation : SocketMessage {
override val timestamp: Long,
override val characterSheetId: String,
) : InventoryApiSynchronisation
@Serializable
sealed interface MapApiSynchronisation : ApiSynchronisation
@Serializable
data class MapUpdate(
override val timestamp: Long,
val id: String,
) : MapApiSynchronisation
@Serializable
data class MapDelete(
override val timestamp: Long,
val id: String,
) : MapApiSynchronisation
}

View file

@ -1,5 +1,6 @@
package com.pixelized.shared.lwa.protocol.websocket
import com.pixelized.shared.lwa.model.campaign.CampaignJson
import kotlinx.serialization.Serializable
@Serializable
@ -7,7 +8,7 @@ sealed interface CampaignEvent : SocketMessage {
// TODO Will probably be moved when the scene become more complex, (should already actually)
@Serializable
data class UpdateScene(
data class SceneUpdated(
override val timestamp: Long,
val name: String,
) : CampaignEvent
@ -35,4 +36,15 @@ sealed interface CampaignEvent : SocketMessage {
override val timestamp: Long,
override val characterSheetId: String,
) : CampaignEvent, CharacterSheetIdMessage
@Serializable
data class MapUpdated(
override val timestamp: Long,
val map: CampaignJson.MiniMap,
): CampaignEvent
@Serializable
data class MapDeleted(
override val timestamp: Long,
): CampaignEvent
}

View file

@ -83,4 +83,14 @@ class PathProvider(
OperatingSystem.Macintosh -> "${storePath(os = os, app = app)}inventory/"
}
}
fun mapPath(
os: OperatingSystem = this.operatingSystem,
app: String = this.appName,
): String {
return when (os) {
OperatingSystem.Windows -> "${storePath(os = os, app = app)}maps\\"
OperatingSystem.Macintosh -> "${storePath(os = os, app = app)}maps/"
}
}
}