george 03ed41118a Этап 8: препятствия и уровни
Прогрессия сложности: каждые 5 очков — новый уровень с новыми стенами.

- obstacles (Set<Cell>) и level; addObstacles() добавляет порцию стен,
  избегая змейки/еды/бонуса/других стен и клеток вплотную к голове
- столкновение со стеной-препятствием = Game Over
- еда и бонус не спавнятся в препятствиях
- двустрочная шапка со счётом/рекордом и уровнем/скоростью
- reset() очищает препятствия и уровень

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:29:44 +03:00

401 lines
18 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}