1
0
george 05d65eab14 Этап 4: звук при поедании еды
Короткий звуковой эффект при съедании еды через SoundPool.

- res/raw/eat.wav — короткий синтезированный «блип» (PCM 16-bit моно)
- SoundPool с AudioAttributes USAGE_GAME, загрузка сэмпла один раз
- воспроизведение в step() при поедании еды
- освобождение SoundPool в DisposableEffect при уходе с экрана

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

278 lines
12 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.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.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
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())
/** Клетка поля / сегмент змейки. 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)
@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) }
// Этап 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() }
}
fun reset() {
snake = startSnake
dir = Cell(0, -1)
nextDir = Cell(0, -1)
food = randomFreeCell(startSnake)
score = 0
gameOver = false
}
// Один шаг змейки
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)) {
gameOver = true
return
}
val grew = nh == food
// голова добавляется в начало; хвост убираем, если не съели еду
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)
if (score > best) {
best = score
// новый рекорд — сохраняем на диск (асинхронно, UI не блокируется)
scope.launch { ScoreStore.saveBest(context, best) }
}
food = randomFreeCell(snake)
}
}
// Игровой цикл на корутине — пока компонент в композиции.
// Задержка пересчитывается каждый шаг из текущего счёта, поэтому
// змейка плавно ускоряется по мере поедания еды.
LaunchedEffect(Unit) {
while (true) {
delay(stepDelay(score))
if (started && !gameOver) step()
}
}
androidx.compose.foundation.layout.Box(
modifier = Modifier
.fillMaxSize()
.background(BG)
// Тапы: старт игры и рестарт после проигрыша
.pointerInput(Unit) {
detectTapGestures {
if (gameOver) {
reset()
started = true
} else if (!started) {
started = true
}
}
}
// Свайпы: смена направления (и старт первым свайпом)
.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()
}
}
) {
// Игровое поле
Canvas(modifier = Modifier.fillMaxSize()) {
drawBoard(snake, food)
}
// Счёт сверху (+ индикатор скорости — видно ускорение)
Text(
text = "Счёт: $score Рекорд: $best Скорость: ${speedLevel(score)}",
color = Color.White,
fontSize = 20.sp,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 48.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))
}
}
}
@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) {
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)
}
// еда
drawCell(food, FOOD, cell, offX, offY)
// змейка (голова — другим цветом)
snake.forEachIndexed { idx, c ->
drawCell(c, if (idx == 0) HEAD else BODY, cell, offX, offY)
}
}
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(snake: List<Cell>): Cell {
while (true) {
val p = Cell(Random.nextInt(COLS), Random.nextInt(ROWS))
if (!snake.contains(p)) return p
}
}