latest
This commit is contained in:
28
flappy/go.mod
Normal file
28
flappy/go.mod
Normal file
@@ -0,0 +1,28 @@
|
||||
module flappy
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
)
|
||||
43
flappy/go.sum
Normal file
43
flappy/go.sum
Normal file
@@ -0,0 +1,43 @@
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
382
flappy/main.go
Normal file
382
flappy/main.go
Normal file
@@ -0,0 +1,382 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
168
geolocate/geolocate.go
Normal file
168
geolocate/geolocate.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// Command geolocate estimates the machine's physical location by scanning
|
||||
// nearby Wi-Fi access points and submitting their BSSIDs to a public
|
||||
// Wi-Fi-to-location service, then records the result under ~/.data/location.
|
||||
//
|
||||
// This is a macOS port of the original Linux script. Two things changed since
|
||||
// the original was written:
|
||||
//
|
||||
// - The original scanned access points with nmcli (NetworkManager). macOS
|
||||
// has no equivalent, and Apple removed the `airport` CLI in macOS 14.4.
|
||||
// On macOS 26, CoreWLAN only returns real BSSIDs to a Developer-ID-signed,
|
||||
// notarized binary that the user has granted Location Services access. We
|
||||
// get that via the macwifi package, which ships such a signed helper.
|
||||
//
|
||||
// - The original queried Mozilla Location Services, which shut down in 2024.
|
||||
// We use BeaconDB instead: a community-run successor that speaks the same
|
||||
// Ichnaea API (identical request/response shape).
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/jaisonerick/macwifi"
|
||||
)
|
||||
|
||||
// geolocateURL is BeaconDB's Ichnaea-compatible geolocate endpoint, the
|
||||
// drop-in replacement for the defunct location.services.mozilla.com.
|
||||
const geolocateURL = "https://api.beacondb.net/v1/geolocate"
|
||||
|
||||
// accessPoint is one observed Wi-Fi access point in the Ichnaea request
|
||||
// schema. Only macAddress is required; signal strength and channel improve
|
||||
// the position estimate when available.
|
||||
type accessPoint struct {
|
||||
MacAddress string `json:"macAddress"`
|
||||
SignalStrength int `json:"signalStrength,omitempty"`
|
||||
Channel int `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
type geolocateRequest struct {
|
||||
WifiAccessPoints []accessPoint `json:"wifiAccessPoints,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "geolocate:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
ts := time.Now().Unix()
|
||||
|
||||
// The first scan triggers macOS's Location Services prompt for the
|
||||
// bundled helper; allow generous time for a user to approve it.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Wi-Fi scanning requires Location Services. If it is disabled (or the
|
||||
// scan otherwise fails), fall back to IP-based geolocation rather than
|
||||
// aborting — matching the original, which posted an empty request when
|
||||
// it couldn't find enough access points.
|
||||
aps, err := listAccessPoints(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"geolocate: wifi scan unavailable (%v); falling back to IP geolocation\n", err)
|
||||
aps = nil
|
||||
}
|
||||
|
||||
loc, err := geolocate(aps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
loc["timestamp"] = ts
|
||||
|
||||
out, err := json.Marshal(loc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Join(home, ".data", "location")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeFile(filepath.Join(dir, "latest.json"), out); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile(filepath.Join(dir, fmt.Sprintf("%d.json", ts)), out)
|
||||
}
|
||||
|
||||
func writeFile(path string, content []byte) error {
|
||||
return os.WriteFile(path, content, 0o644)
|
||||
}
|
||||
|
||||
// listAccessPoints scans nearby Wi-Fi networks via CoreWLAN (through the
|
||||
// macwifi helper) and returns their BSSIDs. It returns nil if fewer than two
|
||||
// usable access points are found, mirroring the original script: the
|
||||
// geolocation service needs at least two observations to triangulate.
|
||||
func listAccessPoints(ctx context.Context) ([]accessPoint, error) {
|
||||
networks, err := macwifi.Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning wifi: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var aps []accessPoint
|
||||
for _, n := range networks {
|
||||
// BSSID is empty when Location Services access has not been
|
||||
// granted, or for saved-but-not-visible networks.
|
||||
if n.BSSID == "" || seen[n.BSSID] {
|
||||
continue
|
||||
}
|
||||
seen[n.BSSID] = true
|
||||
aps = append(aps, accessPoint{
|
||||
MacAddress: n.BSSID,
|
||||
SignalStrength: n.RSSI,
|
||||
Channel: n.Channel,
|
||||
})
|
||||
}
|
||||
|
||||
if len(aps) < 2 {
|
||||
return nil, nil
|
||||
}
|
||||
return aps, nil
|
||||
}
|
||||
|
||||
// geolocate posts the observed access points to BeaconDB and returns the
|
||||
// decoded response (e.g. {"location":{"lat":..,"lng":..},"accuracy":..}).
|
||||
// With no access points it degrades to a GeoIP estimate, matching the
|
||||
// original behaviour of posting an empty request.
|
||||
func geolocate(aps []accessPoint) (map[string]any, error) {
|
||||
body, err := json.Marshal(geolocateRequest{WifiAccessPoints: aps})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := http.Post(geolocateURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("geolocation service returned %s: %s",
|
||||
resp.Status, bytes.TrimSpace(data))
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
5
geolocate/go.mod
Normal file
5
geolocate/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module sneak.berlin/go/geolocate
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require github.com/jaisonerick/macwifi v1.0.0
|
||||
2
geolocate/go.sum
Normal file
2
geolocate/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/jaisonerick/macwifi v1.0.0 h1:Jy86C7BBR/J7u1cxFWOWi+YlwYjiMMjYLUt6RGFD1Mc=
|
||||
github.com/jaisonerick/macwifi v1.0.0/go.mod h1:AtCf/s2Y4izU7IYcBSPoGe2lp2Oj53T7rz9D08uqSRM=
|
||||
Reference in New Issue
Block a user