1
0

Compare commits

...

3 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
2 changed files with 65 additions and 63 deletions

View File

@ -5,14 +5,18 @@
Android-приложение на современном стеке (Kotlin + Jetpack Compose) без лишней
сложности.
## Что внутри
## Что внутри (итоговая версия на `main`)
- Игровое поле-сетка 20×30
- Управление **свайпами** (вверх/вниз/влево/вправо)
- Змейка растёт, когда съедает еду; ведётся счёт и рекорд за сессию
- Столкновение со стеной или с собой = Game Over, **тап** — рестарт
- Игровое поле-сетка 20×30, управление пальцем как **джойстиком** (ведёшь — змейка поворачивает)
- Змейка растёт и **ускоряется** с ростом счёта; ведётся счёт, **уровень** и **рекорд** (сохраняется между запусками)
- **Бонус-еда** с таймером даёт больше очков; **препятствия** добавляются с каждым уровнем
- Границы поля **телепортируют** на противоположную сторону (тор); смерть — от самой себя и от препятствий
- **Звук** и **анимация-вспышка** при поедании, **пауза** по тапу
- Вся отрисовка — через Compose `Canvas`, игровой цикл — на корутине
> Каждая фича добавлялась отдельным этапом-веткой — см. раздел [Этапы](#этапы-ветки)
> ниже: там видно, что именно меняется на каждом шаге.
## Стек
| Что | Версия |
@ -61,47 +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()`.
- **`snake-stage-8`** — препятствия и уровни: каждые 5 очков — новый уровень,
на поле добавляется порция серых стен; врезался в стену — Game Over. Еда и
бонус не спавнятся в препятствиях, новые стены не ставятся вплотную к голове.
В шапке — «Уровень». Смотри `obstacles`/`level`, `addObstacles()`, проверку
коллизий и повышение уровня в `step()`.
Идеи для следующих этапов: режим «сквозь стены», два игрока, выбор размера поля.
Идеи для следующих этапов: два игрока на одном экране, выбор размера поля, экран настроек.

View File

@ -146,6 +146,15 @@ fun SnakeGame() {
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()
@ -166,10 +175,15 @@ fun SnakeGame() {
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) || nh in obstacles) {
// смерть теперь только от самой себя или от препятствия (не от края)
if (snake.contains(nh) || nh in obstacles) {
gameOver = true
return
}
@ -250,28 +264,24 @@ 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)
}
}
}
) {
@ -294,7 +304,7 @@ fun SnakeGame() {
// Оверлеи: старт, пауза и проигрыш
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) {