package main import ( "image/color" "math/rand" "strconv" "time" "tinygo.org/x/tinyfont" "tinygo.org/x/tinyfont/freesans" ) // Maze cell types const ( pmEmpty = 0 pmWall = 1 pmDot = 2 pmPower = 3 ) // Directions const ( pmUp = int16(0) pmDown = int16(1) pmLeft = int16(2) pmRight = int16(3) ) // Maze dimensions and cell size. // pmCell scales with displayHeight so the maze always fits the screen: // gopherbadge 320x240 → pmCell=10, maze fills 320x240 (no offset) // nicebadge 240x135 → pmCell=5, maze fills 160x120, offset (40,7) // gobadge 160x128 → pmCell=5, maze fills 160x120, offset (0,4) const ( pmW = 32 pmH = 24 pmCell = displayHeight / pmH pmGhostCount = 4 pmFrightLen = 40 // frames of fright mode // Pixel offset to center the maze on the display pmOffsetX = (displayWidth - pmW*pmCell) / 2 pmOffsetY = (displayHeight - pmH*pmCell) / 2 // Scoring pmPointsDot = 10 pmPointsPower = 50 pmPointsGhost = 200 // Difficulty pmDiffEasy = 0 pmDiffNormal = 1 pmDiffHard = 2 ) // Drawing dimensions — all derived from pmCell so they scale automatically. // Reference values shown for pmCell=10 / pmCell=5. const ( pmBodyOff = 1 // border inset for pac/ghosts pmBodySz = pmCell - 2*pmBodyOff // body size: 8 / 3 pmDotOff = pmCell/2 - 1 // dot center offset: 4 / 1 pmDotSz = 1 + pmCell/10 // dot size: 2 / 1 pmPowOff = pmCell / 5 // power pellet inset: 2 / 1 pmPowSz = pmCell - 2*(pmCell/5) // power pellet size: 6 / 3 // Pacman mouth cutout (open/close animation) pmMouthOffX = pmCell * 7 / 10 // far-side mouth start: 7 / 3 pmMouthOffY = pmCell * 2 / 5 // mouth vertical offset: 4 / 2 pmMouthW = pmCell * 3 / 10 // mouth width: 3 / 1 pmMouthH = pmCell / 5 // mouth height: 2 / 1 // Ghost eyes pmEye1X = pmCell / 5 // left eye x: 2 / 1 pmEye2X = pmCell * 6 / 10 // right eye x: 6 / 3 pmEyeY = pmCell * 3 / 10 // eye y: 3 / 1 pmEyeSz = pmCell / 5 // eye size: 2 / 1 ) // Package-level state persists across games within a power cycle. var pmHighScore int var pmDifficulty int var pmPowerPositions = [4][2]int16{ {1, 3}, {30, 3}, {1, 20}, {30, 20}, } var ( pmColorWall = color.RGBA{33, 33, 222, 255} pmColorDot = color.RGBA{255, 255, 255, 255} pmColorPower = color.RGBA{255, 255, 200, 255} pmColorPac = color.RGBA{255, 255, 0, 255} pmColorFright = color.RGBA{33, 33, 200, 255} pmColorBg = color.RGBA{0, 0, 0, 255} pmGhostColors = [pmGhostCount]color.RGBA{ {255, 0, 0, 255}, // Blinky (red) {255, 184, 255, 255}, // Pinky {0, 255, 255, 255}, // Inky (cyan) {255, 184, 82, 255}, // Clyde (orange) } ) type PacGhost struct { x, y int16 dir int16 fright int startX, startY int16 } type PacmanGame struct { maze [pmH][pmW]uint8 pacX, pacY int16 dir, nextDir int16 ghosts [pmGhostCount]PacGhost score int dots int totalDots int powerRespawnCount int status uint8 frame int won bool } func NewPacmanGame() *PacmanGame { return &PacmanGame{status: GameSplash} } func (g *PacmanGame) Loop() { g.status = GameSplash g.showSplash() if g.status == GameQuit { return } g.startGame() drawn := false for { switch g.status { case GamePlay: g.update() case GameOver: if !drawn { g.showGameOver() drawn = true } getInput() if !buttonsOldState[buttonA] && buttonsState[buttonA] { g.status = GameSplash g.showSplash() if g.status == GameQuit { return } g.startGame() drawn = false } if goBack() { return } case GameQuit: return } time.Sleep(80 * time.Millisecond) } } func (g *PacmanGame) showSplash() { display.FillScreen(pmColorBg) if displayHeight >= 160 { tinyfont.WriteLine(&display, &freesans.Bold24pt7b, 55, 50, "PAC-MAN", pmColorPac) if pmHighScore > 0 { hs := "Best: " + strconv.Itoa(pmHighScore) w, _ := tinyfont.LineWidth(&freesans.Regular12pt7b, hs) tinyfont.WriteLine(&display, &freesans.Regular12pt7b, (displayWidth-int16(w))/2, 85, hs, colorYellow) } } else { tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 30, 25, "PAC-MAN", pmColorPac) if pmHighScore > 0 { hs := "Best: " + strconv.Itoa(pmHighScore) w, _ := tinyfont.LineWidth(&freesans.Regular9pt7b, hs) tinyfont.WriteLine(&display, &freesans.Regular9pt7b, (displayWidth-int16(w))/2, 42, hs, colorYellow) } } selected := pmDifficulty g.drawDifficultyMenu(selected) for { getInput() if !buttonsOldState[buttonUp] && buttonsState[buttonUp] { if selected > pmDiffEasy { selected-- g.drawDifficultyMenu(selected) } } if !buttonsOldState[buttonDown] && buttonsState[buttonDown] { if selected < pmDiffHard { selected++ g.drawDifficultyMenu(selected) } } if !buttonsOldState[buttonA] && buttonsState[buttonA] { pmDifficulty = selected return } if goBack() { g.status = GameQuit return } time.Sleep(50 * time.Millisecond) } } func (g *PacmanGame) drawDifficultyMenu(selected int) { labels := [3]string{"Easy", "Normal", "Hard"} if displayHeight >= 160 { for i := 0; i < 3; i++ { c := colorWhite if i == selected { c = colorYellow } tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 80, int16(125+i*28), labels[i], c) } tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 30, 215, "Press A to start", colorMellonGreen) tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 30, 238, "[B] EXIT", colorRed) } else { for i := 0; i < 3; i++ { c := colorWhite if i == selected { c = colorYellow } tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 60, int16(60+i*18), labels[i], c) } tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 10, 110, "Press A to start", colorMellonGreen) tinyfont.WriteLine(&display, &freesans.Bold9pt7b, 10, 126, "[B] EXIT", colorRed) } } func (g *PacmanGame) showGameOver() { if g.score > pmHighScore { pmHighScore = g.score } display.FillScreen(pmColorBg) if displayHeight >= 160 { if g.won { tinyfont.WriteLine(&display, &freesans.Bold24pt7b, 40, 50, "YOU WIN!", colorMellonGreen) } else { tinyfont.WriteLine(&display, &freesans.Bold24pt7b, 20, 50, "GAME OVER", colorRed) } scoreText := "Score: " + strconv.Itoa(g.score) w, _ := tinyfont.LineWidth(&freesans.Bold18pt7b, scoreText) tinyfont.WriteLine(&display, &freesans.Bold18pt7b, (displayWidth-int16(w))/2, 100, scoreText, colorYellow) hsText := "Best: " + strconv.Itoa(pmHighScore) w, _ = tinyfont.LineWidth(&freesans.Regular12pt7b, hsText) tinyfont.WriteLine(&display, &freesans.Regular12pt7b, (displayWidth-int16(w))/2, 135, hsText, colorWhite) tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 50, 190, "Press A to retry", colorWhite) tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 50, 220, "Press B to exit", colorText) } else { if g.won { tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 30, 28, "YOU WIN!", colorMellonGreen) } else { tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 10, 28, "GAME OVER", colorRed) } scoreText := "Score: " + strconv.Itoa(g.score) w, _ := tinyfont.LineWidth(&freesans.Bold12pt7b, scoreText) tinyfont.WriteLine(&display, &freesans.Bold12pt7b, (displayWidth-int16(w))/2, 55, scoreText, colorYellow) hsText := "Best: " + strconv.Itoa(pmHighScore) w, _ = tinyfont.LineWidth(&freesans.Regular9pt7b, hsText) tinyfont.WriteLine(&display, &freesans.Regular9pt7b, (displayWidth-int16(w))/2, 75, hsText, colorWhite) tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 20, 100, "Press A to retry", colorWhite) tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 20, 115, "Press B to exit", colorText) } } func (g *PacmanGame) startGame() { g.score = 0 g.dots = 0 g.won = false g.frame = 0 g.dir = pmLeft g.nextDir = pmLeft g.powerRespawnCount = 0 g.initMaze() g.totalDots = g.dots g.pacX = 15 g.pacY = 17 if g.maze[g.pacY][g.pacX] == pmDot { g.dots-- } g.maze[g.pacY][g.pacX] = pmEmpty gx := [4]int16{14, 17, 14, 17} gy := [4]int16{9, 9, 13, 13} gd := [4]int16{pmLeft, pmRight, pmLeft, pmRight} for i := 0; i < pmGhostCount; i++ { g.ghosts[i] = PacGhost{ x: gx[i], y: gy[i], dir: gd[i], startX: gx[i], startY: gy[i], } if g.maze[gy[i]][gx[i]] == pmDot { g.dots-- g.maze[gy[i]][gx[i]] = pmEmpty } } g.drawFullMaze() g.drawPacman() for i := 0; i < pmGhostCount; i++ { g.drawGhost(i) } g.status = GamePlay } func (g *PacmanGame) initMaze() { g.dots = 0 for y := 0; y < pmH; y++ { for x := 0; x < pmW; x++ { g.maze[y][x] = pmDot g.dots++ } } wall := func(x, y, w, h int) { for dy := 0; dy < h; dy++ { for dx := 0; dx < w; dx++ { if g.maze[y+dy][x+dx] == pmDot { g.dots-- } g.maze[y+dy][x+dx] = pmWall } } } empty := func(x, y, w, h int) { for dy := 0; dy < h; dy++ { for dx := 0; dx < w; dx++ { if g.maze[y+dy][x+dx] == pmDot { g.dots-- } g.maze[y+dy][x+dx] = pmEmpty } } } // Border wall(0, 0, pmW, 1) wall(0, pmH-1, pmW, 1) wall(0, 0, 1, pmH) wall(pmW-1, 0, 1, pmH) // Top section wall(2, 2, 4, 2) wall(7, 2, 6, 2) wall(14, 2, 4, 2) wall(19, 2, 6, 2) wall(26, 2, 4, 2) wall(2, 5, 4, 1) wall(7, 5, 2, 3) wall(10, 5, 5, 1) wall(17, 5, 5, 1) wall(23, 5, 2, 3) wall(26, 5, 4, 1) wall(2, 7, 4, 1) wall(10, 7, 5, 1) wall(17, 7, 5, 1) wall(26, 7, 4, 1) wall(2, 8, 4, 2) wall(7, 8, 2, 2) wall(23, 8, 2, 2) wall(26, 8, 4, 2) // Ghost house: rows 10-12, cols 12-19 wall(12, 10, 8, 1) wall(12, 12, 8, 1) wall(12, 10, 1, 3) wall(19, 10, 1, 3) empty(13, 11, 6, 1) empty(15, 10, 2, 1) // entrance // Bottom section (mirrors top) wall(2, 14, 4, 2) wall(7, 14, 2, 2) wall(23, 14, 2, 2) wall(26, 14, 4, 2) wall(2, 16, 4, 1) wall(10, 16, 5, 1) wall(17, 16, 5, 1) wall(26, 16, 4, 1) wall(7, 17, 2, 1) wall(23, 17, 2, 1) wall(2, 18, 4, 1) wall(7, 18, 2, 3) wall(10, 18, 5, 1) wall(17, 18, 5, 1) wall(23, 18, 2, 3) wall(26, 18, 4, 1) wall(2, 20, 4, 2) wall(7, 20, 6, 2) wall(14, 20, 4, 2) wall(19, 20, 6, 2) wall(26, 20, 4, 2) // Power pellets for _, p := range pmPowerPositions { g.maze[p[1]][p[0]] = pmPower } } func (g *PacmanGame) spawnPowerPellet() { var eaten [4]int n := 0 for i, p := range pmPowerPositions { if g.maze[p[1]][p[0]] == pmEmpty { eaten[n] = i n++ } } if n == 0 { return } idx := eaten[rand.Int31n(int32(n))] p := pmPowerPositions[idx] g.maze[p[1]][p[0]] = pmPower g.dots++ g.totalDots++ g.drawCell(p[0], p[1]) } func (g *PacmanGame) drawFullMaze() { display.FillScreen(pmColorBg) for y := int16(0); y < pmH; y++ { for x := int16(0); x < pmW; x++ { g.drawCell(x, y) } } } // drawCell redraws a single maze cell at grid coordinates (x, y). // Pixel position includes pmOffsetX/Y so the maze is centered on any display. func (g *PacmanGame) drawCell(x, y int16) { px := int16(pmOffsetX) + x*pmCell py := int16(pmOffsetY) + y*pmCell switch g.maze[y][x] { case pmWall: display.FillRectangle(px, py, pmCell, pmCell, pmColorWall) case pmDot: display.FillRectangle(px, py, pmCell, pmCell, pmColorBg) display.FillRectangle(px+pmDotOff, py+pmDotOff, pmDotSz, pmDotSz, pmColorDot) case pmPower: display.FillRectangle(px, py, pmCell, pmCell, pmColorBg) display.FillRectangle(px+pmPowOff, py+pmPowOff, pmPowSz, pmPowSz, pmColorPower) default: display.FillRectangle(px, py, pmCell, pmCell, pmColorBg) } } func (g *PacmanGame) drawPacman() { px := int16(pmOffsetX) + g.pacX*pmCell py := int16(pmOffsetY) + g.pacY*pmCell display.FillRectangle(px+pmBodyOff, py+pmBodyOff, pmBodySz, pmBodySz, pmColorPac) if g.frame%4 < 2 { switch g.dir { case pmRight: display.FillRectangle(px+pmMouthOffX, py+pmMouthOffY, pmMouthW, pmMouthH, pmColorBg) case pmLeft: display.FillRectangle(px, py+pmMouthOffY, pmMouthW, pmMouthH, pmColorBg) case pmUp: display.FillRectangle(px+pmMouthOffY, py, pmMouthH, pmMouthW, pmColorBg) case pmDown: display.FillRectangle(px+pmMouthOffY, py+pmMouthOffX, pmMouthH, pmMouthW, pmColorBg) } } } func (g *PacmanGame) drawGhost(idx int) { ghost := &g.ghosts[idx] px := int16(pmOffsetX) + ghost.x*pmCell py := int16(pmOffsetY) + ghost.y*pmCell c := pmGhostColors[idx] if ghost.fright > 0 { c = pmColorFright } display.FillRectangle(px+pmBodyOff, py+pmBodyOff, pmBodySz, pmBodySz, c) display.FillRectangle(px+pmEye1X, py+pmEyeY, pmEyeSz, pmEyeSz, pmColorDot) display.FillRectangle(px+pmEye2X, py+pmEyeY, pmEyeSz, pmEyeSz, pmColorDot) } func (g *PacmanGame) update() { getInput() if goBack() { g.status = GameQuit return } if buttonsState[buttonRight] { g.nextDir = pmRight } else if buttonsState[buttonLeft] { g.nextDir = pmLeft } else if buttonsState[buttonUp] { g.nextDir = pmUp } else if buttonsState[buttonDown] { g.nextDir = pmDown } g.frame++ if g.canMove(g.pacX, g.pacY, g.nextDir) { g.dir = g.nextDir } if g.canMove(g.pacX, g.pacY, g.dir) { oldX, oldY := g.pacX, g.pacY g.pacX += pmDX(g.dir) g.pacY += pmDY(g.dir) cell := g.maze[g.pacY][g.pacX] if cell == pmDot { g.maze[g.pacY][g.pacX] = pmEmpty points := pmPointsDot if pmDifficulty == pmDiffHard { points *= 2 } g.score += points g.dots-- } else if cell == pmPower { g.maze[g.pacY][g.pacX] = pmEmpty g.score += pmPointsPower g.dots-- for i := range g.ghosts { g.ghosts[i].fright = pmFrightLen } } if g.powerRespawnCount < 2 && pmDifficulty != pmDiffHard { eaten := g.totalDots - g.dots threshold := g.totalDots / 2 if g.powerRespawnCount == 1 { threshold = g.totalDots * 3 / 4 } if eaten >= threshold { g.spawnPowerPellet() g.powerRespawnCount++ } } g.drawCell(oldX, oldY) if g.dots <= 0 { g.won = true g.status = GameOver return } for i := 0; i < pmGhostCount; i++ { if g.ghosts[i].x == g.pacX && g.ghosts[i].y == g.pacY { if g.ghosts[i].fright > 0 { g.eatGhost(i) } else { g.status = GameOver return } } } } g.drawPacman() moveGhosts := false switch pmDifficulty { case pmDiffEasy: moveGhosts = g.frame%3 == 0 case pmDiffNormal: moveGhosts = g.frame%2 == 0 case pmDiffHard: moveGhosts = g.frame%4 != 0 } if moveGhosts { for i := 0; i < pmGhostCount; i++ { oldX, oldY := g.ghosts[i].x, g.ghosts[i].y g.moveGhost(i) g.drawCell(oldX, oldY) g.drawGhost(i) if g.ghosts[i].fright > 0 { g.ghosts[i].fright-- } if g.ghosts[i].x == g.pacX && g.ghosts[i].y == g.pacY { if g.ghosts[i].fright > 0 { g.eatGhost(i) } else { g.status = GameOver return } } } } } func (g *PacmanGame) eatGhost(idx int) { g.score += pmPointsGhost g.ghosts[idx].x = g.ghosts[idx].startX g.ghosts[idx].y = g.ghosts[idx].startY g.ghosts[idx].fright = 0 g.drawGhost(idx) } func (g *PacmanGame) moveGhost(idx int) { ghost := &g.ghosts[idx] reverse := pmReverse(ghost.dir) type opt struct { dir int16 x, y int16 dist int16 } var opts [4]opt n := 0 for d := int16(0); d < 4; d++ { if d == reverse { continue } nx := ghost.x + pmDX(d) ny := ghost.y + pmDY(d) if nx < 0 || nx >= pmW || ny < 0 || ny >= pmH { continue } if g.maze[ny][nx] == pmWall { continue } dist := pmAbs(nx-g.pacX) + pmAbs(ny-g.pacY) opts[n] = opt{d, nx, ny, dist} n++ } if n == 0 { d := reverse nx := ghost.x + pmDX(d) ny := ghost.y + pmDY(d) if nx >= 0 && nx < pmW && ny >= 0 && ny < pmH && g.maze[ny][nx] != pmWall { ghost.x = nx ghost.y = ny ghost.dir = d } return } if ghost.fright > 0 { c := rand.Int31n(int32(n)) ghost.x = opts[c].x ghost.y = opts[c].y ghost.dir = opts[c].dir } else { best := 0 for i := 1; i < n; i++ { if opts[i].dist < opts[best].dist { best = i } } if n > 1 && rand.Int31n(4) == 0 { best = int(rand.Int31n(int32(n))) } ghost.x = opts[best].x ghost.y = opts[best].y ghost.dir = opts[best].dir } } func (g *PacmanGame) canMove(x, y, dir int16) bool { nx := x + pmDX(dir) ny := y + pmDY(dir) if nx < 0 || nx >= pmW || ny < 0 || ny >= pmH { return false } return g.maze[ny][nx] != pmWall } func pmDX(dir int16) int16 { switch dir { case pmLeft: return -1 case pmRight: return 1 } return 0 } func pmDY(dir int16) int16 { switch dir { case pmUp: return -1 case pmDown: return 1 } return 0 } func pmReverse(dir int16) int16 { switch dir { case pmUp: return pmDown case pmDown: return pmUp case pmLeft: return pmRight case pmRight: return pmLeft } return -1 } func pmAbs(x int16) int16 { if x < 0 { return -x } return x }