1
0

Compare commits

..

10 Commits

Author SHA1 Message Date
george
f81a1a88a2 Этап 10: джойстик-управление пальцем
Змейка непрерывно поворачивает в сторону смещения пальца от точки касания,
пока ведёшь по экрану (а не один поворот на отпускание свайпа).

- обработка в onDrag (а не onDragEnd): turn() вызывается на каждое движение
- направление по доминирующей оси смещения от origin; мёртвая зона 28dp
- общий turn() с запретом разворота на 180°; быстрый флик тоже работает
- подсказка на старте обновлена

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:49:27 +03:00
george
64b47a5536 docs: таблица этапов в README + актуализация описания
- раздел «Этапы» переписан таблицей: что меняется / приём / где в коде
- «Что внутри» и «Управление» приведены к итоговой версии (тор, пауза)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:40:05 +03:00
george
e84ffa754d Этап 9: сквозь границы (тор)
Змейка проходит сквозь край поля и появляется с противоположной стороны.

- координаты головы считаются по модулю: (x+dx+COLS)%COLS, (y+dy+ROWS)%ROWS
- столкновение с краем больше не убивает; смерть только от себя/препятствий

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:34:45 +03:00
george
03ed41118a Этап 8: препятствия и уровни
Прогрессия сложности: каждые 5 очков — новый уровень с новыми стенами.

- obstacles (Set<Cell>) и level; addObstacles() добавляет порцию стен,
  избегая змейки/еды/бонуса/других стен и клеток вплотную к голове
- столкновение со стеной-препятствием = Game Over
- еда и бонус не спавнятся в препятствиях
- двустрочная шапка со счётом/рекордом и уровнем/скоростью
- reset() очищает препятствия и уровень

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:29:44 +03:00
george
50e6677989 Этап 7: бонус-еда с таймером
Иногда при поедании обычной еды появляется золотая бонус-еда на
ограниченное время и даёт больше очков.

- bonusFood/bonusStepsLeft; шанс выката 1 из 4 при обычном поедании
- бонус живёт BONUS_LIFETIME_STEPS шагов (таймер в шагах -> учитывает паузу)
- съеденный бонус даёт BONUS_POINTS очков, звук выше тоном, вспышка
- отрисовка золотой клетки + дуга-таймер (drawArc) в drawBoard()
- randomFreeCell учитывает и еду, и бонус, чтобы не наложить их

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:20:02 +03:00
george
c3529f08a0 Этап 6: пауза
Тап по полю во время игры ставит/снимает паузу.

- флаг paused; игровой цикл шагает только при !paused
- тап в detectTapGestures переключает паузу (старт/рестарт без изменений)
- оверлей «ПАУЗА» с подсказкой о продолжении
- reset() сбрасывает паузу; подсказка про паузу на стартовом экране

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:06:54 +03:00
george
da783d9f0d Этап 5: анимация поедания еды
В момент съедания на месте еды расходится затухающее кольцо.

- Animatable popProgress (0->1 за один укус), запуск из step()
- отрисовка кольца в drawBoard() через drawCircle + Stroke,
  радиус растёт, прозрачность падает с прогрессом
- цвет кольца — цвет еды

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:00:07 +03:00
george
05d65eab14 Этап 4: звук при поедании еды
Короткий звуковой эффект при съедании еды через SoundPool.

- res/raw/eat.wav — короткий синтезированный «блип» (PCM 16-bit моно)
- SoundPool с AudioAttributes USAGE_GAME, загрузка сэмпла один раз
- воспроизведение в step() при поедании еды
- освобождение SoundPool в DisposableEffect при уходе с экрана

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:44:46 +03:00
george
d8efaa53a6 Этап 3: сохранение рекорда через DataStore
Рекорд теперь сохраняется между запусками приложения.

- ScoreStore.kt — обёртка над Preferences DataStore (Flow на чтение,
  suspend saveBest на запись; работа с диском вне UI-потока)
- загрузка рекорда при старте экрана через LaunchedEffect
- сохранение нового рекорда из step() через scope.launch
- зависимость androidx.datastore:datastore-preferences:1.1.1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:07:42 +03:00
george
6a1719c148 Этап 2: ускорение змейки
Змейка ускоряется по мере роста счёта: задержка между шагами
уменьшается со 150 мс до 60 мс (по 6 мс за каждую съеденную еду).

- stepDelay(score) — пересчёт задержки в игровом цикле LaunchedEffect
- speedLevel(score) — уровень скорости для индикатора на экране
- в шапке добавлен показатель «Скорость»

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:54:52 +03:00
5 changed files with 295 additions and 51 deletions

View File

@ -5,14 +5,18 @@
Android-приложение на современном стеке (Kotlin + Jetpack Compose) без лишней
сложности.
## Что внутри
## Что внутри (итоговая версия на `main`)
- Игровое поле-сетка 20×30
- Управление **свайпами** (вверх/вниз/влево/вправо)
- Змейка растёт, когда съедает еду; ведётся счёт и рекорд за сессию
- Столкновение со стеной или с собой = Game Over, **тап** — рестарт
- Игровое поле-сетка 20×30, управление пальцем как **джойстиком** (ведёшь — змейка поворачивает)
- Змейка растёт и **ускоряется** с ростом счёта; ведётся счёт, **уровень** и **рекорд** (сохраняется между запусками)
- **Бонус-еда** с таймером даёт больше очков; **препятствия** добавляются с каждым уровнем
- Границы поля **телепортируют** на противоположную сторону (тор); смерть — от самой себя и от препятствий
- **Звук** и **анимация-вспышка** при поедании, **пауза** по тапу
- Вся отрисовка — через Compose `Canvas`, игровой цикл — на корутине
> Каждая фича добавлялась отдельным этапом-веткой — см. раздел [Этапы](#этапы-ветки)
> ниже: там видно, что именно меняется на каждом шаге.
## Стек
| Что | Версия |
@ -61,18 +65,35 @@ 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` |
Идеи для следующих этапов: ускорение с ростом счёта, сохранение рекорда через
`DataStore`, экранные кнопки-стрелки, анимации.
Посмотреть, что именно поменялось на этапе N, удобно diff'ом между ветками,
например для этапа 7:
```bash
git diff snake-stage-6 snake-stage-7
```
Zdes' byl Irakli :)
Идеи для следующих этапов: два игрока на одном экране, выбор размера поля, экран настроек.

View File

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

View File

@ -0,0 +1,34 @@
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,5 +1,9 @@
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.background
import androidx.compose.foundation.gestures.detectDragGestures
@ -9,12 +13,15 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
@ -22,19 +29,44 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.material3.Text
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.random.Random
// --- Параметры игры ---
private const val COLS = 20
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). */
private data class Cell(val x: Int, val y: Int)
@ -46,6 +78,8 @@ private val GRID = Color(0xFF171C28)
private val BODY = Color(0xFF4CD964)
private val HEAD = Color(0xFF2ECC71)
private val FOOD = Color(0xFFFF453A)
private val BONUS = Color(0xFFFFD60A) // золотая бонус-еда
private val OBSTACLE = Color(0xFF5A6276) // серые препятствия-стены
@Composable
fun SnakeGame() {
@ -60,6 +94,43 @@ fun SnakeGame() {
var best by remember { mutableIntStateOf(0) }
var started 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() {
snake = startSnake
@ -68,35 +139,111 @@ fun SnakeGame() {
food = randomFreeCell(startSnake)
score = 0
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() {
dir = nextDir
val head = snake.first()
val nh = Cell(head.x + dir.x, head.y + dir.y)
// этап 9: граница экрана «телепортирует» на противоположную сторону (тор).
// %-арифметика с + 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 (hitWall || snake.contains(nh)) {
// смерть теперь только от самой себя или от препятствия (не от края)
if (snake.contains(nh) || nh in obstacles) {
gameOver = true
return
}
val grew = nh == food
// голова добавляется в начало; хвост убираем, если не съели еду
val ateFood = nh == food
val ateBonus = bonusFood == nh
val grew = ateFood || ateBonus
// голова добавляется в начало; хвост убираем, если ничего не съели
snake = if (grew) listOf(nh) + snake else listOf(nh) + snake.dropLast(1)
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++
if (score > best) best = score
food = randomFreeCell(snake)
food = randomFreeCell(snake + obstacles + listOfNotNull(bonusFood))
// иногда выкатываем бонус (если его сейчас нет на поле)
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) {
while (true) {
delay(STEP_MS)
if (started && !gameOver) step()
delay(stepDelay(score))
if (started && !gameOver && !paused) step()
}
}
@ -112,54 +259,56 @@ fun SnakeGame() {
started = true
} else if (!started) {
started = true
}
}
}
// Свайпы: смена направления (и старт первым свайпом)
.pointerInput(Unit) {
var total = Offset.Zero
detectDragGestures(
onDragStart = {
total = Offset.Zero
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)
paused = !paused // во время игры тап ставит/снимает паузу
}
}
}
) { change, drag ->
total += drag
// Джойстик: ведёшь пальцем — змейка непрерывно поворачивает в сторону
// смещения пальца от точки касания (доминирующая ось). Мёртвая зона
// у центра гасит дрожание.
.pointerInput(Unit) {
val deadZone = 28.dp.toPx()
var origin = Offset.Zero
detectDragGestures(
onDragStart = { pos ->
origin = pos
if (!started && !gameOver) started = true
}
) { change, _ ->
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()) {
drawBoard(snake, food)
drawBoard(snake, food, bonusFood, bonusFrac, obstacles, popCell, popProgress.value)
}
// Счёт сверху
// Счёт сверху: две строки (счёт/рекорд и уровень/скорость)
Text(
text = "Счёт: $score Рекорд: $best",
text = "Счёт: $score Рекорд: $best\nУровень: $level Скорость: ${speedLevel(score)}",
color = Color.White,
fontSize = 20.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 48.dp)
.padding(top = 40.dp)
)
// Оверлеи: старт и проигрыш
// Оверлеи: старт, пауза и проигрыш
if (!started) {
Overlay(title = "ЗМЕЙКА", subtitle = "Свайпни, чтобы начать", modifier = Modifier.align(Alignment.Center))
Overlay(title = "ЗМЕЙКА", subtitle = "Веди пальцем — поворот · тап — пауза", modifier = Modifier.align(Alignment.Center))
} else if (gameOver) {
Overlay(title = "GAME OVER", subtitle = "Счёт: $score · тапни для рестарта", modifier = Modifier.align(Alignment.Center))
} else if (paused) {
Overlay(title = "ПАУЗА", subtitle = "Тапни, чтобы продолжить", modifier = Modifier.align(Alignment.Center))
}
}
}
@ -177,7 +326,15 @@ private fun Overlay(title: String, subtitle: String, modifier: Modifier = Modifi
}
/** Вся отрисовка поля внутри DrawScope. */
private fun DrawScope.drawBoard(snake: List<Cell>, food: Cell) {
private fun DrawScope.drawBoard(
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 offX = (size.width - cell * COLS) / 2f
val offY = (size.height - cell * ROWS) / 2f
@ -195,13 +352,43 @@ private fun DrawScope.drawBoard(snake: List<Cell>, food: Cell) {
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)
// бонус-еда + дуга-таймер вокруг неё (тает по часовой)
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 ->
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) {
@ -214,10 +401,10 @@ private fun DrawScope.drawCell(c: Cell, color: Color, cell: Float, offX: Float,
)
}
/** Случайная свободная клетка (не занятая змейкой) — для появления еды. */
private fun randomFreeCell(snake: List<Cell>): Cell {
/** Случайная свободная клетка (не занятая переданными клетками) — для появления еды/бонуса. */
private fun randomFreeCell(occupied: List<Cell>): Cell {
while (true) {
val p = Cell(Random.nextInt(COLS), Random.nextInt(ROWS))
if (!snake.contains(p)) return p
if (!occupied.contains(p)) return p
}
}

Binary file not shown.