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

View File

@ -42,6 +42,4 @@ 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

@ -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
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
@ -13,15 +9,12 @@ 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
@ -29,44 +22,19 @@ 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
// --- Скорость и ускорение (этап 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
private const val STEP_MS = 130L
/** Клетка поля / сегмент змейки. Direction тоже храним как Cell (dx, dy). */
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 HEAD = Color(0xFF2ECC71)
private val FOOD = Color(0xFFFF453A)
private val BONUS = Color(0xFFFFD60A) // золотая бонус-еда
private val OBSTACLE = Color(0xFF5A6276) // серые препятствия-стены
@Composable
fun SnakeGame() {
@ -94,43 +60,6 @@ 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
@ -139,111 +68,35 @@ 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()
// этап 9: граница экрана «телепортирует» на противоположную сторону (тор).
// %-арифметика с + COLS/ROWS, чтобы корректно обрабатывать выход за 0.
val nh = Cell(
(head.x + dir.x + COLS) % COLS,
(head.y + dir.y + ROWS) % ROWS
)
val nh = Cell(head.x + dir.x, head.y + dir.y)
// смерть теперь только от самой себя или от препятствия (не от края)
if (snake.contains(nh) || nh in obstacles) {
val hitWall = nh.x < 0 || nh.y < 0 || nh.x >= COLS || nh.y >= ROWS
if (hitWall || snake.contains(nh)) {
gameOver = true
return
}
val ateFood = nh == food
val ateBonus = bonusFood == nh
val grew = ateFood || ateBonus
// голова добавляется в начало; хвост убираем, если ничего не съели
val grew = nh == food
// голова добавляется в начало; хвост убираем, если не съели еду
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++
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
if (score > best) best = score
food = randomFreeCell(snake)
}
}
// Игровой цикл на корутине — пока компонент в композиции.
// Задержка пересчитывается каждый шаг из текущего счёта, поэтому
// змейка плавно ускоряется по мере поедания еды.
// Игровой цикл на корутине — пока компонент в композиции
LaunchedEffect(Unit) {
while (true) {
delay(stepDelay(score))
if (started && !gameOver && !paused) step()
delay(STEP_MS)
if (started && !gameOver) step()
}
}
@ -259,56 +112,54 @@ fun SnakeGame() {
started = true
} else if (!started) {
started = true
} else {
paused = !paused // во время игры тап ставит/снимает паузу
}
}
}
// Джойстик: ведёшь пальцем — змейка непрерывно поворачивает в сторону
// смещения пальца от точки касания (доминирующая ось). Мёртвая зона
// у центра гасит дрожание.
// Свайпы: смена направления (и старт первым свайпом)
.pointerInput(Unit) {
val deadZone = 28.dp.toPx()
var origin = Offset.Zero
var total = Offset.Zero
detectDragGestures(
onDragStart = { pos ->
origin = pos
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)
}
}
}
) { change, _ ->
) { change, drag ->
total += drag
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, bonusFood, bonusFrac, obstacles, popCell, popProgress.value)
drawBoard(snake, food)
}
// Счёт сверху: две строки (счёт/рекорд и уровень/скорость)
// Счёт сверху
Text(
text = "Счёт: $score Рекорд: $best\nУровень: $level Скорость: ${speedLevel(score)}",
text = "Счёт: $score Рекорд: $best",
color = Color.White,
fontSize = 20.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 40.dp)
.padding(top = 48.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))
}
}
}
@ -326,15 +177,7 @@ private fun Overlay(title: String, subtitle: String, modifier: Modifier = Modifi
}
/** Вся отрисовка поля внутри DrawScope. */
private fun DrawScope.drawBoard(
snake: List<Cell>,
food: Cell,
bonusFood: Cell?,
bonusFrac: Float,
obstacles: Set<Cell>,
popCell: Cell,
popProgress: Float
) {
private fun DrawScope.drawBoard(snake: List<Cell>, food: Cell) {
val cell = minOf(size.width / COLS, size.height / ROWS)
val offX = (size.width - cell * COLS) / 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)
}
// препятствия-стены
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) {
@ -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) {
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.