diff --git a/README.md b/README.md index f27dd9f..0f6f439 100644 --- a/README.md +++ b/README.md @@ -92,4 +92,10 @@ app/src/main/ паузу (свайпы по-прежнему рулят направлением). Смотри флаг `paused`, условие в игровом цикле и оверлей «ПАУЗА». -Идеи для следующих этапов: выбор размера поля, экран настроек, светлая тема. +- **`snake-stage-7`** — бонус-еда с таймером: иногда (1 из 4 при поедании + обычной еды) появляется золотая еда на ~36 шагов и даёт +3 очка; вокруг + неё рисуется дуга-таймер, по истечении она исчезает. Время считается в + шагах игры, поэтому пауза учитывается автоматически. Смотри `bonusFood` / + `bonusStepsLeft`, логику в `step()` и отрисовку (`drawArc`) в `drawBoard()`. + +Идеи для следующих этапов: препятствия и уровни, режим «сквозь стены», два игрока. diff --git a/app/src/main/java/com/example/snake/SnakeGame.kt b/app/src/main/java/com/example/snake/SnakeGame.kt index 5e2cc36..23e6485 100644 --- a/app/src/main/java/com/example/snake/SnakeGame.kt +++ b/app/src/main/java/com/example/snake/SnakeGame.kt @@ -58,6 +58,11 @@ private fun stepDelay(score: Int): Long = private fun speedLevel(score: Int): Int = 1 + minOf(score, ((START_STEP_MS - MIN_STEP_MS) / SPEEDUP_MS).toInt()) +// --- Бонус-еда (этап 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) @@ -68,6 +73,7 @@ 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) // золотая бонус-еда @Composable fun SnakeGame() { @@ -83,6 +89,8 @@ fun SnakeGame() { 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) } // сколько шагов ей осталось жить // Этап 3: контекст и scope для работы с DataStore (рекорд хранится на диске) val context = LocalContext.current @@ -124,6 +132,8 @@ fun SnakeGame() { score = 0 gameOver = false paused = false + bonusFood = null + bonusStepsLeft = 0 } // Один шаг змейки @@ -138,25 +148,45 @@ fun SnakeGame() { return } - val grew = nh == food - // голова добавляется в начало; хвост убираем, если не съели еду + 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) { - score++ - // звук поедания (left=1f, right=1f, prio=1, без зацикливания, обычная скорость) - soundPool.play(eatSoundId, 1f, 1f, 1, 0, 1f) - // запускаем анимацию вспышки на месте съеденной еды + // звук (для бонуса — выше тоном) и анимация вспышки на месте укуса + 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 (score > best) { - best = score - // новый рекорд — сохраняем на диск (асинхронно, UI не блокируется) - scope.launch { ScoreStore.saveBest(context, best) } + } + if (ateFood) { + score++ + food = randomFreeCell(snake + listOfNotNull(bonusFood)) + // иногда выкатываем бонус (если его сейчас нет на поле) + if (bonusFood == null && Random.nextInt(BONUS_CHANCE) == 0) { + bonusFood = randomFreeCell(snake + listOf(food)) + bonusStepsLeft = BONUS_LIFETIME_STEPS } - food = randomFreeCell(snake) + } + if (ateBonus) { + score += BONUS_POINTS + bonusFood = null + bonusStepsLeft = 0 + } + if (grew && score > best) { + best = score + // новый рекорд — сохраняем на диск (асинхронно, UI не блокируется) + scope.launch { ScoreStore.saveBest(context, best) } + } + + // таймер бонуса: считаем в шагах, поэтому пауза учитывается автоматически + if (bonusFood != null) { + bonusStepsLeft-- + if (bonusStepsLeft <= 0) bonusFood = null } } @@ -213,8 +243,9 @@ fun SnakeGame() { } ) { // Игровое поле + val bonusFrac = if (bonusStepsLeft > 0) bonusStepsLeft.toFloat() / BONUS_LIFETIME_STEPS else 0f Canvas(modifier = Modifier.fillMaxSize()) { - drawBoard(snake, food, popCell, popProgress.value) + drawBoard(snake, food, bonusFood, bonusFrac, popCell, popProgress.value) } // Счёт сверху (+ индикатор скорости — видно ускорение) @@ -251,7 +282,14 @@ private fun Overlay(title: String, subtitle: String, modifier: Modifier = Modifi } /** Вся отрисовка поля внутри DrawScope. */ -private fun DrawScope.drawBoard(snake: List, food: Cell, popCell: Cell, popProgress: Float) { +private fun DrawScope.drawBoard( + snake: List, + food: Cell, + bonusFood: Cell?, + bonusFrac: Float, + 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 @@ -272,6 +310,21 @@ private fun DrawScope.drawBoard(snake: List, food: Cell, popCell: Cell, po // еда 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) @@ -300,10 +353,10 @@ private fun DrawScope.drawCell(c: Cell, color: Color, cell: Float, offX: Float, ) } -/** Случайная свободная клетка (не занятая змейкой) — для появления еды. */ -private fun randomFreeCell(snake: List): Cell { +/** Случайная свободная клетка (не занятая переданными клетками) — для появления еды/бонуса. */ +private fun randomFreeCell(occupied: List): Cell { while (true) { val p = Cell(Random.nextInt(COLS), Random.nextInt(ROWS)) - if (!snake.contains(p)) return p + if (!occupied.contains(p)) return p } }