badges/pacman.go
2026-04-21 21:51:05 +02:00

720 lines
16 KiB
Go

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
}