Разработка системы инвентаря мобильной игры
Инвентарь — это не просто список предметов. Это транзакционная система с бизнес-правилами: нельзя потратить больше монет, чем есть; нельзя взять два уникальных предмета; стак должен корректно объединяться. Ошибки здесь стоят денег: дублированные предметы из-за race condition, отрицательная валюта из-за несинхронизированных запросов — это реальные баги в продакшене.
Структура данных инвентаря
@Entity(tableName = "inventory_items")
data class InventoryItemEntity(
@PrimaryKey val instanceId: String, // уникальный ID каждого предмета
val playerId: String,
val itemDefinitionId: String, // ссылка на шаблон предмета
val quantity: Int, // для стакируемых
val durability: Int? = null, // для предметов с прочностью
val enchantments: String = "[]", // JSON array дополнительных свойств
val acquiredAt: Long,
val slotIndex: Int? = null // для экипированных предметов
)
// Определение предмета (статичные данные — загружаются из assets)
data class ItemDefinition(
val id: String,
val name: String,
val type: ItemType, // WEAPON, ARMOR, CONSUMABLE, CURRENCY
val isStackable: Boolean,
val maxStackSize: Int = 1,
val isUnique: Boolean = false, // нельзя иметь больше одного
val maxQuantity: Int = Int.MAX_VALUE
)
Разделение на InstanceData (то, что уникально для каждого предмета у игрока) и ItemDefinition (шаблон предмета) — классический паттерн. Шаблоны грузим из JSON в assets при старте, не кладём в БД — они не меняются в runtime.
Транзакционные операции
Race condition при пополнении кошелька — классическая проблема. Два параллельных запроса «добавить 100 монет» оба читают текущее значение 500, оба пишут 600. Должно быть 700.
@Dao
interface InventoryDao {
// Атомарное добавление к стеку — не читаем и не пишем отдельно
@Query("""
UPDATE inventory_items
SET quantity = MIN(quantity + :amount, :maxStackSize)
WHERE instance_id = :instanceId AND player_id = :playerId
""")
suspend fun incrementQuantity(instanceId: String, playerId: String,
amount: Int, maxStackSize: Int): Int
// Атомарное списание с проверкой — не даёт уйти в минус
@Query("""
UPDATE inventory_items
SET quantity = quantity - :amount
WHERE instance_id = :instanceId AND player_id = :playerId
AND quantity >= :amount
""")
suspend fun decrementQuantity(instanceId: String, playerId: String, amount: Int): Int
// Возвращает количество обновлённых строк — если 0, значит не хватило
@Transaction
suspend fun transferItem(fromPlayerId: String, toPlayerId: String,
instanceId: String): Boolean {
val updated = updateOwner(instanceId, fromPlayerId, toPlayerId)
return updated > 0
}
}
decrementQuantity возвращает количество затронутых строк. Если 0 — операция не прошла из-за нехватки ресурсов. Никакого read-check-write — одна атомарная SQL-операция.
Серверная валидация
Локальный инвентарь — для отображения. Всё, что касается реальной монетизации (покупки, расход gems, получение за реальные деньги), обязательно проходит валидацию на сервере:
class InventoryRepository(
private val localDao: InventoryDao,
private val api: InventoryApi
) {
suspend fun spendGems(amount: Int, reason: String): Result<Unit> {
return try {
// Сервер проверяет баланс, списывает, возвращает новое состояние
val serverState = api.spendGems(SpendGemsRequest(amount, reason))
// Синхронизируем локальное состояние с сервером
localDao.updateCurrencyBalance(
playerId = serverState.playerId,
gems = serverState.newGemsBalance
)
Result.success(Unit)
} catch (e: InsufficientFundsException) {
Result.failure(e)
}
}
}
Оптимистичный update на клиенте с rollback при ошибке — только для некритичных операций. Для монетизации — всегда server-first.
Сортировка и фильтрация
Инвентарь из 500 предметов нельзя держать весь в памяти и фильтровать на клиенте. Room Paging 3:
@Dao
interface InventoryDao {
@Query("""
SELECT * FROM inventory_items
WHERE player_id = :playerId
AND (:typeFilter IS NULL OR item_type = :typeFilter)
AND (:searchQuery IS NULL OR item_name LIKE '%' || :searchQuery || '%')
ORDER BY
CASE :sortBy WHEN 'rarity' THEN rarity_value ELSE acquired_at END DESC
""")
fun pagingSource(playerId: String, typeFilter: String?, searchQuery: String?,
sortBy: String): PagingSource<Int, InventoryItemEntity>
}
// ViewModel
val inventoryItems = Pager(PagingConfig(pageSize = 20)) {
dao.pagingSource(playerId, selectedType, searchQuery, sortBy)
}.flow.cachedIn(viewModelScope)
Paging 3 загружает по 20 предметов при скролле — никакого лага при большом инвентаре.
Drag-and-drop перестановка слотов
В Jetpack Compose через reorderable библиотеку (burnoutcrew/reorderable) или через detectDragGesturesAfterLongPress. Ключевой момент — применять новый порядок оптимистично в UI сразу, а в БД — батчем после drop:
fun onItemDropped(fromIndex: Int, toIndex: Int) {
// Оптимистично обновляем список в памяти
val newList = _inventoryState.value.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
_inventoryState.value = newList
// Сохраняем новый порядок в БД батчем
viewModelScope.launch(Dispatchers.IO) {
dao.updateSlotIndices(newList.mapIndexed { index, item ->
SlotUpdate(item.instanceId, index)
})
}
}
Разработка системы инвентаря с транзакционными операциями, серверной валидацией и Paging: 2–4 недели. Стоимость рассчитывается индивидуально.







