Compare commits
1 Commits
main
...
my-onboard
| Author | SHA1 | Date | |
|---|---|---|---|
| bb23490e7c |
47
README.md
47
README.md
@ -5,18 +5,14 @@
|
|||||||
Android-приложение на современном стеке (Kotlin + Jetpack Compose) без лишней
|
Android-приложение на современном стеке (Kotlin + Jetpack Compose) без лишней
|
||||||
сложности.
|
сложности.
|
||||||
|
|
||||||
## Что внутри (итоговая версия на `main`)
|
## Что внутри
|
||||||
|
|
||||||
- Игровое поле-сетка 20×30, управление пальцем как **джойстиком** (ведёшь — змейка поворачивает)
|
- Игровое поле-сетка 20×30
|
||||||
- Змейка растёт и **ускоряется** с ростом счёта; ведётся счёт, **уровень** и **рекорд** (сохраняется между запусками)
|
- Управление **свайпами** (вверх/вниз/влево/вправо)
|
||||||
- **Бонус-еда** с таймером даёт больше очков; **препятствия** добавляются с каждым уровнем
|
- Змейка растёт, когда съедает еду; ведётся счёт и рекорд за сессию
|
||||||
- Границы поля **телепортируют** на противоположную сторону (тор); смерть — от самой себя и от препятствий
|
- Столкновение со стеной или с собой = Game Over, **тап** — рестарт
|
||||||
- **Звук** и **анимация-вспышка** при поедании, **пауза** по тапу
|
|
||||||
- Вся отрисовка — через Compose `Canvas`, игровой цикл — на корутине
|
- Вся отрисовка — через Compose `Canvas`, игровой цикл — на корутине
|
||||||
|
|
||||||
> Каждая фича добавлялась отдельным этапом-веткой — см. раздел [Этапы](#этапы-ветки)
|
|
||||||
> ниже: там видно, что именно меняется на каждом шаге.
|
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
| Что | Версия |
|
| Что | Версия |
|
||||||
@ -65,35 +61,18 @@ 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` |
|
|
||||||
|
|
||||||
Посмотреть, что именно поменялось на этапе N, удобно diff'ом между ветками,
|
Идеи для следующих этапов: ускорение с ростом счёта, сохранение рекорда через
|
||||||
например для этапа 7:
|
`DataStore`, экранные кнопки-стрелки, анимации.
|
||||||
```bash
|
|
||||||
git diff snake-stage-6 snake-stage-7
|
|
||||||
```
|
|
||||||
|
|
||||||
Идеи для следующих этапов: два игрока на одном экране, выбор размера поля, экран настроек.
|
Zdes' byl Irakli :)
|
||||||
|
|||||||
@ -42,6 +42,4 @@ dependencies {
|
|||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.compose.foundation:foundation")
|
implementation("androidx.compose.foundation:foundation")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
// Этап 3: сохранение рекорда между запусками
|
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +1,5 @@
|
|||||||
package com.example.snake
|
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.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
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.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
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.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
// --- Параметры игры ---
|
// --- Параметры игры ---
|
||||||
private const val COLS = 20
|
private const val COLS = 20
|
||||||
private const val ROWS = 30
|
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). */
|
/** Клетка поля / сегмент змейки. Direction тоже храним как Cell (dx, dy). */
|
||||||
private data class Cell(val x: Int, val y: Int)
|
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 BODY = Color(0xFF4CD964)
|
||||||
private val HEAD = Color(0xFF2ECC71)
|
private val HEAD = Color(0xFF2ECC71)
|
||||||
private val FOOD = Color(0xFFFF453A)
|
private val FOOD = Color(0xFFFF453A)
|
||||||
private val BONUS = Color(0xFFFFD60A) // золотая бонус-еда
|
|
||||||
private val OBSTACLE = Color(0xFF5A6276) // серые препятствия-стены
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SnakeGame() {
|
fun SnakeGame() {
|
||||||
@ -94,43 +60,6 @@ fun SnakeGame() {
|
|||||||
var best by remember { mutableIntStateOf(0) }
|
var best by remember { mutableIntStateOf(0) }
|
||||||
var started by remember { mutableStateOf(false) }
|
var started by remember { mutableStateOf(false) }
|
||||||
var gameOver 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() {
|
fun reset() {
|
||||||
snake = startSnake
|
snake = startSnake
|
||||||
@ -139,111 +68,35 @@ fun SnakeGame() {
|
|||||||
food = randomFreeCell(startSnake)
|
food = randomFreeCell(startSnake)
|
||||||
score = 0
|
score = 0
|
||||||
gameOver = false
|
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() {
|
fun step() {
|
||||||
dir = nextDir
|
dir = nextDir
|
||||||
val head = snake.first()
|
val head = snake.first()
|
||||||
// этап 9: граница экрана «телепортирует» на противоположную сторону (тор).
|
val nh = Cell(head.x + dir.x, head.y + dir.y)
|
||||||
// %-арифметика с + 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 (snake.contains(nh) || nh in obstacles) {
|
if (hitWall || snake.contains(nh)) {
|
||||||
gameOver = true
|
gameOver = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val ateFood = nh == food
|
val grew = nh == food
|
||||||
val ateBonus = bonusFood == nh
|
// голова добавляется в начало; хвост убираем, если не съели еду
|
||||||
val grew = ateFood || ateBonus
|
|
||||||
// голова добавляется в начало; хвост убираем, если ничего не съели
|
|
||||||
snake = if (grew) listOf(nh) + snake else listOf(nh) + snake.dropLast(1)
|
snake = if (grew) listOf(nh) + snake else listOf(nh) + snake.dropLast(1)
|
||||||
|
|
||||||
if (grew) {
|
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++
|
score++
|
||||||
food = randomFreeCell(snake + obstacles + listOfNotNull(bonusFood))
|
if (score > best) best = score
|
||||||
// иногда выкатываем бонус (если его сейчас нет на поле)
|
food = randomFreeCell(snake)
|
||||||
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) {
|
LaunchedEffect(Unit) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(stepDelay(score))
|
delay(STEP_MS)
|
||||||
if (started && !gameOver && !paused) step()
|
if (started && !gameOver) step()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,56 +112,54 @@ fun SnakeGame() {
|
|||||||
started = true
|
started = true
|
||||||
} else if (!started) {
|
} else if (!started) {
|
||||||
started = true
|
started = true
|
||||||
} else {
|
|
||||||
paused = !paused // во время игры тап ставит/снимает паузу
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Джойстик: ведёшь пальцем — змейка непрерывно поворачивает в сторону
|
// Свайпы: смена направления (и старт первым свайпом)
|
||||||
// смещения пальца от точки касания (доминирующая ось). Мёртвая зона
|
|
||||||
// у центра гасит дрожание.
|
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
val deadZone = 28.dp.toPx()
|
var total = Offset.Zero
|
||||||
var origin = Offset.Zero
|
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
onDragStart = { pos ->
|
onDragStart = {
|
||||||
origin = pos
|
total = Offset.Zero
|
||||||
if (!started && !gameOver) started = true
|
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()
|
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()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
drawBoard(snake, food, bonusFood, bonusFrac, obstacles, popCell, popProgress.value)
|
drawBoard(snake, food)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Счёт сверху: две строки (счёт/рекорд и уровень/скорость)
|
// Счёт сверху
|
||||||
Text(
|
Text(
|
||||||
text = "Счёт: $score Рекорд: $best\nУровень: $level Скорость: ${speedLevel(score)}",
|
text = "Счёт: $score Рекорд: $best",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.padding(top = 40.dp)
|
.padding(top = 48.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Оверлеи: старт, пауза и проигрыш
|
// Оверлеи: старт и проигрыш
|
||||||
if (!started) {
|
if (!started) {
|
||||||
Overlay(title = "ЗМЕЙКА", subtitle = "Веди пальцем — поворот · тап — пауза", modifier = Modifier.align(Alignment.Center))
|
Overlay(title = "ЗМЕЙКА", subtitle = "Свайпни, чтобы начать", modifier = Modifier.align(Alignment.Center))
|
||||||
} else if (gameOver) {
|
} else if (gameOver) {
|
||||||
Overlay(title = "GAME OVER", subtitle = "Счёт: $score · тапни для рестарта", modifier = Modifier.align(Alignment.Center))
|
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. */
|
/** Вся отрисовка поля внутри DrawScope. */
|
||||||
private fun DrawScope.drawBoard(
|
private fun DrawScope.drawBoard(snake: List<Cell>, food: Cell) {
|
||||||
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 cell = minOf(size.width / COLS, size.height / ROWS)
|
||||||
val offX = (size.width - cell * COLS) / 2f
|
val offX = (size.width - cell * COLS) / 2f
|
||||||
val offY = (size.height - cell * ROWS) / 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)
|
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)
|
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 ->
|
snake.forEachIndexed { idx, c ->
|
||||||
drawCell(c, if (idx == 0) HEAD else BODY, cell, offX, offY)
|
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) {
|
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) {
|
while (true) {
|
||||||
val p = Cell(Random.nextInt(COLS), Random.nextInt(ROWS))
|
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.
Loading…
x
Reference in New Issue
Block a user