Kotlin — Kotlinochi
О практикуме
Kotlinochi — тамагочi "Коточи": три шкалы (здоровье, энергия, чистота), три действия и игровой цикл угасания на корутинах. Состояние пишется в DataStore и восстанавливается после закрытия приложения, в том числе с offline decay — "питомец скучал, пока вас не было".
Эталон: F:\Projects\JVM\Kotlin\Kotlinochi.
Перед стартом: KotlinMobileApp (Compose) + корутины + Flow. Обзор MVVM на Android — 1135.
Сначала — KotlinMobileApp или Compose — первый экран, затем корутины и Flow. Здесь вы соберёте полный MVVM: PetViewModel, StateFlow, PetRepository, DataStore Preferences.
Словарь практикума
| Термин | В Kotlinochi |
|---|---|
| MVVM | Model (PetState) + View (TamagotchiScreen) + ViewModel (PetViewModel) |
| DataStore | Асинхронное key-value хранилище вместо SharedPreferences |
| StateFlow | Поток состояния питомца; UI подписывается через collectAsState() |
| decay | Плановое уменьшение статов |
| offline decay | Догоняющие тики за время, пока приложение было закрыто |
| Canvas | Отрисовка кота без PNG-спрайтов |
Архитектура
Оценка времени — 3–5 часов.
Карта этапов
| Этап | Фокус | Результат |
|---|---|---|
| 0 | Gradle | ViewModel, DataStore, Compose |
| 1 | Model | PetState, PetMood, PetAction |
| 2 | Repository | Flow из DataStore, suspend save |
| 3 | ViewModel | Действия, decay, offline |
| 4 | Theme | Пастельная палитра |
| 5 | Компоненты | StatBar, ActionButton |
| 6 | PetDisplay | Canvas-кот и анимация |
| 7 | TamagotchiScreen | Сборка экрана, Snackbar |
| 8 | MainActivity | Запуск |
Этап 0 — проект и зависимости
Цель этапа
Android-проект с Compose и библиотеками для MVVM и персистентности.
Шаги
- New Project → Empty Activity, package
com.kotlinochi.tamagotchi, minSdk 26. - В
app/build.gradle.ktsк зависимостям из KotlinMobileApp добавьте:
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation(composeBom)
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// + core-ktx, activity-compose, ui — как в KotlinMobileApp
}
Разбор зависимостей
| Артефакт | Назначение |
|---|---|
lifecycle-viewmodel-compose | viewModel() внутри @Composable |
lifecycle-runtime-compose | collectAsState() для Flow/StateFlow |
datastore-preferences | Типобезопасные ключи intPreferencesKey, async API |
material-icons-extended | Опционально; в эталоне emoji в тексте, не icon font |
DataStore vs SharedPreferences — DataStore пишет на диск асинхронно и отдаёт Flow; это лучше стыкуется с корутинами (226.md).
Самопроверка
- Gradle Sync успешен.
- Созданы пакеты
model,data,viewmodel,ui(можно пустые).
Этап 1 — модель PetState
Цель этапа
Описать правила игры в чистых Kotlin-классах без android.* — их проще тестировать и переносить.
Код model/PetState.kt
package com.kotlinochi.tamagotchi.model
data class PetState(
val name: String = DEFAULT_NAME,
val health: Int = 80,
val energy: Int = 80,
val cleanliness: Int = 80,
val lastUpdatedMillis: Long = System.currentTimeMillis(),
) {
val mood: PetMood get() = PetMood.fromStats(health, energy, cleanliness)
fun clamped(): PetState = copy(
health = health.coerceIn(0, MAX_STAT),
energy = energy.coerceIn(0, MAX_STAT),
cleanliness = cleanliness.coerceIn(0, MAX_STAT),
)
companion object {
const val DEFAULT_NAME = "Коточи"
const val MAX_STAT = 100
}
}
Добавьте PetMood, PetAction, ActionFeedback — полный блок в эталоне (полный блок — в эталоне model/PetState.kt).
Разбор
data class— автоматическиеequals,copy; удобно для immutable state (15.md).mood(computed property) — UI не решает "грустный или нет"; одна функцияfromStats— единый источник правды.lastUpdatedMillis— метка времени для offline decay.clamped()— после арифметики stat не выходят за 0…100.
Логика PetMood.fromStats (идея)
Приоритет проверок сверху вниз:
- критическое состояние — health < 15 или все три stat очень низкие;
- болезнь, голод/сон, грязь;
- иначе — ecstatic / happy / sad.
Самопроверка
-
PetState().mood == PetMood.Happyпри 80/80/80. -
PetState(health = 10).mood == PetMood.Critical.
Этап 2 — PetRepository и DataStore
Цель этапа
Единая точка доступа к диску; ViewModel не знает про ключи Preferences.
Код data/PetRepository.kt
private val Context.petDataStore by preferencesDataStore(name = "pet_state")
class PetRepository(private val context: Context) {
val petState: Flow<PetState> = context.petDataStore.data.map { prefs ->
PetState(
name = prefs[KEY_NAME] ?: PetState.DEFAULT_NAME,
health = prefs[KEY_HEALTH] ?: 80,
energy = prefs[KEY_ENERGY] ?: 80,
cleanliness = prefs[KEY_CLEANLINESS] ?: 80,
lastUpdatedMillis = prefs[KEY_LAST_UPDATED] ?: System.currentTimeMillis(),
)
}
suspend fun save(state: PetState) {
context.petDataStore.edit { prefs ->
prefs[KEY_NAME] = state.name
prefs[KEY_HEALTH] = state.health
prefs[KEY_ENERGY] = state.energy
prefs[KEY_CLEANLINESS] = state.cleanliness
prefs[KEY_LAST_UPDATED] = state.lastUpdatedMillis
}
}
}
Ключи — в companion object: stringPreferencesKey("name"), intPreferencesKey("health"), …
Разбор
preferencesDataStore(name = …)— extension наContext; один DataStore на файл.Flow<PetState>— при каждом изменении prefs эмитится новый объект (для подписчиков; ViewModel в эталоне держит свойStateFlow).suspend fun save— вызывается из корутины; не блокирует main thread.
Для табличных данных (история кормлений) позже — Room.
Самопроверка
- Проект компилируется.
- Имена ключей уникальны.
Этап 3 — PetViewModel
Цель этапа
Вся игровая логика и фоновый таймер — вне Composable.
Каркас класса
class PetViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PetRepository(application)
private val _petState = MutableStateFlow(PetState())
val petState: StateFlow<PetState> = _petState.asStateFlow()
private val _feedback = MutableStateFlow<ActionFeedback?>(null)
val feedback: StateFlow<ActionFeedback?> = _feedback.asStateFlow()
init {
viewModelScope.launch {
val saved = repository.petState.first()
val withOffline = applyOfflineDecay(saved)
_petState.value = withOffline
repository.save(withOffline)
startDecayLoop()
}
}
}
Действия игрока — onAction
fun onAction(action: PetAction) {
val current = _petState.value
val updated = when (action) {
PetAction.Feed -> current.copy(
energy = current.energy + 25,
health = current.health + 8,
)
PetAction.Play -> current.copy(
energy = current.energy - 18,
health = current.health + 12,
cleanliness = current.cleanliness - 8,
)
PetAction.Wash -> current.copy(
cleanliness = current.cleanliness + 35,
energy = current.energy - 5,
)
}.clamped().copy(lastUpdatedMillis = System.currentTimeMillis())
_petState.value = updated
_feedback.value = actionFeedback(action)
persist(updated)
}
Разбор trade-off'ов
- Кормление быстро поднимает energy — но нельзя "перекормить" (кнопка disabled при energy ≥ 95 в UI).
- Игра тратит energy — нельзя играть при energy < 20.
- Мытьё сильно чистит, но слегка утомляет.
Decay — один тик
private fun applyDecayStep(state: PetState): PetState {
var health = state.health - 1
var energy = state.energy - 3
var cleanliness = state.cleanliness - 2
if (energy < 30) health -= 2
if (cleanliness < 30) health -= 2
if (energy < 15) health -= 1
return state.copy(health = health, energy = energy, cleanliness = cleanliness)
}
Каждые 30 секунд (DECAY_INTERVAL_MS = 30_000L) корутина вызывает applyTickDecay() → обновляет _petState → repository.save().
Offline decay
private fun applyOfflineDecay(state: PetState): PetState {
val elapsed = (System.currentTimeMillis() - state.lastUpdatedMillis).coerceAtLeast(0)
val ticks = (elapsed / DECAY_INTERVAL_MS).toInt()
if (ticks <= 0) return state.clamped()
var result = state
repeat(ticks.coerceAtMost(MAX_OFFLINE_TICKS)) {
result = applyDecayStep(result)
}
return result.clamped()
}
Зачем MAX_OFFLINE_TICKS = 48 — cap ~24 часа offline, чтобы не убить питомца за месяц отсутствия одним открытием.
Игровой цикл
private fun startDecayLoop() {
viewModelScope.launch {
while (true) {
delay(DECAY_INTERVAL_MS)
applyTickDecay()
}
}
}
viewModelScope отменяется в onCleared() — нет утечки после закрытия экрана (222.md).
Самопроверка
- Через ~1 минуту energy заметно ниже 80 (с временным UI или Logcat).
-
onAction(Feed)поднимает energy; Snackbar-текст через_feedback. - После kill процесса и паузы 2+ мин stat ниже, чем при instant restart без паузы.
Этап 4 — тема
Цель этапа
Визуальный стиль "милого" тамагочi — пастельные цвета, не дефолтный Material purple.
ui/theme/Color.kt (фрагмент)
val CreamBackground = Color(0xFFFFF5F7)
val SoftPink = Color(0xFFFFB5C5)
val DeepPink = Color(0xFFFF8FAB)
val MintGreen = Color(0xFFB8F2E6)
val HealthRed = Color(0xFFFF6B8A)
val EnergyYellow = Color(0xFFFFB74D)
val CleanBlue = Color(0xFF64B5F6)
KotlinochiTheme — обёртка MaterialTheme; фон экрана — CreamBackground у Scaffold(containerColor = …).
Самопроверка
- Экран не белый дефолтный — виден кремовый фон.
Этап 5 — StatBar и ActionButton
Цель этапа
Вынести повторяющиеся куски UI в переиспользуемые Composable — как компоненты в React (272).
StatBar
- Row с emoji, подписью и
"$value%". - Полоска прогресса —
BoxсfillMaxWidth(animatedProgress). animateFloatAsState— плавное изменение ширины при новом value (600 ms).
ActionButton
- Принимает
PetAction,containerColor,enabled,onClick. - Внутри Column с emoji и
action.label. ButtonDefaults.buttonColors— приenabled = falseполупрозрачный цвет.
Самопроверка
-
@PreviewдляStatBarиActionButtonв IDE. - При value 80 → 40 полоска анимируется, не прыгает.
Этап 6 — PetDisplay (Canvas)
Цель этапа
"Спрайт" без PNG — рисуем кота примитивами и меняем выражение от PetMood.
Техники в эталоне
| Приём | Эффект |
|---|---|
rememberInfiniteTransition + animateFloat | Лёгкий bounce при Happy/Ecstatic |
Отдельная анимация blink | Глаза сужаются раз в ~3 с |
Canvas { drawCircle, drawPath } | Голова, уши, щёки, рот |
when (mood) для Path рта | Улыбка / грусть / линия / сон |
Доп. круги при Dirty, Sleepy | Пятна, "пузырь сна" |
Рот через quadraticTo — дуга улыбки или перевернутая дуга грусти.
Полный файл ~200 строк — копируйте из PetDisplay.kt эталона блоками: тело → глаза → рот → mood-ветки.
Самопроверка
- При
PetMood.Sad— грустный рот; приEcstatic— подпрыгивание. - Emoji настроения в углу (
mood.emoji) совпадает сPetMood.
Этап 7 — TamagotchiScreen
Цель этапа
Собрать экран и связать StateFlow → UI.
Ключевые фрагменты
@Composable
fun TamagotchiScreen(viewModel: PetViewModel = viewModel()) {
val pet by viewModel.petState.collectAsState()
val feedback by viewModel.feedback.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(feedback) {
feedback?.let {
snackbarHostState.showSnackbar("${it.emoji} ${it.message}")
viewModel.clearFeedback()
}
}
Scaffold(
containerColor = CreamBackground,
snackbarHost = { SnackbarHost(snackbarHostState) { data -> Snackbar(…) { Text(data.visuals.message) } } },
) { padding ->
Column(Modifier.padding(padding).verticalScroll(rememberScrollState())) {
PetDisplay(name = pet.name, mood = pet.mood)
// Card + StatBar × 3
Row {
ActionButton(PetAction.Feed, MintGreen, enabled = pet.energy < 95) {
viewModel.onAction(PetAction.Feed)
}
// Play, Wash — см. эталон
}
}
}
}
Разбор
viewModel()— default parameter; Android создаёт/переиспользует ViewModel для Activity.collectAsState()— подписка на Flow; при новом_petState— recomposition (226.md).LaunchedEffect(feedback)— side-effect при смене feedback; показ Snackbar и сброс.verticalScroll— на маленьких экранах контент прокручивается.
Самопроверка
- Три кнопки меняют stat; disabled-состояния работают.
- Snackbar после каждого действия.
- Полоски и Canvas обновляются без ручного
invalidate().
Этап 8 — MainActivity
Цель этапа
Точка входа — как в KotlinMobileApp, но TamagotchiScreen() и KotlinochiTheme.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
KotlinochiTheme {
TamagotchiScreen()
}
}
}
}
Финальная проверка
- Run — покормите и поиграйте; stat меняются, Snackbar реагирует.
- Подождите 1–2 минуты — passive decay.
- Свайп "закрыть" приложение, пауза 3 мин, открыть — offline decay.
- Сверка с
F:\Projects\JVM\Kotlin\Kotlinochi.
Самопроверка
- Полный сценарий совпадает с эталоном.
Сверка с эталоном
| Файл | На что смотреть |
|---|---|
PetViewModel.kt | DECAY_INTERVAL_MS, MAX_OFFLINE_TICKS, applyDecayStep, отмена job в onCleared |
PetRepository.kt | Пять ключей DataStore, дефолты 80 |
PetDisplay.kt | Все ветки when (mood) для рта и blink |
TamagotchiScreen.kt | enabled на кнопках, цвета Snackbar |
Что дальше
- Уменьшить
DECAY_INTERVAL_MSдо 5_000L для быстрых тестов. - Экран переименования питомца (
nameв DataStore). - Room + ViewModel — журнал действий.
- Публикация Android.
- Сравнить с Kivy Snake — другой язык, та же идея game loop.
Частые ошибки
| Симптом | Причина | Решение |
|---|---|---|
| Статы не падают | Нет startDecayLoop() или decay не в viewModelScope | Этап 3 |
| После перезапуска всё 80 | Не вызывается repository.save | persist() после каждого изменения |
| ViewModel не находится | Нет lifecycle-viewmodel-compose | Этап 0 |
| Canvas пустой | Нет Modifier.size у Canvas | Минимум 180.dp |
| Snackbar не показывается | feedback не сбрасывается | clearFeedback() после show |
| ANR | repository.save в main thread без coroutine | Только viewModelScope.launch |
Что попробовать
- Добавить четвёртый stat "счастье" — потребует новый ключ DataStore и ветку в
fromStats. - Вибрация при
PetMood.Critical—Vibratorиз Android API (1135). - Unit-тест
applyDecayStepбез эмулятора — 223.md.