Compare commits

...

8 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
3 changed files with 230 additions and 57 deletions

View File

@ -5,14 +5,18 @@
Android-приложение на современном стеке (Kotlin + Jetpack Compose) без лишней
сложности.
## Что внутри
## Что внутри (итоговая версия на `main`)
- Игровое поле-сетка 20×30
- Управление **свайпами** (вверх/вниз/влево/вправо)
- Змейка растёт, когда съедает еду; ведётся счёт и рекорд за сессию
- Столкновение со стеной или с собой = Game Over, **тап** — рестарт
- Игровое поле-сетка 20×30, управление пальцем как **джойстиком** (ведёшь — змейка поворачивает)
- Змейка растёт и **ускоряется** с ростом счёта; ведётся счёт, **уровень** и **рекорд** (сохраняется между запусками)
- **Бонус-еда** с таймером даёт больше очков; **препятствия** добавляются с каждым уровнем
- Границы поля **телепортируют** на противоположную сторону (тор); смерть — от самой себя и от препятствий
- **Звук** и **анимация-вспышка** при поедании, **пауза** по тапу
- Вся отрисовка — через Compose `Canvas`, игровой цикл — на корутине
> Каждая фича добавлялась отдельным этапом-веткой — см. раздел [Этапы](#этапы-ветки)
> ниже: там видно, что именно меняется на каждом шаге.
## Стек
| Что | Версия |
@ -61,22 +65,35 @@ app/src/main/
| Действие | Жест |
|----------|------|
| Начать игру | свайп или тап |
| Повернуть | свайп ↑ ↓ ← → |
| Начать игру | касание/движение или тап |
| Повернуть | **веди пальцем** как джойстиком (быстрый флик тоже работает) |
| Пауза / продолжить | тап по полю во время игры |
| Рестарт после проигрыша | тап по экрану |
## Этапы (ветки)
Проект задуман как серия этапов — каждый в своей ветке, чтобы можно было
сравнивать версии:
Проект построен как серия этапов: каждый — отдельная **ветка**, добавляющая
**одну** фичу поверх предыдущего. Между соседними ветками — маленький diff,
поэтому легко увидеть, что именно меняется и какой Android/Kotlin-приём за это
отвечает. `main` = последний этап (вся игра целиком).
- **`snake-stage-1`** — базовая рабочая игра.
- **`snake-stage-2`** — ускорение: змейка ускоряется с каждой съеденной едой
(задержка шага 150 → 60 мс), на экране индикатор «Скорость». Смотри
функции `stepDelay()` / `speedLevel()` и игровой цикл в `SnakeGame.kt`.
- **`snake-stage-3`** — рекорд сохраняется между запусками через **Jetpack
DataStore** (`ScoreStore.kt`): чтение — `Flow`, запись — suspend-функция,
диск не блокирует UI. Смотри `LaunchedEffect` для загрузки рекорда и
`scope.launch { ScoreStore.saveBest(...) }` при новом рекорде.
| Этап (ветка) | Что меняется | Ключевой приём | Где в коде |
|---|---|---|---|
| **`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:
```bash
git diff snake-stage-6 snake-stage-7
```
Идеи для следующих этапов: два игрока на одном экране, выбор размера поля, экран настроек.

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,6 +13,7 @@ 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
@ -24,8 +29,10 @@ 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
@ -52,6 +59,15 @@ private fun stepDelay(score: Int): Long =
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)
@ -62,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() {
@ -76,6 +94,11 @@ 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
@ -86,6 +109,29 @@ fun SnakeGame() {
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
dir = Cell(0, -1)
@ -93,31 +139,101 @@ 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) {
score++
if (score > best) {
best = score
// новый рекорд — сохраняем на диск (асинхронно, UI не блокируется)
scope.launch { ScoreStore.saveBest(context, best) }
// звук (для бонуса — выше тоном) и анимация вспышки на месте укуса
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))
}
food = randomFreeCell(snake)
}
if (ateFood) {
score++
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
}
}
@ -127,7 +243,7 @@ fun SnakeGame() {
LaunchedEffect(Unit) {
while (true) {
delay(stepDelay(score))
if (started && !gameOver) step()
if (started && !gameOver && !paused) step()
}
}
@ -143,54 +259,56 @@ fun SnakeGame() {
started = true
} else if (!started) {
started = true
} else {
paused = !paused // во время игры тап ставит/снимает паузу
}
}
}
// Свайпы: смена направления (и старт первым свайпом)
// Джойстик: ведёшь пальцем — змейка непрерывно поворачивает в сторону
// смещения пальца от точки касания (доминирующая ось). Мёртвая зона
// у центра гасит дрожание.
.pointerInput(Unit) {
var total = Offset.Zero
val deadZone = 28.dp.toPx()
var origin = Offset.Zero
detectDragGestures(
onDragStart = {
total = Offset.Zero
onDragStart = { pos ->
origin = pos
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, drag ->
total += drag
) { 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 Скорость: ${speedLevel(score)}",
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))
}
}
}
@ -208,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
@ -226,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) {
@ -245,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.