Прогрессия сложности: каждые 5 очков — новый уровень с новыми стенами. - obstacles (Set<Cell>) и level; addObstacles() добавляет порцию стен, избегая змейки/еды/бонуса/других стен и клеток вплотную к голове - столкновение со стеной-препятствием = Game Over - еда и бонус не спавнятся в препятствиях - двустрочная шапка со счётом/рекордом и уровнем/скоростью - reset() очищает препятствия и уровень Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
401 lines
18 KiB
Kotlin
401 lines
18 KiB
Kotlin
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
|
||
import androidx.compose.foundation.gestures.detectTapGestures
|
||
import androidx.compose.foundation.layout.Arrangement
|
||
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
|
||
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
|
||
|
||
/** Клетка поля / сегмент змейки. Direction тоже храним как Cell (dx, dy). */
|
||
private data class Cell(val x: Int, val y: Int)
|
||
|
||
// --- Цвета ---
|
||
private val BG = Color(0xFF10131A)
|
||
private val FIELD = Color(0xFF10131A)
|
||
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() {
|
||
// Начальная змейка из 3 клеток, смотрит вверх
|
||
val startSnake = listOf(Cell(COLS / 2, ROWS / 2), Cell(COLS / 2, ROWS / 2 + 1), Cell(COLS / 2, ROWS / 2 + 2))
|
||
|
||
var snake by remember { mutableStateOf(startSnake) }
|
||
var dir by remember { mutableStateOf(Cell(0, -1)) } // текущее направление
|
||
var nextDir by remember { mutableStateOf(Cell(0, -1)) } // заданное игроком на след. шаг
|
||
var food by remember { mutableStateOf(randomFreeCell(startSnake)) }
|
||
var score by remember { mutableIntStateOf(0) }
|
||
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
|
||
dir = Cell(0, -1)
|
||
nextDir = Cell(0, -1)
|
||
food = randomFreeCell(startSnake)
|
||
score = 0
|
||
gameOver = false
|
||
paused = false
|
||
bonusFood = null
|
||
bonusStepsLeft = 0
|
||
level = 1
|
||
obstacles = emptySet()
|
||
}
|
||
|
||
// Добавить порцию препятствий в свободные клетки (не у головы змейки).
|
||
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)
|
||
|
||
val hitWall = nh.x < 0 || nh.y < 0 || nh.x >= COLS || nh.y >= ROWS
|
||
if (hitWall || snake.contains(nh) || nh in obstacles) {
|
||
gameOver = true
|
||
return
|
||
}
|
||
|
||
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) {
|
||
// звук (для бонуса — выше тоном) и анимация вспышки на месте укуса
|
||
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
|
||
}
|
||
}
|
||
|
||
// Игровой цикл на корутине — пока компонент в композиции.
|
||
// Задержка пересчитывается каждый шаг из текущего счёта, поэтому
|
||
// змейка плавно ускоряется по мере поедания еды.
|
||
LaunchedEffect(Unit) {
|
||
while (true) {
|
||
delay(stepDelay(score))
|
||
if (started && !gameOver && !paused) step()
|
||
}
|
||
}
|
||
|
||
androidx.compose.foundation.layout.Box(
|
||
modifier = Modifier
|
||
.fillMaxSize()
|
||
.background(BG)
|
||
// Тапы: старт игры и рестарт после проигрыша
|
||
.pointerInput(Unit) {
|
||
detectTapGestures {
|
||
if (gameOver) {
|
||
reset()
|
||
started = true
|
||
} else if (!started) {
|
||
started = true
|
||
} else {
|
||
paused = !paused // во время игры тап ставит/снимает паузу
|
||
}
|
||
}
|
||
}
|
||
// Свайпы: смена направления (и старт первым свайпом)
|
||
.pointerInput(Unit) {
|
||
var total = Offset.Zero
|
||
detectDragGestures(
|
||
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, drag ->
|
||
total += drag
|
||
change.consume()
|
||
}
|
||
}
|
||
) {
|
||
// Игровое поле
|
||
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)
|
||
}
|
||
|
||
// Счёт сверху: две строки (счёт/рекорд и уровень/скорость)
|
||
Text(
|
||
text = "Счёт: $score Рекорд: $best\nУровень: $level Скорость: ${speedLevel(score)}",
|
||
color = Color.White,
|
||
fontSize = 20.sp,
|
||
textAlign = TextAlign.Center,
|
||
modifier = Modifier
|
||
.align(Alignment.TopCenter)
|
||
.padding(top = 40.dp)
|
||
)
|
||
|
||
// Оверлеи: старт, пауза и проигрыш
|
||
if (!started) {
|
||
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))
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun Overlay(title: String, subtitle: String, modifier: Modifier = Modifier) {
|
||
Column(
|
||
modifier = modifier,
|
||
horizontalAlignment = Alignment.CenterHorizontally,
|
||
verticalArrangement = Arrangement.Center
|
||
) {
|
||
Text(text = title, color = Color.White, fontSize = 44.sp, fontWeight = FontWeight.Bold)
|
||
Text(text = subtitle, color = Color(0xFFB0B6C0), fontSize = 16.sp, modifier = Modifier.padding(top = 12.dp))
|
||
}
|
||
}
|
||
|
||
/** Вся отрисовка поля внутри DrawScope. */
|
||
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
|
||
|
||
// фон поля
|
||
drawRect(color = FIELD, topLeft = Offset(offX, offY), size = Size(cell * COLS, cell * ROWS))
|
||
|
||
// сетка
|
||
for (i in 0..COLS) {
|
||
val x = offX + i * cell
|
||
drawLine(GRID, Offset(x, offY), Offset(x, offY + cell * ROWS), strokeWidth = 1f)
|
||
}
|
||
for (j in 0..ROWS) {
|
||
val y = offY + j * 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) {
|
||
val pad = cell * 0.08f
|
||
drawRoundRect(
|
||
color = color,
|
||
topLeft = Offset(offX + c.x * cell + pad, offY + c.y * cell + pad),
|
||
size = Size(cell - 2 * pad, cell - 2 * pad),
|
||
cornerRadius = CornerRadius(cell * 0.25f, cell * 0.25f)
|
||
)
|
||
}
|
||
|
||
/** Случайная свободная клетка (не занятая переданными клетками) — для появления еды/бонуса. */
|
||
private fun randomFreeCell(occupied: List<Cell>): Cell {
|
||
while (true) {
|
||
val p = Cell(Random.nextInt(COLS), Random.nextInt(ROWS))
|
||
if (!occupied.contains(p)) return p
|
||
}
|
||
}
|