1
0

Compare commits

..

1 Commits

Author SHA1 Message Date
bb23490e7c I was done the commit for onboarding 2026-06-24 15:06:06 +03:00
5 changed files with 51 additions and 295 deletions

View File

@ -5,18 +5,14 @@
Android-приложение на современном стеке (Kotlin + Jetpack Compose) без лишней Android-приложение на современном стеке (Kotlin + Jetpack Compose) без лишней
сложности. сложности.
## Что внутри (итоговая версия на `main`) ## Что внутри
- Игровое поле-сетка 20×30, управление пальцем как **джойстиком** (ведёшь — змейка поворачивает) - Игровое поле-сетка 20×30
- Змейка растёт и **ускоряется** с ростом счёта; ведётся счёт, **уровень** и **рекорд** (сохраняется между запусками) - Управление **свайпами** (вверх/вниз/влево/вправо)
- **Бонус-еда** с таймером даёт больше очков; **препятствия** добавляются с каждым уровнем - Змейка растёт, когда съедает еду; ведётся счёт и рекорд за сессию
- Границы поля **телепортируют** на противоположную сторону (тор); смерть — от самой себя и от препятствий - Столкновение со стеной или с собой = Game Over, **тап** — рестарт
- **Звук** и **анимация-вспышка** при поедании, **пауза** по тапу
- Вся отрисовка — через Compose `Canvas`, игровой цикл — на корутине - Вся отрисовка — через Compose `Canvas`, игровой цикл — на корутине
> Каждая фича добавлялась отдельным этапом-веткой — см. раздел [Этапы](#этапы-ветки)
> ниже: там видно, что именно меняется на каждом шаге.
## Стек ## Стек
| Что | Версия | | Что | Версия |
@ -65,35 +61,18 @@ app/src/main/
| Действие | Жест | | Действие | Жест |
|----------|------| |----------|------|
| Начать игру | касание/движение или тап | | Начать игру | свайп или тап |
| Повернуть | **веди пальцем** как джойстиком (быстрый флик тоже работает) | | Повернуть | свайп ↑ ↓ ← → |
| Пауза / продолжить | тап по полю во время игры |
| Рестарт после проигрыша | тап по экрану | | Рестарт после проигрыша | тап по экрану |
## Этапы (ветки) ## Этапы (ветки)
Проект построен как серия этапов: каждый — отдельная **ветка**, добавляющая Проект задуман как серия этапов — каждый в своей ветке, чтобы можно было
**одну** фичу поверх предыдущего. Между соседними ветками — маленький diff, сравнивать версии:
поэтому легко увидеть, что именно меняется и какой Android/Kotlin-приём за это
отвечает. `main` = последний этап (вся игра целиком).
| Этап (ветка) | Что меняется | Ключевой приём | Где в коде | - **`snake-stage-1`** — базовая рабочая игра (этот код).
|---|---|---|---|
| **`snake-stage-1`** | Базовая игра: поле 20×30, рост змейки, счёт, рекорд за сессию, свайпы, Game Over об стену/себя | Compose `Canvas`, игровой цикл на корутине, состояние в `remember` | весь `SnakeGame.kt` |
| **`snake-stage-2`** | Змейка ускоряется с ростом счёта (150→60 мс), индикатор «Скорость» | задержка шага пересчитывается из состояния | `stepDelay()`, `speedLevel()` |
| **`snake-stage-3`** | Рекорд **сохраняется между запусками** | Jetpack **DataStore** (`Flow` на чтение, `suspend` на запись) | `ScoreStore.kt`, загрузка в `LaunchedEffect` |
| **`snake-stage-4`** | **Звук** при поедании еды | **SoundPool** + `res/raw/eat.wav`, очистка в `DisposableEffect` | `soundPool.play(...)` в `step()` |
| **`snake-stage-5`** | **Анимация-вспышка** (кольцо) в момент укуса | **`Animatable`** (0→1) + `drawCircle` | `popProgress`, `drawBoard()` |
| **`snake-stage-6`** | **Пауза** по тапу во время игры | флаг `paused` в условии игрового цикла | `detectTapGestures`, оверлей «ПАУЗА» |
| **`snake-stage-7`** | **Бонус-еда** с таймером (1 из 4), +3 очка, золотая, исчезает по времени | таймер в «шагах» (учитывает паузу) + `drawArc` | `bonusFood`/`bonusStepsLeft`, `step()`/`drawBoard()` |
| **`snake-stage-8`** | **Препятствия и уровни**: каждые 5 очков +порция стен; врезался — Game Over | генерация на `Set<Cell>`, коллизии, прогрессия | `obstacles`/`level`, `addObstacles()` |
| **`snake-stage-9`** | **Сквозь границы (тор)**: уходишь за край — появляешься с другой стороны | модульная арифметика координат `% COLS/ROWS` | `step()` |
| **`snake-stage-10`** | **Джойстик**: ведёшь пальцем — змейка непрерывно поворачивает в сторону смещения от точки касания | `detectDragGestures` с обработкой в `onDrag` (а не на отпускании) + мёртвая зона | `turn()`, блок `pointerInput` |
Посмотреть, что именно поменялось на этапе N, удобно diff'ом между ветками, Идеи для следующих этапов: ускорение с ростом счёта, сохранение рекорда через
например для этапа 7: `DataStore`, экранные кнопки-стрелки, анимации.
```bash
git diff snake-stage-6 snake-stage-7
```
Идеи для следующих этапов: два игрока на одном экране, выбор размера поля, экран настроек. Zdes' byl Irakli :)

View File

@ -42,6 +42,4 @@ dependencies {
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
// Этап 3: сохранение рекорда между запусками
implementation("androidx.datastore:datastore-preferences:1.1.1")
} }

View File

@ -1,34 +0,0 @@
package com.example.snake
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
// Один DataStore на всё приложение (файл snake_prefs.preferences_pb во
// внутреннем хранилище). Расширение Context — стандартный приём для DataStore.
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "snake_prefs")
/**
* Хранилище рекорда между запусками приложения.
*
* DataStore работает асинхронно (на корутинах): чтение это [Flow],
* запись suspend-функция. Так UI никогда не блокируется обращением к диску.
*/
object ScoreStore {
private val BEST_KEY = intPreferencesKey("best_score")
/** Поток рекорда. Если ещё ничего не сохраняли — отдаёт 0. */
fun bestFlow(context: Context): Flow<Int> =
context.dataStore.data.map { prefs -> prefs[BEST_KEY] ?: 0 }
/** Сохранить новый рекорд. */
suspend fun saveBest(context: Context, value: Int) {
context.dataStore.edit { prefs -> prefs[BEST_KEY] = value }
}
}

View File

@ -1,9 +1,5 @@
package com.example.snake package com.example.snake
import android.media.AudioAttributes
import android.media.SoundPool
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
@ -13,15 +9,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
@ -29,44 +22,19 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.material3.Text import androidx.compose.material3.Text
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
import kotlin.random.Random import kotlin.random.Random
// --- Параметры игры --- // --- Параметры игры ---
private const val COLS = 20 private const val COLS = 20
private const val ROWS = 30 private const val ROWS = 30
private const val STEP_MS = 130L
// --- Скорость и ускорение (этап 2) ---
private const val START_STEP_MS = 150L // стартовая задержка между шагами (медленнее = безопаснее старт)
private const val MIN_STEP_MS = 60L // потолок скорости: быстрее этого змейка не станет
private const val SPEEDUP_MS = 6L // на сколько мс ускоряемся за каждую съеденную еду
/** Текущая задержка шага: чем больше счёт, тем меньше задержка (но не ниже MIN_STEP_MS). */
private fun stepDelay(score: Int): Long =
(START_STEP_MS - score * SPEEDUP_MS).coerceAtLeast(MIN_STEP_MS)
/** Уровень скорости для индикатора: растёт со счётом, пока не упёрлись в потолок. */
private fun speedLevel(score: Int): Int =
1 + minOf(score, ((START_STEP_MS - MIN_STEP_MS) / SPEEDUP_MS).toInt())
// --- Уровни и препятствия (этап 8) ---
private const val POINTS_PER_LEVEL = 5 // каждые N очков — новый уровень
private const val OBSTACLES_PER_LEVEL = 4 // сколько препятствий добавляется за уровень
// --- Бонус-еда (этап 7) ---
private const val BONUS_LIFETIME_STEPS = 36 // сколько шагов живёт бонус (~5 c на старте)
private const val BONUS_POINTS = 3 // бонус стоит как 3 обычные еды
private const val BONUS_CHANCE = 4 // шанс выката бонуса при поедании обычной еды: 1 из N
/** Клетка поля / сегмент змейки. Direction тоже храним как Cell (dx, dy). */ /** Клетка поля / сегмент змейки. Direction тоже храним как Cell (dx, dy). */
private data class Cell(val x: Int, val y: Int) private data class Cell(val x: Int, val y: Int)
@ -78,8 +46,6 @@ private val GRID = Color(0xFF171C28)
private val BODY = Color(0xFF4CD964) private val BODY = Color(0xFF4CD964)
private val HEAD = Color(0xFF2ECC71) private val HEAD = Color(0xFF2ECC71)
private val FOOD = Color(0xFFFF453A) private val FOOD = Color(0xFFFF453A)
private val BONUS = Color(0xFFFFD60A) // золотая бонус-еда
private val OBSTACLE = Color(0xFF5A6276) // серые препятствия-стены
@Composable @Composable
fun SnakeGame() { fun SnakeGame() {
@ -94,43 +60,6 @@ fun SnakeGame() {
var best by remember { mutableIntStateOf(0) } var best by remember { mutableIntStateOf(0) }
var started by remember { mutableStateOf(false) } var started by remember { mutableStateOf(false) }
var gameOver by remember { mutableStateOf(false) } var gameOver by remember { mutableStateOf(false) }
var paused by remember { mutableStateOf(false) } // этап 6: пауза
var bonusFood by remember { mutableStateOf<Cell?>(null) } // этап 7: бонус-еда (null = нет)
var bonusStepsLeft by remember { mutableIntStateOf(0) } // сколько шагов ей осталось жить
var level by remember { mutableIntStateOf(1) } // этап 8: уровень
var obstacles by remember { mutableStateOf<Set<Cell>>(emptySet()) } // препятствия-стены
// Этап 3: контекст и scope для работы с DataStore (рекорд хранится на диске)
val context = LocalContext.current
val scope = rememberCoroutineScope()
// Подгружаем сохранённый рекорд один раз при запуске экрана
LaunchedEffect(Unit) {
best = ScoreStore.bestFlow(context).first()
}
// Этап 4: звук поедания еды через SoundPool (короткий эффект, низкая задержка).
// Пул и загруженный сэмпл создаём один раз и освобождаем при уходе с экрана.
val soundPool = remember {
SoundPool.Builder()
.setMaxStreams(4)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
.build()
}
val eatSoundId = remember { soundPool.load(context, R.raw.eat, 1) }
DisposableEffect(Unit) {
onDispose { soundPool.release() }
}
// Этап 5: анимация поедания — расходящееся затухающее кольцо на месте еды.
// popProgress идёт 0→1 за один укус; 1f означает «анимация завершена».
val popProgress = remember { Animatable(1f) }
var popCell by remember { mutableStateOf(food) }
fun reset() { fun reset() {
snake = startSnake snake = startSnake
@ -139,111 +68,35 @@ fun SnakeGame() {
food = randomFreeCell(startSnake) food = randomFreeCell(startSnake)
score = 0 score = 0
gameOver = false gameOver = false
paused = false
bonusFood = null
bonusStepsLeft = 0
level = 1
obstacles = emptySet()
}
// Сменить направление (для джойстика). Запрет разворота на 180°; первый ввод запускает игру.
fun turn(dx: Int, dy: Int) {
if (gameOver) return
if (!started) started = true
if (dx != 0 && dir.x == -dx) return
if (dy != 0 && dir.y == -dy) return
nextDir = Cell(dx, dy)
}
// Добавить порцию препятствий в свободные клетки (не у головы змейки).
fun addObstacles(count: Int) {
val head = snake.first()
val avoid = (snake + obstacles + food + listOfNotNull(bonusFood)).toSet()
val added = mutableSetOf<Cell>()
var tries = 0
while (added.size < count && tries < 300) {
tries++
val p = Cell(Random.nextInt(COLS), Random.nextInt(ROWS))
if (p in avoid || p in added) continue
if (abs(p.x - head.x) + abs(p.y - head.y) <= 2) continue // не вплотную к голове
added += p
}
obstacles = obstacles + added
} }
// Один шаг змейки // Один шаг змейки
fun step() { fun step() {
dir = nextDir dir = nextDir
val head = snake.first() val head = snake.first()
// этап 9: граница экрана «телепортирует» на противоположную сторону (тор). val nh = Cell(head.x + dir.x, head.y + dir.y)
// %-арифметика с + COLS/ROWS, чтобы корректно обрабатывать выход за 0.
val nh = Cell(
(head.x + dir.x + COLS) % COLS,
(head.y + dir.y + ROWS) % ROWS
)
// смерть теперь только от самой себя или от препятствия (не от края) val hitWall = nh.x < 0 || nh.y < 0 || nh.x >= COLS || nh.y >= ROWS
if (snake.contains(nh) || nh in obstacles) { if (hitWall || snake.contains(nh)) {
gameOver = true gameOver = true
return return
} }
val ateFood = nh == food val grew = nh == food
val ateBonus = bonusFood == nh // голова добавляется в начало; хвост убираем, если не съели еду
val grew = ateFood || ateBonus
// голова добавляется в начало; хвост убираем, если ничего не съели
snake = if (grew) listOf(nh) + snake else listOf(nh) + snake.dropLast(1) snake = if (grew) listOf(nh) + snake else listOf(nh) + snake.dropLast(1)
if (grew) { if (grew) {
// звук (для бонуса — выше тоном) и анимация вспышки на месте укуса
soundPool.play(eatSoundId, 1f, 1f, 1, 0, if (ateBonus) 1.5f else 1f)
popCell = nh
scope.launch {
popProgress.snapTo(0f)
popProgress.animateTo(1f, animationSpec = tween(durationMillis = 260))
}
}
if (ateFood) {
score++ score++
food = randomFreeCell(snake + obstacles + listOfNotNull(bonusFood)) if (score > best) best = score
// иногда выкатываем бонус (если его сейчас нет на поле) food = randomFreeCell(snake)
if (bonusFood == null && Random.nextInt(BONUS_CHANCE) == 0) {
bonusFood = randomFreeCell(snake + obstacles + listOf(food))
bonusStepsLeft = BONUS_LIFETIME_STEPS
}
}
if (ateBonus) {
score += BONUS_POINTS
bonusFood = null
bonusStepsLeft = 0
}
if (grew && score > best) {
best = score
// новый рекорд — сохраняем на диск (асинхронно, UI не блокируется)
scope.launch { ScoreStore.saveBest(context, best) }
}
// повышение уровня: каждые POINTS_PER_LEVEL очков добавляем препятствия
val newLevel = score / POINTS_PER_LEVEL + 1
if (newLevel > level) {
repeat(newLevel - level) { addObstacles(OBSTACLES_PER_LEVEL) }
level = newLevel
}
// таймер бонуса: считаем в шагах, поэтому пауза учитывается автоматически
if (bonusFood != null) {
bonusStepsLeft--
if (bonusStepsLeft <= 0) bonusFood = null
} }
} }
// Игровой цикл на корутине — пока компонент в композиции. // Игровой цикл на корутине — пока компонент в композиции
// Задержка пересчитывается каждый шаг из текущего счёта, поэтому
// змейка плавно ускоряется по мере поедания еды.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {
delay(stepDelay(score)) delay(STEP_MS)
if (started && !gameOver && !paused) step() if (started && !gameOver) step()
} }
} }
@ -259,56 +112,54 @@ fun SnakeGame() {
started = true started = true
} else if (!started) { } else if (!started) {
started = true started = true
} else {
paused = !paused // во время игры тап ставит/снимает паузу
} }
} }
} }
// Джойстик: ведёшь пальцем — змейка непрерывно поворачивает в сторону // Свайпы: смена направления (и старт первым свайпом)
// смещения пальца от точки касания (доминирующая ось). Мёртвая зона
// у центра гасит дрожание.
.pointerInput(Unit) { .pointerInput(Unit) {
val deadZone = 28.dp.toPx() var total = Offset.Zero
var origin = Offset.Zero
detectDragGestures( detectDragGestures(
onDragStart = { pos -> onDragStart = {
origin = pos total = Offset.Zero
if (!started && !gameOver) started = true if (!started && !gameOver) started = true
},
onDragEnd = {
if (started && !gameOver) {
if (abs(total.x) > abs(total.y)) {
if (total.x > 0 && dir.x != -1) nextDir = Cell(1, 0)
else if (total.x < 0 && dir.x != 1) nextDir = Cell(-1, 0)
} else {
if (total.y > 0 && dir.y != -1) nextDir = Cell(0, 1)
else if (total.y < 0 && dir.y != 1) nextDir = Cell(0, -1)
}
}
} }
) { change, _ -> ) { change, drag ->
total += drag
change.consume() change.consume()
val off = change.position - origin
if (off.getDistance() >= deadZone) {
if (abs(off.x) > abs(off.y)) turn(if (off.x > 0) 1 else -1, 0)
else turn(0, if (off.y > 0) 1 else -1)
}
} }
} }
) { ) {
// Игровое поле // Игровое поле
val bonusFrac = if (bonusStepsLeft > 0) bonusStepsLeft.toFloat() / BONUS_LIFETIME_STEPS else 0f
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
drawBoard(snake, food, bonusFood, bonusFrac, obstacles, popCell, popProgress.value) drawBoard(snake, food)
} }
// Счёт сверху: две строки (счёт/рекорд и уровень/скорость) // Счёт сверху
Text( Text(
text = "Счёт: $score Рекорд: $best\nУровень: $level Скорость: ${speedLevel(score)}", text = "Счёт: $score Рекорд: $best",
color = Color.White, color = Color.White,
fontSize = 20.sp, fontSize = 20.sp,
textAlign = TextAlign.Center,
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(top = 40.dp) .padding(top = 48.dp)
) )
// Оверлеи: старт, пауза и проигрыш // Оверлеи: старт и проигрыш
if (!started) { if (!started) {
Overlay(title = "ЗМЕЙКА", subtitle = "Веди пальцем — поворот · тап — пауза", modifier = Modifier.align(Alignment.Center)) Overlay(title = "ЗМЕЙКА", subtitle = "Свайпни, чтобы начать", modifier = Modifier.align(Alignment.Center))
} else if (gameOver) { } else if (gameOver) {
Overlay(title = "GAME OVER", subtitle = "Счёт: $score · тапни для рестарта", modifier = Modifier.align(Alignment.Center)) Overlay(title = "GAME OVER", subtitle = "Счёт: $score · тапни для рестарта", modifier = Modifier.align(Alignment.Center))
} else if (paused) {
Overlay(title = "ПАУЗА", subtitle = "Тапни, чтобы продолжить", modifier = Modifier.align(Alignment.Center))
} }
} }
} }
@ -326,15 +177,7 @@ private fun Overlay(title: String, subtitle: String, modifier: Modifier = Modifi
} }
/** Вся отрисовка поля внутри DrawScope. */ /** Вся отрисовка поля внутри DrawScope. */
private fun DrawScope.drawBoard( private fun DrawScope.drawBoard(snake: List<Cell>, food: Cell) {
snake: List<Cell>,
food: Cell,
bonusFood: Cell?,
bonusFrac: Float,
obstacles: Set<Cell>,
popCell: Cell,
popProgress: Float
) {
val cell = minOf(size.width / COLS, size.height / ROWS) val cell = minOf(size.width / COLS, size.height / ROWS)
val offX = (size.width - cell * COLS) / 2f val offX = (size.width - cell * COLS) / 2f
val offY = (size.height - cell * ROWS) / 2f val offY = (size.height - cell * ROWS) / 2f
@ -352,43 +195,13 @@ private fun DrawScope.drawBoard(
drawLine(GRID, Offset(offX, y), Offset(offX + cell * COLS, y), strokeWidth = 1f) drawLine(GRID, Offset(offX, y), Offset(offX + cell * COLS, y), strokeWidth = 1f)
} }
// препятствия-стены
obstacles.forEach { drawCell(it, OBSTACLE, cell, offX, offY) }
// еда // еда
drawCell(food, FOOD, cell, offX, offY) drawCell(food, FOOD, cell, offX, offY)
// бонус-еда + дуга-таймер вокруг неё (тает по часовой)
if (bonusFood != null) {
drawCell(bonusFood, BONUS, cell, offX, offY)
val pad = cell * 0.06f
drawArc(
color = BONUS,
startAngle = -90f,
sweepAngle = 360f * bonusFrac,
useCenter = false,
topLeft = Offset(offX + bonusFood.x * cell - pad, offY + bonusFood.y * cell - pad),
size = Size(cell + 2 * pad, cell + 2 * pad),
style = Stroke(width = cell * 0.12f)
)
}
// змейка (голова — другим цветом) // змейка (голова — другим цветом)
snake.forEachIndexed { idx, c -> snake.forEachIndexed { idx, c ->
drawCell(c, if (idx == 0) HEAD else BODY, cell, offX, offY) drawCell(c, if (idx == 0) HEAD else BODY, cell, offX, offY)
} }
// анимация поедания: расходящееся затухающее кольцо на месте съеденной еды
if (popProgress < 1f) {
val center = Offset(offX + popCell.x * cell + cell / 2f, offY + popCell.y * cell + cell / 2f)
val radius = cell * (0.4f + popProgress * 1.4f)
drawCircle(
color = FOOD.copy(alpha = 1f - popProgress),
radius = radius,
center = center,
style = Stroke(width = cell * 0.16f)
)
}
} }
private fun DrawScope.drawCell(c: Cell, color: Color, cell: Float, offX: Float, offY: Float) { private fun DrawScope.drawCell(c: Cell, color: Color, cell: Float, offX: Float, offY: Float) {
@ -401,10 +214,10 @@ private fun DrawScope.drawCell(c: Cell, color: Color, cell: Float, offX: Float,
) )
} }
/** Случайная свободная клетка (не занятая переданными клетками) — для появления еды/бонуса. */ /** Случайная свободная клетка (не занятая змейкой) — для появления еды. */
private fun randomFreeCell(occupied: List<Cell>): Cell { private fun randomFreeCell(snake: List<Cell>): Cell {
while (true) { while (true) {
val p = Cell(Random.nextInt(COLS), Random.nextInt(ROWS)) val p = Cell(Random.nextInt(COLS), Random.nextInt(ROWS))
if (!occupied.contains(p)) return p if (!snake.contains(p)) return p
} }
} }

Binary file not shown.