Last active
September 12, 2025 02:12
-
-
Save Kyriakos-Georgiopoulos/d54aad7a3d68626803752fe695e4e137 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import android.annotation.SuppressLint | |
import androidx.compose.animation.core.LinearEasing | |
import androidx.compose.animation.core.RepeatMode | |
import androidx.compose.animation.core.animateFloat | |
import androidx.compose.animation.core.infiniteRepeatable | |
import androidx.compose.animation.core.rememberInfiniteTransition | |
import androidx.compose.animation.core.tween | |
import androidx.compose.foundation.Canvas | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.layout.Arrangement | |
import androidx.compose.foundation.layout.BoxWithConstraints | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.runtime.Composable | |
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.setValue | |
import androidx.compose.runtime.toMutableStateList | |
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.Rect | |
import androidx.compose.ui.geometry.Size | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.Path | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.unit.Dp | |
import kotlinx.coroutines.delay | |
val ClassicMazeLayout = listOf( | |
"############################", | |
"#..P.........##............#", | |
"#.####.#####.##.#####.####.#", | |
"#o####.#####.##.#####.####.#", | |
"#.####.#####.##.#####.####.#", | |
"#..........................#", | |
"#.####.##.########.##.####.#", | |
"#.####.##.########.##.####.#", | |
"#......##....##....##......#", | |
"######.#####.##.#####.######", | |
"######.#####.##.#####.######", | |
"######.##..........##.######", | |
"######.##.########.##.######", | |
"######.##.########.##.######", | |
"#............##............#", | |
"######.##.########.##.######", | |
"######.##.########.##.######", | |
"######.##....O.....##.######", | |
"######.##.########.##.######", | |
"######.##.########.##.######", | |
"#............##............#", | |
"#.####.#####.##.#####.####.#", | |
"#o####.#####.##.#####.####.#", | |
"#...##................##...#", | |
"###.##.##.########.##.##.###", | |
"#......##....##....##......#", | |
"#.##########.##.##########.#", | |
"#............A.....P....G..#", | |
"############################" | |
) | |
@SuppressLint("UnusedBoxWithConstraintsScope") | |
@Composable | |
fun PacmanMaze(modifier: Modifier = Modifier) { | |
val mazeState = remember { | |
ClassicMazeLayout.map { it.toMutableList().toMutableStateList() }.toMutableStateList() | |
} | |
val pacmanPosition = remember { mutableStateOf(Pair(23, 13)) } | |
val pacmanDirection = remember { mutableStateOf(Pair(0, -1)) } | |
val lastMoveDirection = remember { mutableStateOf(Pair(0, -1)) } | |
val ghostInitialPosition = remember { | |
ClassicMazeLayout.withIndex() | |
.flatMap { (rowIndex, row) -> | |
row.mapIndexedNotNull { colIndex, char -> | |
if (char == 'G') Pair(rowIndex, colIndex) else null | |
} | |
} | |
.firstOrNull() ?: Pair(1, 1) | |
} | |
val monsterInitialPosition = remember { | |
ClassicMazeLayout.withIndex() | |
.flatMap { (rowIndex, row) -> | |
row.mapIndexedNotNull { colIndex, char -> | |
if (char == 'M') Pair(rowIndex, colIndex) else null | |
} | |
} | |
.firstOrNull() ?: Pair(1, 1) | |
} | |
val ghostPosition = remember { mutableStateOf(ghostInitialPosition) } | |
var ghostDirection by remember { mutableIntStateOf(1) } | |
mazeState[ghostInitialPosition.first][ghostInitialPosition.second] = ' ' | |
val mouthOffset by rememberInfiniteTransition(label = "mouth").animateFloat( | |
initialValue = 0f, | |
targetValue = 25f, | |
animationSpec = infiniteRepeatable( | |
animation = tween(durationMillis = 200, easing = LinearEasing), | |
repeatMode = RepeatMode.Reverse | |
), | |
label = "mouthOffset" | |
) | |
LaunchedEffect(Unit) { | |
while (true) { | |
delay(150L) | |
val (row, col) = pacmanPosition.value | |
findNextDirectionToDot(mazeState.map { it.toList() }, pacmanPosition.value)?.let { | |
pacmanDirection.value = it | |
} | |
val (dy, dx) = pacmanDirection.value | |
val nextTile = mazeState.getOrNull(row + dy)?.getOrNull(col + dx) | |
if (nextTile != null && nextTile != '#') { | |
pacmanPosition.value = row + dy to col + dx | |
lastMoveDirection.value = pacmanDirection.value | |
if (nextTile in listOf('.', 'O', 'A', 'P')) { | |
mazeState[row + dy][col + dx] = ' ' | |
} | |
} | |
} | |
} | |
LaunchedEffect(Unit) { | |
while (true) { | |
delay(400L) | |
val (r, c) = ghostPosition.value | |
val nextCol = c + ghostDirection | |
if (mazeState.getOrNull(r)?.getOrNull(nextCol) == '#') { | |
ghostDirection *= -1 | |
} else { | |
ghostPosition.value = r to nextCol | |
} | |
} | |
} | |
BoxWithConstraints( | |
modifier = modifier | |
.fillMaxSize() | |
.background(Color.Black) | |
) { | |
val cols = mazeState.maxOf { it.size } | |
val rows = mazeState.size | |
val tileSizePx = minOf(maxWidth.toPx() / cols, maxHeight.toPx() / rows) | |
val offsetX = (maxWidth.toPx() - cols * tileSizePx) / 2f | |
val offsetY = (maxHeight.toPx() - rows * tileSizePx) / 2f | |
Canvas(modifier = Modifier.fillMaxSize()) { | |
mazeState.forEachIndexed { row, tiles -> | |
tiles.forEachIndexed { col, tile -> | |
val x = col * tileSizePx + offsetX | |
val y = row * tileSizePx + offsetY | |
when (tile) { | |
'.' -> drawCircle( | |
Color(0xFFFFE0B2), | |
tileSizePx / 10, | |
Offset(x + tileSizePx / 2, y + tileSizePx / 2) | |
) | |
'#' -> { | |
val stroke = tileSizePx / 5 | |
if (mazeState.getOrNull(row - 1) | |
?.getOrNull(col) != '#' | |
) drawLine(Color.Blue, Offset(x, y), Offset(x + tileSizePx, y), stroke) | |
if (mazeState.getOrNull(row + 1)?.getOrNull(col) != '#') drawLine( | |
Color.Blue, | |
Offset(x, y + tileSizePx), | |
Offset(x + tileSizePx, y + tileSizePx), | |
stroke | |
) | |
if (mazeState.getOrNull(row) | |
?.getOrNull(col - 1) != '#' | |
) drawLine(Color.Blue, Offset(x, y), Offset(x, y + tileSizePx), stroke) | |
if (mazeState.getOrNull(row)?.getOrNull(col + 1) != '#') drawLine( | |
Color.Blue, | |
Offset(x + tileSizePx, y), | |
Offset(x + tileSizePx, y + tileSizePx), | |
stroke | |
) | |
} | |
'O' -> drawCircle( | |
Color(0xFFFFA500), | |
tileSizePx / 3, | |
Offset(x + tileSizePx / 2, y + tileSizePx / 2) | |
) | |
'A' -> { | |
drawCircle( | |
Color.Red, | |
tileSizePx / 3, | |
Offset(x + tileSizePx / 2, y + tileSizePx / 2) | |
) | |
drawLine( | |
Color.Green, | |
Offset(x + tileSizePx / 2, y + tileSizePx / 4), | |
Offset(x + tileSizePx / 2, y + tileSizePx / 8), | |
tileSizePx / 12 | |
) | |
} | |
'P' -> { | |
drawRoundRect( | |
color = Color(0xFFFFC107), | |
topLeft = Offset(x + tileSizePx * 0.25f, y + tileSizePx * 0.2f), | |
size = Size(tileSizePx * 0.5f, tileSizePx * 0.6f), | |
cornerRadius = CornerRadius(tileSizePx * 0.15f) | |
) | |
val leafWidth = tileSizePx * 0.1f | |
for (i in -1..1) { | |
drawLine( | |
color = Color(0xFF2E7D32), | |
start = Offset(x + tileSizePx / 2, y + tileSizePx * 0.2f), | |
end = Offset(x + tileSizePx / 2 + i * tileSizePx * 0.15f, y), | |
strokeWidth = leafWidth | |
) | |
} | |
} | |
} | |
} | |
} | |
val (prow, pcol) = pacmanPosition.value | |
val (pdy, pdx) = lastMoveDirection.value | |
val pcenterX = pcol * tileSizePx + offsetX + tileSizePx / 2 | |
val pcenterY = prow * tileSizePx + offsetY + tileSizePx / 2 | |
val angleOffset = when (pdx to pdy) { | |
1 to 0 -> 0f | |
0 to -1 -> 270f | |
-1 to 0 -> 180f | |
0 to 1 -> 90f | |
else -> 0f | |
} | |
drawArc( | |
color = Color.Yellow, | |
startAngle = angleOffset + mouthOffset, | |
sweepAngle = 360f - 2 * mouthOffset, | |
useCenter = true, | |
topLeft = Offset(pcenterX - tileSizePx / 2, pcenterY - tileSizePx / 2), | |
size = Size(tileSizePx, tileSizePx) | |
) | |
val (gRow, gCol) = ghostPosition.value | |
val gx = gCol * tileSizePx + offsetX | |
val gy = gRow * tileSizePx + offsetY | |
val ghostPath = Path().apply { | |
moveTo(x = gx, y = gy + tileSizePx / 2) | |
arcTo( | |
rect = Rect(Offset(gx, gy), Size(tileSizePx, tileSizePx)), | |
startAngleDegrees = 180f, | |
sweepAngleDegrees = 180f, | |
forceMoveTo = false | |
) | |
lineTo(gx + tileSizePx, gy + tileSizePx) | |
val waveCount = 4 | |
val waveWidth = tileSizePx / waveCount | |
for (i in waveCount - 1 downTo 0) { | |
val cx = gx + i * waveWidth + waveWidth / 2 | |
val cy = gy + tileSizePx | |
quadraticBezierTo( | |
gx + i * waveWidth + waveWidth / 4, | |
cy - tileSizePx / 6, | |
cx, | |
cy | |
) | |
} | |
lineTo(gx, gy + tileSizePx) | |
close() | |
} | |
drawPath(ghostPath, Color.Cyan) | |
listOf(-1, 1).forEach { dir -> | |
drawCircle( | |
Color.White, | |
tileSizePx / 6, | |
Offset(gx + tileSizePx / 2 + dir * tileSizePx / 4, gy + tileSizePx / 3) | |
) | |
drawCircle( | |
Color.Blue, | |
tileSizePx / 10, | |
Offset(gx + tileSizePx / 2 + dir * tileSizePx / 4, gy + tileSizePx / 3) | |
) | |
} | |
} | |
} | |
} | |
fun findNextDirectionToDot( | |
maze: List<List<Char>>, | |
start: Pair<Int, Int> | |
): Pair<Int, Int>? { | |
val directions = listOf(0 to -1, -1 to 0, 1 to 0, 0 to 1) | |
val visited = mutableSetOf<Pair<Int, Int>>() | |
val queue = ArrayDeque<List<Pair<Int, Int>>>() | |
queue.add(listOf(start)) | |
while (queue.isNotEmpty()) { | |
val path = queue.removeFirst() | |
val current = path.last() | |
if (current != start && | |
(maze.getOrNull(current.first)?.getOrNull(current.second) == '.' || | |
maze.getOrNull(current.first)?.getOrNull(current.second) == 'F' || | |
maze.getOrNull(current.first)?.getOrNull(current.second) == 'A' || | |
maze.getOrNull(current.first)?.getOrNull(current.second) == 'G' || | |
maze.getOrNull(current.first)?.getOrNull(current.second) == 'B') | |
) { | |
val nextStep = path[1] | |
return nextStep.first - start.first to nextStep.second - start.second | |
} | |
for ((dy, dx) in directions) { | |
val next = current.first + dy to current.second + dx | |
if ( | |
next !in visited && | |
maze.getOrNull(next.first)?.getOrNull(next.second) != null && | |
maze[next.first][next.second] != '#' | |
) { | |
visited.add(next) | |
queue.add(path + next) | |
} | |
} | |
} | |
return null | |
} | |
@Composable | |
fun Dp.toPx(): Float { | |
return with(LocalDensity.current) { [email protected]() } | |
} | |
@SuppressLint("UnusedBoxWithConstraintsScope") | |
@Composable | |
fun PacmanMazeDemo() { | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.Black), | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.Center | |
) { | |
PacmanMaze() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment