forked from george/snake-compose
Compare commits
4 Commits
snake-stag
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f81a1a88a2 | ||
|
|
64b47a5536 | ||
|
|
e84ffa754d | ||
|
|
03ed41118a |
70
README.md
70
README.md
@ -5,14 +5,18 @@
|
||||
Android-приложение на современном стеке (Kotlin + Jetpack Compose) без лишней
|
||||
сложности.
|
||||
|
||||
## Что внутри
|
||||
## Что внутри (итоговая версия на `main`)
|
||||
|
||||
- Игровое поле-сетка 20×30
|
||||
- Управление **свайпами** (вверх/вниз/влево/вправо)
|
||||
- Змейка растёт, когда съедает еду; ведётся счёт и рекорд за сессию
|
||||
- Столкновение со стеной или с собой = Game Over, **тап** — рестарт
|
||||
- Игровое поле-сетка 20×30, управление пальцем как **джойстиком** (ведёшь — змейка поворачивает)
|
||||
- Змейка растёт и **ускоряется** с ростом счёта; ведётся счёт, **уровень** и **рекорд** (сохраняется между запусками)
|
||||
- **Бонус-еда** с таймером даёт больше очков; **препятствия** добавляются с каждым уровнем
|
||||
- Границы поля **телепортируют** на противоположную сторону (тор); смерть — от самой себя и от препятствий
|
||||
- **Звук** и **анимация-вспышка** при поедании, **пауза** по тапу
|
||||
- Вся отрисовка — через Compose `Canvas`, игровой цикл — на корутине
|
||||
|
||||
> Каждая фича добавлялась отдельным этапом-веткой — см. раздел [Этапы](#этапы-ветки)
|
||||
> ниже: там видно, что именно меняется на каждом шаге.
|
||||
|
||||
## Стек
|
||||
|
||||
| Что | Версия |
|
||||
@ -61,41 +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` |
|
||||
|
||||
- **`snake-stage-4`** — звук при поедании еды через **SoundPool**: короткий
|
||||
WAV в `res/raw/eat.wav` грузится один раз, играется при каждом съедании
|
||||
(низкая задержка). Смотри настройку `SoundPool` и `soundPool.play(...)`
|
||||
в `step()`; освобождение ресурса — в `DisposableEffect`.
|
||||
Посмотреть, что именно поменялось на этапе N, удобно diff'ом между ветками,
|
||||
например для этапа 7:
|
||||
```bash
|
||||
git diff snake-stage-6 snake-stage-7
|
||||
```
|
||||
|
||||
- **`snake-stage-5`** — анимация поедания: в момент укуса на месте еды
|
||||
расходится затухающее кольцо. Смотри `Animatable` (`popProgress` 0→1),
|
||||
запуск анимации в `step()` и отрисовку кольца в `drawBoard()`.
|
||||
|
||||
- **`snake-stage-6`** — пауза: тап по полю во время игры ставит/снимает
|
||||
паузу (свайпы по-прежнему рулят направлением). Смотри флаг `paused`,
|
||||
условие в игровом цикле и оверлей «ПАУЗА».
|
||||
|
||||
- **`snake-stage-7`** — бонус-еда с таймером: иногда (1 из 4 при поедании
|
||||
обычной еды) появляется золотая еда на ~36 шагов и даёт +3 очка; вокруг
|
||||
неё рисуется дуга-таймер, по истечении она исчезает. Время считается в
|
||||
шагах игры, поэтому пауза учитывается автоматически. Смотри `bonusFood` /
|
||||
`bonusStepsLeft`, логику в `step()` и отрисовку (`drawArc`) в `drawBoard()`.
|
||||
|
||||
Идеи для следующих этапов: препятствия и уровни, режим «сквозь стены», два игрока.
|
||||
Идеи для следующих этапов: два игрока на одном экране, выбор размера поля, экран настроек.
|
||||
|
||||
@ -32,6 +32,7 @@ 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
|
||||
@ -58,6 +59,10 @@ 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 обычные еды
|
||||
@ -74,6 +79,7 @@ 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() {
|
||||
@ -91,6 +97,8 @@ fun SnakeGame() {
|
||||
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
|
||||
@ -134,16 +142,48 @@ fun SnakeGame() {
|
||||
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
|
||||
}
|
||||
@ -165,10 +205,10 @@ fun SnakeGame() {
|
||||
}
|
||||
if (ateFood) {
|
||||
score++
|
||||
food = randomFreeCell(snake + listOfNotNull(bonusFood))
|
||||
food = randomFreeCell(snake + obstacles + listOfNotNull(bonusFood))
|
||||
// иногда выкатываем бонус (если его сейчас нет на поле)
|
||||
if (bonusFood == null && Random.nextInt(BONUS_CHANCE) == 0) {
|
||||
bonusFood = randomFreeCell(snake + listOf(food))
|
||||
bonusFood = randomFreeCell(snake + obstacles + listOf(food))
|
||||
bonusStepsLeft = BONUS_LIFETIME_STEPS
|
||||
}
|
||||
}
|
||||
@ -183,6 +223,13 @@ fun SnakeGame() {
|
||||
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--
|
||||
@ -217,50 +264,47 @@ fun SnakeGame() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Свайпы: смена направления (и старт первым свайпом)
|
||||
// Джойстик: ведёшь пальцем — змейка непрерывно поворачивает в сторону
|
||||
// смещения пальца от точки касания (доминирующая ось). Мёртвая зона
|
||||
// у центра гасит дрожание.
|
||||
.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, bonusFood, bonusFrac, popCell, popProgress.value)
|
||||
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) {
|
||||
@ -287,6 +331,7 @@ private fun DrawScope.drawBoard(
|
||||
food: Cell,
|
||||
bonusFood: Cell?,
|
||||
bonusFrac: Float,
|
||||
obstacles: Set<Cell>,
|
||||
popCell: Cell,
|
||||
popProgress: Float
|
||||
) {
|
||||
@ -307,6 +352,9 @@ 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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user