383 lines
9.4 KiB
Go
383 lines
9.4 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"math/rand"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// Physics tuned so per-frame displacement stays around one row, which keeps
|
|
// motion smooth and the 7-row gap threadable at 30fps.
|
|
const (
|
|
fps = 30
|
|
gravity = 0.09 // rows/frame^2
|
|
jumpVel = -0.95 // rows/frame; peak rise ≈ v²/2g ≈ 5 rows over ~0.35s
|
|
maxFall = 1.15 // terminal velocity, rows/frame — never skip >1 row
|
|
pipeSpeed = 0.5 // cells/frame → 15 cells/s
|
|
pipeGap = 7
|
|
pipeSpacing = 28 // cells between pipes → one pipe ≈ every 1.9s
|
|
pipeWidth = 3
|
|
groundH = 2
|
|
)
|
|
|
|
// tickMsg advances the simulation. gen guards against stale tick chains:
|
|
// without it, restarting can leave an old chain in flight and the game
|
|
// silently runs at double speed.
|
|
type tickMsg struct{ gen int }
|
|
|
|
func tick(gen int) tea.Cmd {
|
|
return tea.Tick(time.Second/fps, func(time.Time) tea.Msg {
|
|
return tickMsg{gen: gen}
|
|
})
|
|
}
|
|
|
|
type pipe struct {
|
|
x float64
|
|
gapY int
|
|
scored bool
|
|
}
|
|
|
|
type star struct{ x, y int }
|
|
|
|
type model struct {
|
|
termW, termH int
|
|
width int // field width in cells
|
|
height int // field height in cells (terminal minus header+footer)
|
|
birdX int
|
|
birdY float64
|
|
birdVel float64
|
|
pipes []pipe
|
|
stars []star
|
|
score int
|
|
best int
|
|
gen int // tick generation
|
|
gameOver bool
|
|
started bool
|
|
resized bool
|
|
}
|
|
|
|
func newGame(best, gen int, termW, termH int) model {
|
|
m := model{best: best, gen: gen}
|
|
if termW > 0 && termH > 0 {
|
|
m.applySize(termW, termH)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// applySize records the terminal size, sizes the field, and rebuilds the
|
|
// static starfield. The starfield must be stable between frames — rolling
|
|
// new random speckles every render is what caused full-screen flicker.
|
|
func (m *model) applySize(w, h int) {
|
|
m.termW, m.termH = w, h
|
|
m.width = w
|
|
fieldH := h - 2 // one row header, one row footer
|
|
if fieldH < 8 {
|
|
fieldH = 8
|
|
}
|
|
m.height = fieldH
|
|
m.birdX = w / 4
|
|
if m.birdX < 4 {
|
|
m.birdX = 4
|
|
}
|
|
if !m.resized {
|
|
m.birdY = float64(m.height-groundH) / 2
|
|
m.resized = true
|
|
}
|
|
// keep the bird inside the new bounds after a resize
|
|
floor := float64(m.height - groundH - 1)
|
|
if m.birdY > floor {
|
|
m.birdY = floor
|
|
}
|
|
// static starfield, ~1 speckle per 40 sky cells
|
|
skyH := m.height - groundH
|
|
m.stars = m.stars[:0]
|
|
n := m.width * skyH / 40
|
|
for i := 0; i < n; i++ {
|
|
m.stars = append(m.stars, star{x: rand.Intn(m.width), y: rand.Intn(skyH)})
|
|
}
|
|
}
|
|
|
|
func (m model) Init() tea.Cmd { return nil }
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.applySize(msg.Width, msg.Height)
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case " ", "space", "enter", "up", "k":
|
|
if m.gameOver {
|
|
// restart: bump generation so any in-flight tick is ignored
|
|
nm := newGame(m.best, m.gen+1, m.termW, m.termH)
|
|
nm.started = true
|
|
nm.birdVel = jumpVel // start with a flap, like the original
|
|
return nm, tick(nm.gen)
|
|
}
|
|
if !m.started {
|
|
m.started = true
|
|
m.birdVel = jumpVel
|
|
return m, tick(m.gen)
|
|
}
|
|
m.birdVel = jumpVel
|
|
case "q", "esc", "ctrl+c":
|
|
return m, tea.Quit
|
|
}
|
|
|
|
case tickMsg:
|
|
if msg.gen != m.gen {
|
|
return m, nil // stale chain — drop it
|
|
}
|
|
if !m.started || m.gameOver {
|
|
return m, nil // stop the loop; nothing animates on end screens
|
|
}
|
|
if m.resized {
|
|
m.step()
|
|
}
|
|
return m, tick(m.gen)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *model) step() {
|
|
m.birdVel += gravity
|
|
if m.birdVel > maxFall {
|
|
m.birdVel = maxFall
|
|
}
|
|
m.birdY += m.birdVel
|
|
|
|
// ceiling clamp
|
|
if m.birdY < 0 {
|
|
m.birdY = 0
|
|
m.birdVel = 0
|
|
}
|
|
// ground collision
|
|
floor := float64(m.height - groundH - 1)
|
|
if m.birdY >= floor {
|
|
m.birdY = floor
|
|
m.gameOver = true
|
|
}
|
|
|
|
// advance pipes
|
|
for i := range m.pipes {
|
|
m.pipes[i].x -= pipeSpeed
|
|
}
|
|
for len(m.pipes) > 0 && m.pipes[0].x+pipeWidth < 0 {
|
|
m.pipes = m.pipes[1:]
|
|
}
|
|
// spawn pipes
|
|
usableH := m.height - groundH - pipeGap - 4
|
|
if usableH < 1 {
|
|
usableH = 1
|
|
}
|
|
if len(m.pipes) == 0 || m.pipes[len(m.pipes)-1].x < float64(m.width-pipeSpacing) {
|
|
gapY := rand.Intn(usableH) + 2
|
|
m.pipes = append(m.pipes, pipe{x: float64(m.width), gapY: gapY})
|
|
}
|
|
|
|
by := int(m.birdY)
|
|
for i := range m.pipes {
|
|
p := &m.pipes[i]
|
|
px := int(p.x)
|
|
// score once the pipe's trailing edge passes the bird
|
|
if !p.scored && px+pipeWidth <= m.birdX {
|
|
p.scored = true
|
|
m.score++
|
|
if m.score > m.best {
|
|
m.best = m.score
|
|
}
|
|
}
|
|
// collision with pipe body
|
|
if m.birdX >= px && m.birdX < px+pipeWidth {
|
|
if by < p.gapY || by > p.gapY+pipeGap {
|
|
m.gameOver = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// styles ----------------------------------------------------------------------
|
|
|
|
var (
|
|
birdStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true)
|
|
birdUpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
|
|
birdDnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
|
|
pipeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("34"))
|
|
pipeHi = lipgloss.NewStyle().Foreground(lipgloss.Color("46"))
|
|
capStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("36"))
|
|
skyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("33"))
|
|
groundStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("142"))
|
|
dirtStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130"))
|
|
scoreStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true)
|
|
bestStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
|
|
overStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
|
|
hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
|
titleStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("220")).
|
|
Background(lipgloss.Color("23")).
|
|
Bold(true)
|
|
)
|
|
|
|
func birdGlyph(vel float64) (string, lipgloss.Style) {
|
|
switch {
|
|
case vel < -0.3:
|
|
return "➚", birdUpStyle
|
|
case vel > 0.5:
|
|
return "➘", birdDnStyle
|
|
default:
|
|
return "➙", birdStyle
|
|
}
|
|
}
|
|
|
|
func (m model) View() string {
|
|
if !m.resized {
|
|
return hintStyle.Render(" sizing terminal… ")
|
|
}
|
|
width, height := m.width, m.height
|
|
grid := make([][]string, height)
|
|
for i := range grid {
|
|
grid[i] = make([]string, width)
|
|
for j := range grid[i] {
|
|
grid[i][j] = " "
|
|
}
|
|
}
|
|
|
|
// static sky speckles (precomputed — stable between frames)
|
|
for _, s := range m.stars {
|
|
if s.y < height-groundH && s.x < width {
|
|
grid[s.y][s.x] = skyStyle.Render("·")
|
|
}
|
|
}
|
|
|
|
// pipes
|
|
for _, p := range m.pipes {
|
|
px := int(p.x)
|
|
topCap := p.gapY - 1
|
|
botCap := p.gapY + pipeGap + 1
|
|
for x := px; x < px+pipeWidth && x < width; x++ {
|
|
if x < 0 {
|
|
continue
|
|
}
|
|
for y := 0; y < height-groundH; y++ {
|
|
if y < p.gapY || y > p.gapY+pipeGap {
|
|
if y == topCap || y == botCap {
|
|
grid[y][x] = capStyle.Render("▓")
|
|
} else if x == px {
|
|
grid[y][x] = pipeHi.Render("█")
|
|
} else {
|
|
grid[y][x] = pipeStyle.Render("█")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ground
|
|
for y := height - groundH; y < height; y++ {
|
|
for x := 0; x < width; x++ {
|
|
if y == height-groundH {
|
|
grid[y][x] = groundStyle.Render("▔")
|
|
} else {
|
|
grid[y][x] = dirtStyle.Render("▓")
|
|
}
|
|
}
|
|
}
|
|
|
|
// bird
|
|
by := int(m.birdY)
|
|
if by >= 0 && by < height && m.birdX >= 0 && m.birdX < width {
|
|
glyph, style := birdGlyph(m.birdVel)
|
|
grid[by][m.birdX] = style.Render(glyph)
|
|
}
|
|
|
|
// overlays are written directly into the grid, cell by cell, so styled
|
|
// field content underneath can never be corrupted by string splicing.
|
|
if !m.started && !m.gameOver {
|
|
m.overlayGrid(grid, []overline{
|
|
{" FLAPPY BIRD ", titleStyle},
|
|
{},
|
|
{"press SPACE to flap", hintStyle},
|
|
})
|
|
} else if m.gameOver {
|
|
m.overlayGrid(grid, []overline{
|
|
{"✖ GAME OVER ✖", overStyle},
|
|
{},
|
|
{fmt.Sprintf("score %d • SPACE to retry", m.score), hintStyle},
|
|
})
|
|
}
|
|
|
|
// compose field
|
|
var b strings.Builder
|
|
for _, row := range grid {
|
|
for _, c := range row {
|
|
b.WriteString(c)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
field := b.String()
|
|
|
|
header := scoreStyle.Render(fmt.Sprintf(" SCORE %d ", m.score)) +
|
|
" " +
|
|
bestStyle.Render(fmt.Sprintf("BEST %d ", m.best))
|
|
footer := hintStyle.Render(" SPACE: flap • Q: quit ")
|
|
|
|
return header + "\n" + field + footer
|
|
}
|
|
|
|
// overline is one centred line of overlay text with its style.
|
|
type overline struct {
|
|
text string
|
|
style lipgloss.Style
|
|
}
|
|
|
|
// overlayGrid writes overlay lines into the middle of the grid, one styled
|
|
// cell per rune. Empty lines clear a centred band for readability.
|
|
func (m model) overlayGrid(grid [][]string, lines []overline) {
|
|
startY := (len(grid) - len(lines)) / 2
|
|
if startY < 0 {
|
|
startY = 0
|
|
}
|
|
// widest line determines the cleared band
|
|
maxW := 0
|
|
for _, ol := range lines {
|
|
if n := len([]rune(ol.text)); n > maxW {
|
|
maxW = n
|
|
}
|
|
}
|
|
for i, ol := range lines {
|
|
y := startY + i
|
|
if y < 0 || y >= len(grid) {
|
|
continue
|
|
}
|
|
// clear a band as wide as the widest line so text sits on a clean row
|
|
bandX := (m.width - maxW - 2) / 2
|
|
for j := 0; j < maxW+2; j++ {
|
|
x := bandX + j
|
|
if x >= 0 && x < m.width {
|
|
grid[y][x] = " "
|
|
}
|
|
}
|
|
runes := []rune(ol.text)
|
|
startX := (m.width - len(runes)) / 2
|
|
for j, r := range runes {
|
|
x := startX + j
|
|
if x < 0 || x >= m.width {
|
|
continue
|
|
}
|
|
grid[y][x] = ol.style.Render(string(r))
|
|
}
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
p := tea.NewProgram(newGame(0, 0, 0, 0), tea.WithAltScreen())
|
|
if _, err := p.Run(); err != nil {
|
|
fmt.Println("error running game:", err)
|
|
}
|
|
}
|