diff --git a/gameoflife.go b/gameoflife.go index 1814e7e..7a7be6e 100644 --- a/gameoflife.go +++ b/gameoflife.go @@ -18,6 +18,7 @@ var ( cellSize int16 = 6 wh = colors[WHITE] + bk = colors[BLACK] cellBuf = []color.RGBA{ wh, wh, wh, wh, wh, wh, diff --git a/main.go b/main.go index 0433811..5e48307 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ const ( TEXT ORANGE PURPLE + YELLOW ) var colors = []color.RGBA{ @@ -35,6 +36,7 @@ var colors = []color.RGBA{ color.RGBA{160, 160, 160, 255}, color.RGBA{255, 153, 51, 255}, color.RGBA{153, 51, 255, 255}, + color.RGBA{220, 220, 0, 255}, } var snakeGame = NewSnakeGame() diff --git a/menu.go b/menu.go index e7da261..d37d967 100644 --- a/menu.go +++ b/menu.go @@ -45,27 +45,54 @@ func menu() int16 { tinydraw.FilledCircle(&display, 28, 35, 4, color.RGBA{200, 200, 0, 255}) - released := true + btnUpPressed := false + btnDownPressed := false + btnAPressed := false + for { - if released && !btnUp.Get() && selected > 0 { - selected-- - tinydraw.FilledCircle(&display, 28, 35+20*selected, 4, color.RGBA{200, 200, 0, 255}) - tinydraw.FilledCircle(&display, 28, 35+20*(selected+1), 4, bgColor) - } - if released && !btnDown.Get() && selected < (numOpts-1) { - selected++ - tinydraw.FilledCircle(&display, 28, 35+20*selected, 4, color.RGBA{200, 200, 0, 255}) - tinydraw.FilledCircle(&display, 28, 35+20*(selected-1), 4, bgColor) + // Detect button press on transition from released to pressed + if !btnUp.Get() { + if !btnUpPressed { + prevSelected := selected + if selected > 0 { + selected-- + } else { + selected = numOpts - 1 // Wrap to bottom + } + tinydraw.FilledCircle(&display, 28, 35+20*selected, 4, color.RGBA{200, 200, 0, 255}) + tinydraw.FilledCircle(&display, 28, 35+20*prevSelected, 4, bgColor) + } + btnUpPressed = true + } else { + btnUpPressed = false } - if released && !btnA.Get() { - break + + if !btnDown.Get() { + if !btnDownPressed { + prevSelected := selected + if selected < (numOpts - 1) { + selected++ + } else { + selected = 0 // Wrap to top + } + tinydraw.FilledCircle(&display, 28, 35+20*selected, 4, color.RGBA{200, 200, 0, 255}) + tinydraw.FilledCircle(&display, 28, 35+20*prevSelected, 4, bgColor) + } + btnDownPressed = true + } else { + btnDownPressed = false } - if btnA.Get() && btnUp.Get() && btnDown.Get() { - released = true + + if !btnA.Get() { + if !btnAPressed { + break + } + btnAPressed = true } else { - released = false + btnAPressed = false } - time.Sleep(200 * time.Millisecond) + + time.Sleep(100 * time.Millisecond) } return selected } diff --git a/snake.go b/snake.go index c44c039..a956b93 100644 --- a/snake.go +++ b/snake.go @@ -35,22 +35,37 @@ const ( var ( // Those variable are there for a more easy reading of the apple shape. - re = colors[RED] // red - bk = colors[BLACK] // background - gr = colors[SNAKE] // green + red = colors[RED] // red + blk = colors[BLACK] // background + grn = colors[SNAKE] // green + prl = colors[PURPLE] // purple // The array is split for a visual purpose too. appleBuf = []color.RGBA{ - bk, bk, bk, bk, bk, gr, gr, gr, bk, bk, - bk, bk, bk, bk, gr, gr, gr, bk, bk, bk, - bk, bk, bk, re, gr, gr, re, bk, bk, bk, - bk, bk, re, re, re, re, re, re, bk, bk, - bk, re, re, re, re, re, re, re, re, bk, - bk, re, re, re, re, re, re, re, re, bk, - bk, re, re, re, re, re, re, re, re, bk, - bk, bk, re, re, re, re, re, re, bk, bk, - bk, bk, bk, re, re, re, re, bk, bk, bk, - bk, bk, bk, bk, bk, bk, bk, bk, bk, bk, + blk, blk, blk, blk, blk, grn, grn, grn, blk, blk, + blk, blk, blk, blk, grn, grn, grn, blk, blk, blk, + blk, blk, blk, red, grn, grn, red, blk, blk, blk, + blk, blk, red, red, red, red, red, red, blk, blk, + blk, red, red, red, red, red, red, red, red, blk, + blk, red, red, red, red, red, red, red, red, blk, + blk, red, red, red, red, red, red, red, red, blk, + blk, blk, red, red, red, red, red, red, blk, blk, + blk, blk, blk, red, red, red, red, blk, blk, blk, + blk, blk, blk, blk, blk, blk, blk, blk, blk, blk, + } + + // The array is split for a visual purpose too. + poisonAppleBuf = []color.RGBA{ + blk, blk, blk, blk, blk, grn, grn, grn, blk, blk, + blk, blk, blk, blk, grn, grn, grn, blk, blk, blk, + blk, blk, blk, prl, grn, grn, prl, blk, blk, blk, + blk, blk, prl, prl, prl, prl, prl, prl, blk, blk, + blk, prl, prl, prl, prl, prl, prl, prl, prl, blk, + blk, prl, prl, prl, prl, prl, prl, prl, prl, blk, + blk, prl, prl, prl, prl, prl, prl, prl, prl, blk, + blk, blk, prl, prl, prl, prl, prl, prl, blk, blk, + blk, blk, blk, prl, prl, prl, prl, blk, blk, blk, + blk, blk, blk, blk, blk, blk, blk, blk, blk, blk, } ) @@ -61,15 +76,18 @@ type Snake struct { } type SnakeGame struct { - snake Snake - appleX, appleY int16 - status uint8 - score int - frame, delay int + snake Snake + appleX, appleY int16 + poisonX, poisonY int16 + status uint8 + frame, delay int + sparkleX, sparkleY int16 + eatCounter int } var splashed = false var scoreStr string +var snakeSoundEnabled = false // Sound mode selection (default OFF) func NewSnakeGame() *SnakeGame { return &SnakeGame{ @@ -82,10 +100,12 @@ func NewSnakeGame() *SnakeGame { length: 3, direction: SnakeLeft, }, - appleX: 5, - appleY: 5, - status: GameSplash, - delay: 120, + appleX: 5, + appleY: 5, + poisonX: 5, + poisonY: 5, + status: GameSplash, + delay: 120, } } @@ -97,7 +117,7 @@ func (g *SnakeGame) Splash() { } func (g *SnakeGame) Start() { - display.FillScreen(bk) + display.FillScreen(blk) g.initSnake() g.drawSnake() @@ -118,49 +138,136 @@ func (g *SnakeGame) Play(direction int) { } func (g *SnakeGame) Over() { - display.FillScreen(bk) splashed = false - g.status = GameOver } +func (g *SnakeGame) showGameOver() { + display.FillScreen(blk) + + // Display "GAME OVER" in large text + tinyfont.WriteLine(&display, &freesans.Bold24pt7b, 20, 80, "GAME OVER", colors[RED]) + + // Display the score (snake length is the score) + scoreText := "Score: " + strconv.Itoa(int(g.snake.length)) + w32, _ := tinyfont.LineWidth(&freesans.Bold18pt7b, scoreText) + tinyfont.WriteLine(&display, &freesans.Bold18pt7b, (WIDTH-int16(w32))/2, 130, scoreText, colors[YELLOW]) + + // Display instructions + tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 50, 180, "Press A to retry", colors[WHITE]) + tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 50, 210, "Press B to exit", colors[TEXT]) +} + func (g *SnakeGame) splash() { - display.FillScreen(bk) + display.FillScreen(blk) logo := ` - ___ ___ ___ - / /\ / /\ / /\ - / /::\ / /::| / /::\ - /__/:/\:\ / /:|:| / /:/\:\ - _\_ \:\ \:\ / /:/|:|__ / /::\ \:\ + ___ ___ ___ + / /\ / /\ / /\ + / /::\ / /::| / /::\ + /__/:/\:\ / /:|:| / /:/\:\ + _\_ \:\ \:\ / /:/|:|__ / /::\ \:\ /__/\ \:\ \:\ /__/:/ |:| /\ /__/:/\:\_\:\ \ \:\ \:\_\/ \__\/ |:|/:/ \__\/ \:\/:/ - \ \:\_\:\ | |:/:/ \__\::/ - \ \:\/:/ |__|::/ / /:/ - \ \::/ /__/:/ /__/:/ - \__\/ \__\/ \__\/ - ___ ___ - / /\ / /\ - / /:/ / /::\ - / /:/ / /:/\:\ - / /::\____ / /::\ \:\ + \ \:\_\:\ | |:/:/ \__\::/ + \ \:\/:/ |__|::/ / /:/ + \ \::/ /__/:/ /__/:/ + \__\/ \__\/ \__\/ + ___ ___ + / /\ / /\ + / /:/ / /::\ + / /:/ / /:/\:\ + / /::\____ / /::\ \:\ /__/:/\:::::\ /__/:/\:\ \:\ \__\/~|:|~~~~ \ \:\ \:\_\/ - | |:| \ \:\ \:\ - | |:| \ \:\_\/ - |__|:| \ \:\ - \__\| \__\/ + | |:| \ \:\ \:\ + | |:| \ \:\_\/ + |__|:| \ \:\ + \__\| \__\/ ` for i, line := range strings.Split(strings.TrimSuffix(logo, "\n"), "\n") { - tinyfont.WriteLine(&display, &proggy.TinySZ8pt7b, 0, int16(-6+i*11), line+"\n", gr) + tinyfont.WriteLine(&display, &proggy.TinySZ8pt7b, 0, int16(-6+i*11), line+"\n", grn) + } + + scoreStr = strconv.Itoa(int(g.snake.length)) + tinyfont.WriteLineRotated(&display, &freesans.Regular12pt7b, 300, 200, "SCORE: "+scoreStr, colors[TEXT], tinyfont.ROTATION_270) + + // Sound mode menu (selected=0 is Sound OFF, selected=1 is Sound ON) + selected := 0 // Default to Sound OFF + if snakeSoundEnabled { + selected = 1 + } + g.drawSoundMenu(selected) + + btnUpPressed := false + btnDownPressed := false + btnAPressed := true // Start pressed to avoid immediate selection + + for { + // Detect button press on transition from released to pressed + if !btnUp.Get() { + if !btnUpPressed && selected > 0 { + selected-- + g.drawSoundMenu(selected) + } + btnUpPressed = true + } else { + btnUpPressed = false + } + + if !btnDown.Get() { + if !btnDownPressed && selected < 1 { + selected++ + g.drawSoundMenu(selected) + } + btnDownPressed = true + } else { + btnDownPressed = false + } + + if !btnA.Get() { + if !btnAPressed { + snakeSoundEnabled = (selected == 1) // selected=0 is Sound OFF, selected=1 is Sound ON + break + } + } else { + btnAPressed = false + } + + time.Sleep(50 * time.Millisecond) } +} - tinyfont.WriteLine(&display, &freesans.Regular18pt7b, 30, 130, "Press A to start", colors[RED]) +func (g *SnakeGame) drawSoundMenu(selected int) { + // Clear menu area + //display.FillRectangle(30, 115, 260, 80, blk) - if g.score > 0 { - scoreStr = strconv.Itoa(g.score) - tinyfont.WriteLineRotated(&display, &freesans.Regular12pt7b, 300, 200, "SCORE: "+scoreStr, colors[TEXT], tinyfont.ROTATION_270) + // Draw menu options (selected=0 is Sound OFF, selected=1 is Sound ON) + soundOnColor := colors[WHITE] + soundOffColor := colors[WHITE] + if selected == 0 { + soundOffColor = colors[YELLOW] + } else { + soundOnColor = colors[YELLOW] } + + tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 50, 140, "Sound OFF", soundOffColor) + tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 50, 170, "Sound ON", soundOnColor) + tinyfont.WriteLine(&display, &freesans.Regular12pt7b, 30, 210, "Press A to start", colors[RED]) +} + +// flashNeopixels briefly flashes both Neopixels with the given color +func flashNeopixels(c color.RGBA) { + off := color.RGBA{0, 0, 0, 255} + ledColors := []color.RGBA{c, c} + leds.WriteColors(ledColors) + time.Sleep(100 * time.Millisecond) + // Turn off LEDs - write twice like leds.go does for reliability + ledColors[0] = off + ledColors[1] = off + leds.WriteColors(ledColors) + time.Sleep(50 * time.Millisecond) + leds.WriteColors(ledColors) } func (g *SnakeGame) initSnake() { @@ -187,11 +294,19 @@ func (g *SnakeGame) collisionWithSnake(x, y int16) bool { func (g *SnakeGame) createApple() { g.appleX = int16(rand.Int31n(WIDTHBLOCKS)) g.appleY = int16(rand.Int31n(HEIGHTBLOCKS)) - for g.collisionWithSnake(g.appleX, g.appleY) { + for g.collisionWithSnake(g.appleX, g.appleY) || (g.appleX == g.sparkleX && g.appleY == g.sparkleY && g.eatCounter > 0) { g.appleX = int16(rand.Int31n(WIDTHBLOCKS)) g.appleY = int16(rand.Int31n(HEIGHTBLOCKS)) } g.drawApple(g.appleX, g.appleY) + // Poison apple + g.poisonX = int16(rand.Int31n(WIDTHBLOCKS)) + g.poisonY = int16(rand.Int31n(HEIGHTBLOCKS)) + for g.collisionWithSnake(g.poisonX, g.poisonY) || (g.poisonX == g.sparkleX && g.poisonY == g.sparkleY && g.eatCounter > 0) { + g.poisonX = int16(rand.Int31n(WIDTHBLOCKS)) + g.poisonY = int16(rand.Int31n(HEIGHTBLOCKS)) + } + g.drawPoisonApple(g.poisonX, g.poisonY) } func (g *SnakeGame) moveSnake() { @@ -226,17 +341,59 @@ func (g *SnakeGame) moveSnake() { } if g.collisionWithSnake(x, y) { - g.score = int(g.snake.length - 3) g.Over() return } // draw head - g.drawSnakePartial(x, y, colors[SNAKE]) + snakeColor := g.getSnakeColor() + g.drawSnakePartial(x, y, snakeColor) if x == g.appleX && y == g.appleY { g.snake.length++ + // Clear any previous unfinished sparkle animation before starting a new one + if g.eatCounter > 0 { + g.clearSparkles(g.sparkleX, g.sparkleY) + } + // Trigger sparkle animation and color flash at apple position + g.sparkleX = g.appleX + g.sparkleY = g.appleY + g.eatCounter = 8 // 8 frames = 800ms for both sparkles and color flash + // remove poison apple + g.drawSnakePartial(g.poisonX, g.poisonY, colors[BLACK]) + // create new apple and poison apple g.createApple() + if snakeSoundEnabled { + go tone(1500) + } else { + // TODO: fix Neopixel issue - LEDs sometimes stay on + // go flashNeopixels(color.RGBA{0, 255, 0, 255}) // Green for good apple + } + } else if x == g.poisonX && y == g.poisonY { + // Clear any previous unfinished sparkle animation + if g.eatCounter > 0 { + g.clearSparkles(g.sparkleX, g.sparkleY) + g.eatCounter = 0 + } + // Check if snake is too short to survive eating poison + if g.snake.length <= 1 { + g.Over() + return + } + // remove tail (safe now since length > 1) + g.drawSnakePartial(g.snake.body[g.snake.length-2][0], g.snake.body[g.snake.length-2][1], colors[BLACK]) + g.drawSnakePartial(g.snake.body[g.snake.length-1][0], g.snake.body[g.snake.length-1][1], colors[BLACK]) + g.snake.length-- + // remove regular apple + g.drawSnakePartial(g.appleX, g.appleY, colors[BLACK]) + // create new apple and poison apple + g.createApple() + if snakeSoundEnabled { + go tone(500) + } else { + // TODO: fix Neopixel issue - LEDs sometimes stay on + // go flashNeopixels(color.RGBA{255, 0, 0, 255}) // Red for poison apple + } } else { // remove tail g.drawSnakePartial(g.snake.body[g.snake.length-1][0], g.snake.body[g.snake.length-1][1], colors[BLACK]) @@ -253,9 +410,24 @@ func (g *SnakeGame) drawApple(x, y int16) { display.FillRectangleWithBuffer(10*x, 10*y, 10, 10, appleBuf) } +func (g *SnakeGame) drawPoisonApple(x, y int16) { + display.FillRectangleWithBuffer(10*x, 10*y, 10, 10, poisonAppleBuf) +} + +func (g *SnakeGame) getSnakeColor() color.RGBA { + if g.eatCounter > 0 { + // Alternate between normal green and yellow every frame + if g.eatCounter%2 == 0 { + return colors[YELLOW] + } + } + return colors[SNAKE] // normal green +} + func (g *SnakeGame) drawSnake() { + snakeColor := g.getSnakeColor() for i := int16(0); i < g.snake.length; i++ { - g.drawSnakePartial(g.snake.body[i][0], g.snake.body[i][1], colors[SNAKE]) + g.drawSnakePartial(g.snake.body[i][0], g.snake.body[i][1], snakeColor) } } @@ -263,6 +435,78 @@ func (g *SnakeGame) drawSnakePartial(x, y int16, c color.RGBA) { display.FillRectangle(10*x, 10*y, 9, 9, c) } +func (g *SnakeGame) drawSparkles(x, y int16, frame int) { + // Define sparkle positions around the apple (relative offsets in pixels) + sparkleOffsets := [][2]int16{ + {-2, -2}, {5, -2}, {12, -2}, // top row + {-2, 5}, {12, 5}, // middle row (left and right) + {-2, 12}, {5, 12}, {12, 12}, // bottom row + } + + // Cycle through different sparkle colors based on frame + var sparkleColor color.RGBA + switch frame % 4 { + case 0: + sparkleColor = colors[WHITE] + case 1: + sparkleColor = color.RGBA{255, 255, 0, 255} // yellow + case 2: + sparkleColor = colors[ORANGE] + case 3: + sparkleColor = color.RGBA{255, 200, 200, 255} // light pink + } + + baseX := 10 * x + baseY := 10 * y + + // Draw sparkles at different positions based on frame for animation effect + for i, offset := range sparkleOffsets { + px := baseX + offset[0] + py := baseY + offset[1] + // Make sparkles appear/disappear in a pattern + if (frame+i)%2 == 0 { + // Draw a small 2x2 sparkle + display.FillRectangle(px, py, 2, 2, sparkleColor) + } else { + // Clear the sparkle when it's not visible + display.FillRectangle(px, py, 2, 2, colors[BLACK]) + } + } +} + +func (g *SnakeGame) clearSparkles(x, y int16) { + // Clear all sparkle positions + sparkleOffsets := [][2]int16{ + {-2, -2}, {5, -2}, {12, -2}, // top row + {-2, 5}, {12, 5}, // middle row (left and right) + {-2, 12}, {5, 12}, {12, 12}, // bottom row + } + + baseX := 10 * x + baseY := 10 * y + + for _, offset := range sparkleOffsets { + px := baseX + offset[0] + py := baseY + offset[1] + display.FillRectangle(px, py, 2, 2, colors[BLACK]) + } +} + +func (g *SnakeGame) updateEatAnimation() { + if g.eatCounter > 0 { + // Update sparkles + g.drawSparkles(g.sparkleX, g.sparkleY, g.eatCounter) + // Update snake color flash + g.drawSnake() + g.eatCounter-- + // Clear sparkles and reset snake color when animation ends + if g.eatCounter == 0 { + g.clearSparkles(g.sparkleX, g.sparkleY) + g.drawSnake() + } + } +} + func (g *SnakeGame) Loop() { g.status = GameSplash splashed = false @@ -312,10 +556,15 @@ func (g *SnakeGame) update() { g.Play(-1) break } + // Update eat animation (sparkles and color flash) every frame + g.updateEatAnimation() break case GameQuit: case GameOver: - g.Splash() + if !splashed { + g.showGameOver() + splashed = true + } if !btnA.Get() { g.Start()