Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Last active September 12, 2025 02:12
Show Gist options
  • Save Kyriakos-Georgiopoulos/d54aad7a3d68626803752fe695e4e137 to your computer and use it in GitHub Desktop.
Save Kyriakos-Georgiopoulos/d54aad7a3d68626803752fe695e4e137 to your computer and use it in GitHub Desktop.
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