Розробка системи інвентаря мобільної гри
Інвентар — це не просто список предметів. Це трансакційна система з бізнес-правилами: неможна витратити більше монет, ніж є; неможна взяти два унікальних предмети; стак повинен корректно об'єднуватися. Помилки тут коштують грошей: дубльовані предмети через race condition, від'ємна валюта через несинхронізовані запити — це реальні баги у production.
Структура даних інвентаря
@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 тижні. Вартість рассчитывается індивідуально.







