From 38a5f90080613e04e5b8f86c8bc03d686d68a991 Mon Sep 17 00:00:00 2001 From: Daniel Esteban Date: Tue, 21 Apr 2026 21:51:05 +0200 Subject: [PATCH] changes --- badge.go | 3 +- defs_gopherbadge.go | 2 + defs_nicebadge.go | 2 + defs_pybadge.go | 2 + main.go | 7 + menu.go | 4 + pacman.go | 720 ++++++++++++++++++++++++++++++++++++++++++++ reflex.go | 320 ++++++++++++++++++++ 8 files changed, 1058 insertions(+), 2 deletions(-) create mode 100644 pacman.go create mode 100644 reflex.go diff --git a/badge.go b/badge.go index e827fa5..8b2ad82 100644 --- a/badge.go +++ b/badge.go @@ -42,8 +42,7 @@ func Badge() { selectedScreen = 0 fallthrough case 0: - //showLogoBin() - selectedScreen++ + showLogoBin() if quit { break } diff --git a/defs_gopherbadge.go b/defs_gopherbadge.go index 4a398bf..4b14ead 100644 --- a/defs_gopherbadge.go +++ b/defs_gopherbadge.go @@ -56,6 +56,8 @@ var menuOptions = []int8{ modeGameSnake, modeGameLife, modeGameColors, + modeGameReflex, + modeGamePacman, modeInfo, } diff --git a/defs_nicebadge.go b/defs_nicebadge.go index ae8e7ff..93a091c 100644 --- a/defs_nicebadge.go +++ b/defs_nicebadge.go @@ -55,6 +55,8 @@ var menuOptions = []int8{ modeGameSnake, modeGameLife, modeGameColors, + modeGameReflex, + modeGamePacman, modeInfo, } diff --git a/defs_pybadge.go b/defs_pybadge.go index 8e1f3c1..d968eac 100644 --- a/defs_pybadge.go +++ b/defs_pybadge.go @@ -56,6 +56,8 @@ var menuOptions = []int8{ modeGameSnake, modeGameLife, modeGameColors, + modeGameReflex, + modeGamePacman, modeInfo, } diff --git a/main.go b/main.go index f06cc7e..9d529e1 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ var ( colorBlackLED = color.RGBA{0, 0, 0, 0} colorText = color.RGBA{160, 160, 160, 255} colorOrange = color.RGBA{255, 153, 51, 255} + colorYellow = color.RGBA{255, 255, 0, 255} ) const ( @@ -50,6 +51,8 @@ const ( modeGameSnake modeGameLife modeGameColors + modeGameReflex + modeGamePacman ) var buttonsState []bool @@ -65,6 +68,8 @@ var options = []string{ "Snake", "Game of Life", "Color Game", + "Reflex", + "Pac-Man", } var leds ws2812.Device @@ -74,6 +79,8 @@ var accel lis3dh.Device var defaultFont tinyfont.Fonter var snakeGame = NewSnakeGame() +var reflexGame = NewReflexGame() +var pacmanGame = NewPacmanGame() func main() { diff --git a/menu.go b/menu.go index 87e8796..5e56b24 100644 --- a/menu.go +++ b/menu.go @@ -35,6 +35,10 @@ func runMenuOption(selected int16) { GameOfLife() case modeGameColors: ColorGame() + case modeGameReflex: + reflexGame.Loop() + case modeGamePacman: + pacmanGame.Loop() case modeInfo: Info() } diff --git a/pacman.go b/pacman.go new file mode 100644 index 0000000..6090211 --- /dev/null +++ b/pacman.go @@ -0,0 +1,720 @@ +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 +} diff --git a/reflex.go b/reflex.go new file mode 100644 index 0000000..9f9796c --- /dev/null +++ b/reflex.go @@ -0,0 +1,320 @@ +package main + +import ( + "image/color" + "math/rand" + "strconv" + "time" + + "tinygo.org/x/tinydraw" + "tinygo.org/x/tinyfont" + "tinygo.org/x/tinyfont/freesans" +) + +const ( + reflexStateWait = iota + reflexStateReady + reflexStateGo + reflexStateResult +) + +type ReflexGame struct { + state int + direction int // 0=Up 1=Down 2=Left 3=Right + score int + round int + startTime time.Time + reactionMs int64 + bestTime int64 + splashShown bool +} + +func NewReflexGame() *ReflexGame { + return &ReflexGame{ + state: reflexStateWait, + bestTime: 999999, + } +} + +func (r *ReflexGame) Loop() { + r.state = reflexStateWait + r.score = 0 + r.round = 0 + r.bestTime = 999999 + r.splashShown = false + + for { + getInput() + switch r.state { + case reflexStateWait: + r.showInstructions() + if !buttonsOldState[buttonA] && buttonsState[buttonA] { + r.state = reflexStateReady + time.Sleep(300 * time.Millisecond) + } + if goBack() { + return + } + + case reflexStateReady: + r.showGetReady() + delay := time.Duration(1000+rand.Int31n(2000)) * time.Millisecond + deadline := time.Now().Add(delay) + for time.Now().Before(deadline) { + getInput() + if goBack() { + return + } + time.Sleep(50 * time.Millisecond) + } + r.state = reflexStateGo + + case reflexStateGo: + r.round++ + r.direction = int(rand.Int31n(4)) + r.startTime = time.Now() + r.showTarget() + pressed := false + for !pressed { + getInput() + if goBack() { + return + } + switch r.direction { + case 0: + pressed = buttonsState[buttonUp] + case 1: + pressed = buttonsState[buttonDown] + case 2: + pressed = buttonsState[buttonLeft] + case 3: + pressed = buttonsState[buttonRight] + } + time.Sleep(10 * time.Millisecond) + } + r.reactionMs = time.Since(r.startTime).Milliseconds() + if r.reactionMs < r.bestTime { + r.bestTime = r.reactionMs + } + if points := int(1000 - r.reactionMs); points > 0 { + r.score += points + } + r.state = reflexStateResult + + case reflexStateResult: + r.showResult() + r.feedbackLEDs() + for i := 0; i < 40; i++ { + getInput() + if goBack() { + r.clearLEDs() + return + } + time.Sleep(50 * time.Millisecond) + } + r.clearLEDs() + if r.round >= 5 { + r.showFinalScore() + for { + getInput() + if goBack() { + return + } + if !buttonsOldState[buttonA] && buttonsState[buttonA] { + break + } + time.Sleep(50 * time.Millisecond) + } + r.state = reflexStateWait + r.score = 0 + r.round = 0 + r.bestTime = 999999 + r.splashShown = false + } else { + r.state = reflexStateReady + } + } + time.Sleep(50 * time.Millisecond) + } +} + +func (r *ReflexGame) showInstructions() { + if r.splashShown { + return + } + display.FillScreen(colorBlack) + if displayHeight >= 160 { + tinyfont.WriteLine(&display, &freesans.Bold18pt7b, 40, 50, "REFLEX", colorYellow) + tinyfont.WriteLine(&display, &freesans.Bold18pt7b, 40, 80, "TESTER", colorYellow) + tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 20, 130, "Press matching", colorWhite) + tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 20, 155, "direction button", colorWhite) + tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 20, 180, "when arrow appears!", colorWhite) + tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 60, 215, "[A] START", colorMellonGreen) + tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 60, 235, "[B] BACK", colorRed) + } else { + tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 10, 25, "REFLEX TESTER", colorYellow) + tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 10, 55, "Press matching", colorWhite) + tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 10, 70, "direction button", colorWhite) + tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 10, 85, "when arrow appears!", colorWhite) + tinyfont.WriteLine(&display, &freesans.Bold9pt7b, 10, 110, "[A] START", colorMellonGreen) + tinyfont.WriteLine(&display, &freesans.Bold9pt7b, 10, 125, "[B] BACK", colorRed) + } + r.splashShown = true +} + +func (r *ReflexGame) showGetReady() { + display.FillScreen(colorBlack) + if displayHeight >= 160 { + tinyfont.WriteLine(&display, &freesans.Bold24pt7b, 20, displayHeight/2+15, "GET READY!", colorOrange) + } else { + tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 20, displayHeight/2+8, "GET READY!", colorOrange) + } +} + +func (r *ReflexGame) showTarget() { + display.FillScreen(colorBlack) + roundText := "Round " + strconv.Itoa(r.round) + "/5" + if displayHeight >= 160 { + tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 10, 20, roundText, colorText) + } else { + tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 5, 14, roundText, colorText) + } + cx := int16(displayWidth / 2) + cy := int16(displayHeight / 2) + switch r.direction { + case 0: + r.drawArrow(cx, cy, colorPurple, 0) + case 1: + r.drawArrow(cx, cy, colorMellonGreen, 1) + case 2: + r.drawArrow(cx, cy, colorOrange, 2) + case 3: + r.drawArrow(cx, cy, colorRed, 3) + } +} + +func (r *ReflexGame) showResult() { + display.FillScreen(colorBlack) + + timeText := strconv.FormatInt(r.reactionMs, 10) + " ms" + var feedbackText string + var feedbackColor color.RGBA + switch { + case r.reactionMs < 250: + feedbackText = "AMAZING!" + feedbackColor = colorMellonGreen + case r.reactionMs < 400: + feedbackText = "GREAT!" + feedbackColor = colorMellonGreen + case r.reactionMs < 550: + feedbackText = "GOOD" + feedbackColor = colorOrange + case r.reactionMs < 750: + feedbackText = "OK" + feedbackColor = colorOrange + default: + feedbackText = "SLOW" + feedbackColor = colorRed + } + scoreText := "Score: " + strconv.Itoa(r.score) + + if displayHeight >= 160 { + w, _ := tinyfont.LineWidth(&freesans.Bold24pt7b, timeText) + tinyfont.WriteLine(&display, &freesans.Bold24pt7b, (displayWidth-int16(w))/2, 80, timeText, colorYellow) + w, _ = tinyfont.LineWidth(&freesans.Bold18pt7b, feedbackText) + tinyfont.WriteLine(&display, &freesans.Bold18pt7b, (displayWidth-int16(w))/2, 125, feedbackText, feedbackColor) + tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 80, 165, scoreText, colorWhite) + } else { + w, _ := tinyfont.LineWidth(&freesans.Bold12pt7b, timeText) + tinyfont.WriteLine(&display, &freesans.Bold12pt7b, (displayWidth-int16(w))/2, 45, timeText, colorYellow) + w, _ = tinyfont.LineWidth(&freesans.Bold9pt7b, feedbackText) + tinyfont.WriteLine(&display, &freesans.Bold9pt7b, (displayWidth-int16(w))/2, 70, feedbackText, feedbackColor) + tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 10, 95, scoreText, colorWhite) + } +} + +func (r *ReflexGame) showFinalScore() { + display.FillScreen(colorBlack) + + scoreText := "Final: " + strconv.Itoa(r.score) + bestText := "Best: " + strconv.FormatInt(r.bestTime, 10) + " ms" + + avgTime := int64(5000-r.score) / 5 + var rating string + switch { + case avgTime < 300: + rating = "LIGHTNING FAST!" + case avgTime < 450: + rating = "SUPER QUICK!" + case avgTime < 600: + rating = "PRETTY GOOD!" + default: + rating = "KEEP PRACTICING!" + } + + if displayHeight >= 160 { + tinyfont.WriteLine(&display, &freesans.Bold18pt7b, 40, 50, "GAME OVER!", colorYellow) + tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 40, 100, scoreText, colorWhite) + tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 40, 130, bestText, colorMellonGreen) + tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 20, 180, rating, colorOrange) + tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 40, displayHeight-15, "Press A to retry", colorText) + } else { + tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 10, 25, "GAME OVER!", colorYellow) + tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 10, 55, scoreText, colorWhite) + tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 10, 70, bestText, colorMellonGreen) + tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 10, 90, rating, colorOrange) + tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 10, displayHeight-15, "Press A to retry", colorText) + } +} + +// drawArrow draws a direction arrow centered at (cx, cy). +// dir: 0=Up, 1=Down, 2=Left, 3=Right +func (r *ReflexGame) drawArrow(cx, cy int16, c color.RGBA, dir int) { + half := int16(displayHeight) / 4 // distance from center to tip/tail + headW := int16(displayHeight) / 8 // half-width of arrowhead base + shaftW := int16(displayHeight) / 20 // half-width of shaft + + switch dir { + case 0: // Up: tip at top, shaft extends down + tinydraw.FilledTriangle(&display, cx, cy-half, cx-headW, cy, cx+headW, cy, c) + display.FillRectangle(cx-shaftW, cy, 2*shaftW, half, c) + case 1: // Down: tip at bottom, shaft extends up + display.FillRectangle(cx-shaftW, cy-half, 2*shaftW, half, c) + tinydraw.FilledTriangle(&display, cx, cy+half, cx-headW, cy, cx+headW, cy, c) + case 2: // Left: tip at left, shaft extends right + tinydraw.FilledTriangle(&display, cx-half, cy, cx, cy-headW, cx, cy+headW, c) + display.FillRectangle(cx, cy-shaftW, half, 2*shaftW, c) + case 3: // Right: tip at right, shaft extends left + display.FillRectangle(cx-half, cy-shaftW, half, 2*shaftW, c) + tinydraw.FilledTriangle(&display, cx+half, cy, cx, cy-headW, cx, cy+headW, c) + } +} + +func (r *ReflexGame) feedbackLEDs() { + ledColors := make([]color.RGBA, numLEDs) + var c color.RGBA + switch { + case r.reactionMs < 250: + c = color.RGBA{0, 255, 0, 255} + case r.reactionMs < 400: + c = color.RGBA{0, 200, 0, 255} + case r.reactionMs < 550: + c = color.RGBA{150, 200, 0, 255} + case r.reactionMs < 750: + c = colorOrange + default: + c = colorRed + } + for i := range ledColors { + ledColors[i] = c + } + leds.WriteColors(ledColors) +} + +func (r *ReflexGame) clearLEDs() { + ledColors := make([]color.RGBA, numLEDs) + for i := range ledColors { + ledColors[i] = colorBlackLED + } + leds.WriteColors(ledColors) +}