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(null) } // этап 7: бонус-еда (null = нет) var bonusStepsLeft by remember { mutableIntStateOf(0) } // сколько шагов ей осталось жить var level by remember { mutableIntStateOf(1) } // этап 8: уровень var obstacles by remember { mutableStateOf>(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() 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, food: Cell, bonusFood: Cell?, bonusFrac: Float, obstacles: Set, 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 { while (true) { val p = Cell(Random.nextInt(COLS), Random.nextInt(ROWS)) if (!occupied.contains(p)) return p } }