Перейти к основному содержимому

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
MVVMModel (PetState) + View (TamagotchiScreen) + ViewModel (PetViewModel)
DataStoreАсинхронное key-value хранилище вместо SharedPreferences
StateFlowПоток состояния питомца; UI подписывается через collectAsState()
decayПлановое уменьшение статов
offline decayДогоняющие тики за время, пока приложение было закрыто
CanvasОтрисовка кота без PNG-спрайтов

Архитектура

Оценка времени — 3–5 часов.

Карта этапов

ЭтапФокусРезультат
0GradleViewModel, DataStore, Compose
1ModelPetState, PetMood, PetAction
2RepositoryFlow из DataStore, suspend save
3ViewModelДействия, decay, offline
4ThemeПастельная палитра
5КомпонентыStatBar, ActionButton
6PetDisplayCanvas-кот и анимация
7TamagotchiScreenСборка экрана, Snackbar
8MainActivityЗапуск

Этап 0 — проект и зависимости

Цель этапа

Android-проект с Compose и библиотеками для MVVM и персистентности.

Шаги

  1. New Project → Empty Activity, package com.kotlinochi.tamagotchi, minSdk 26.
  2. В 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-composeviewModel() внутри @Composable
lifecycle-runtime-composecollectAsState() для 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() → обновляет _petStaterepository.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()
}
}
}
}

Финальная проверка

  1. Run — покормите и поиграйте; stat меняются, Snackbar реагирует.
  2. Подождите 1–2 минуты — passive decay.
  3. Свайп "закрыть" приложение, пауза 3 мин, открыть — offline decay.
  4. Сверка с F:\Projects\JVM\Kotlin\Kotlinochi.

Самопроверка

  • Полный сценарий совпадает с эталоном.

Сверка с эталоном

ФайлНа что смотреть
PetViewModel.ktDECAY_INTERVAL_MS, MAX_OFFLINE_TICKS, applyDecayStep, отмена job в onCleared
PetRepository.ktПять ключей DataStore, дефолты 80
PetDisplay.ktВсе ветки when (mood) для рта и blink
TamagotchiScreen.ktenabled на кнопках, цвета Snackbar

Что дальше

  • Уменьшить DECAY_INTERVAL_MS до 5_000L для быстрых тестов.
  • Экран переименования питомца (name в DataStore).
  • Room + ViewModel — журнал действий.
  • Публикация Android.
  • Сравнить с Kivy Snake — другой язык, та же идея game loop.

Частые ошибки

СимптомПричинаРешение
Статы не падаютНет startDecayLoop() или decay не в viewModelScopeЭтап 3
После перезапуска всё 80Не вызывается repository.savepersist() после каждого изменения
ViewModel не находитсяНет lifecycle-viewmodel-composeЭтап 0
Canvas пустойНет Modifier.size у CanvasМинимум 180.dp
Snackbar не показываетсяfeedback не сбрасываетсяclearFeedback() после show
ANRrepository.save в main thread без coroutineТолько viewModelScope.launch

Что попробовать

  1. Добавить четвёртый stat "счастье" — потребует новый ключ DataStore и ветку в fromStats.
  2. Вибрация при PetMood.CriticalVibrator из Android API (1135).
  3. Unit-тест applyDecayStep без эмулятора — 223.md.