Last active
October 28, 2025 11:09
-
-
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
| /* | |
| * Copyright 2025 Kyriakos Georgiopoulos | |
| * | |
| * Licensed under the Apache License, Version 2.0 (the "License"); | |
| * you may not use this file except in compliance with the License. | |
| * You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| 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