Compare commits
2 Commits
c3529f08a0
...
03ed41118a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03ed41118a | ||
|
|
50e6677989 |
14
README.md
14
README.md
@ -92,4 +92,16 @@ app/src/main/
|
|||||||
паузу (свайпы по-прежнему рулят направлением). Смотри флаг `paused`,
|
паузу (свайпы по-прежнему рулят направлением). Смотри флаг `paused`,
|
||||||
условие в игровом цикле и оверлей «ПАУЗА».
|
условие в игровом цикле и оверлей «ПАУЗА».
|
||||||
|
|
||||||
Идеи для следующих этапов: выбор размера поля, экран настроек, светлая тема.
|
- **`snake-stage-7`** — бонус-еда с таймером: иногда (1 из 4 при поедании
|
||||||
|
обычной еды) появляется золотая еда на ~36 шагов и даёт +3 очка; вокруг
|
||||||
|
неё рисуется дуга-таймер, по истечении она исчезает. Время считается в
|
||||||
|
шагах игры, поэтому пауза учитывается автоматически. Смотри `bonusFood` /
|
||||||
|
`bonusStepsLeft`, логику в `step()` и отрисовку (`drawArc`) в `drawBoard()`.
|
||||||
|
|
||||||
|
- **`snake-stage-8`** — препятствия и уровни: каждые 5 очков — новый уровень,
|
||||||
|
на поле добавляется порция серых стен; врезался в стену — Game Over. Еда и
|
||||||
|
бонус не спавнятся в препятствиях, новые стены не ставятся вплотную к голове.
|
||||||
|
В шапке — «Уровень». Смотри `obstacles`/`level`, `addObstacles()`, проверку
|
||||||
|
коллизий и повышение уровня в `step()`.
|
||||||
|
|
||||||
|
Идеи для следующих этапов: режим «сквозь стены», два игрока, выбор размера поля.
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
|
|||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
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
|
||||||
@ -58,6 +59,15 @@ private fun stepDelay(score: Int): Long =
|
|||||||
private fun speedLevel(score: Int): Int =
|
private fun speedLevel(score: Int): Int =
|
||||||
1 + minOf(score, ((START_STEP_MS - MIN_STEP_MS) / SPEEDUP_MS).toInt())
|
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)
|
||||||
|
|
||||||
@ -68,6 +78,8 @@ 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() {
|
||||||
@ -83,6 +95,10 @@ fun SnakeGame() {
|
|||||||
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 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 (рекорд хранится на диске)
|
// Этап 3: контекст и scope для работы с DataStore (рекорд хранится на диске)
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@ -124,6 +140,26 @@ fun SnakeGame() {
|
|||||||
score = 0
|
score = 0
|
||||||
gameOver = false
|
gameOver = false
|
||||||
paused = 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Один шаг змейки
|
// Один шаг змейки
|
||||||
@ -133,30 +169,57 @@ fun SnakeGame() {
|
|||||||
val nh = Cell(head.x + dir.x, head.y + dir.y)
|
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
|
val hitWall = nh.x < 0 || nh.y < 0 || nh.x >= COLS || nh.y >= ROWS
|
||||||
if (hitWall || snake.contains(nh)) {
|
if (hitWall || snake.contains(nh) || nh in obstacles) {
|
||||||
gameOver = true
|
gameOver = true
|
||||||
return
|
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)
|
snake = if (grew) listOf(nh) + snake else listOf(nh) + snake.dropLast(1)
|
||||||
|
|
||||||
if (grew) {
|
if (grew) {
|
||||||
score++
|
// звук (для бонуса — выше тоном) и анимация вспышки на месте укуса
|
||||||
// звук поедания (left=1f, right=1f, prio=1, без зацикливания, обычная скорость)
|
soundPool.play(eatSoundId, 1f, 1f, 1, 0, if (ateBonus) 1.5f else 1f)
|
||||||
soundPool.play(eatSoundId, 1f, 1f, 1, 0, 1f)
|
|
||||||
// запускаем анимацию вспышки на месте съеденной еды
|
|
||||||
popCell = nh
|
popCell = nh
|
||||||
scope.launch {
|
scope.launch {
|
||||||
popProgress.snapTo(0f)
|
popProgress.snapTo(0f)
|
||||||
popProgress.animateTo(1f, animationSpec = tween(durationMillis = 260))
|
popProgress.animateTo(1f, animationSpec = tween(durationMillis = 260))
|
||||||
}
|
}
|
||||||
if (score > best) {
|
}
|
||||||
best = score
|
if (ateFood) {
|
||||||
// новый рекорд — сохраняем на диск (асинхронно, UI не блокируется)
|
score++
|
||||||
scope.launch { ScoreStore.saveBest(context, best) }
|
food = randomFreeCell(snake + obstacles + listOfNotNull(bonusFood))
|
||||||
|
// иногда выкатываем бонус (если его сейчас нет на поле)
|
||||||
|
if (bonusFood == null && Random.nextInt(BONUS_CHANCE) == 0) {
|
||||||
|
bonusFood = randomFreeCell(snake + obstacles + 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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// повышение уровня: каждые 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,18 +276,20 @@ fun SnakeGame() {
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// Игровое поле
|
// Игровое поле
|
||||||
|
val bonusFrac = if (bonusStepsLeft > 0) bonusStepsLeft.toFloat() / BONUS_LIFETIME_STEPS else 0f
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
drawBoard(snake, food, popCell, popProgress.value)
|
drawBoard(snake, food, bonusFood, bonusFrac, obstacles, popCell, popProgress.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Счёт сверху (+ индикатор скорости — видно ускорение)
|
// Счёт сверху: две строки (счёт/рекорд и уровень/скорость)
|
||||||
Text(
|
Text(
|
||||||
text = "Счёт: $score Рекорд: $best Скорость: ${speedLevel(score)}",
|
text = "Счёт: $score Рекорд: $best\nУровень: $level Скорость: ${speedLevel(score)}",
|
||||||
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 = 48.dp)
|
.padding(top = 40.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Оверлеи: старт, пауза и проигрыш
|
// Оверлеи: старт, пауза и проигрыш
|
||||||
@ -251,7 +316,15 @@ private fun Overlay(title: String, subtitle: String, modifier: Modifier = Modifi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Вся отрисовка поля внутри DrawScope. */
|
/** Вся отрисовка поля внутри DrawScope. */
|
||||||
private fun DrawScope.drawBoard(snake: List<Cell>, food: Cell, popCell: Cell, popProgress: Float) {
|
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 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
|
||||||
@ -269,9 +342,27 @@ private fun DrawScope.drawBoard(snake: List<Cell>, food: Cell, popCell: Cell, po
|
|||||||
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)
|
||||||
@ -300,10 +391,10 @@ private fun DrawScope.drawCell(c: Cell, color: Color, cell: Float, offX: Float,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Случайная свободная клетка (не занятая змейкой) — для появления еды. */
|
/** Случайная свободная клетка (не занятая переданными клетками) — для появления еды/бонуса. */
|
||||||
private fun randomFreeCell(snake: List<Cell>): Cell {
|
private fun randomFreeCell(occupied: 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 (!snake.contains(p)) return p
|
if (!occupied.contains(p)) return p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user