diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c2a16e8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: + - main + - master + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install native dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libc6-dev \ + libglu1-mesa-dev \ + libgl1-mesa-dev \ + libxcursor-dev \ + libxi-dev \ + libxinerama-dev \ + libxrandr-dev \ + libxxf86vm-dev \ + libasound2-dev \ + pkg-config + + - name: Verify formatting + run: test -z "$(gofmt -l main.go game/*.go)" + + - name: Run tests + run: make test diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0781eb0 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: help run build test tidy fmt + +help: + @echo "Available targets:" + @echo " make run - Run the game" + @echo " make build - Build the gong binary" + @echo " make test - Run Go tests" + @echo " make tidy - Tidy go modules" + @echo " make fmt - Format Go source files" + +run: + go run . + +build: + go build ./... + +test: + go test ./... + +tidy: + go mod tidy + +fmt: + gofmt -w main.go game/*.go diff --git a/README.md b/README.md index 0506a40..105392c 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,48 @@ # Gong! - The Go Pong -Retro styled classic [Pong](https://en.wikipedia.org/wiki/Pong) clone based upon [Ebiten](https://ebiten.org/) game framework. +Retro styled classic [Pong](https://en.wikipedia.org/wiki/Pong) clone based on the [Ebitengine](https://ebitengine.org/) game framework. -With go version 16 or above installed, type +## Run from source + +With Go **1.22+** installed, run: ```bash -git clone http://github.com/jenska/gong +git clone https://github.com/jenska/gong.git cd gong -go run main.go +go run . ``` -to start - ![Screenshot](game/assets/screenshot.png) -For a standalone gong game, type +## Install + +To build and install a standalone `gong` binary into your Go bin path: + ```bash go install ``` +## Makefile targets + +Common development tasks are available through `make`: + +```bash +make run +make build +make test +make fmt +make tidy +``` + +## Controls + +- `W` / `S`: move left paddle +- `↑` / `↓`: move right paddle +- `Esc`: quit ## Dependencies -Ebiten requires some additional libs installed +Ebitengine requires additional native libraries on Linux. ### Debian / Ubuntu @@ -36,15 +56,6 @@ sudo apt install libc6-dev libglu1-mesa-dev libgl1-mesa-dev libxcursor-dev libxi sudo dnf install mesa-libGLU-devel mesa-libGLES-devel libXrandr-devel libXcursor-devel libXinerama-devel libXi-devel libXxf86vm-devel alsa-lib-devel pkg-config ``` -## Thanks to - -[OpenGameArt.Org](https://opengameart.org/) - -## Soon to come +## Thanks -- ~~update package structure~~ -- ~~stereo sounds~~ -- ~~make window resizable~~ -- ~~upgrade to ebiten 2.0~~ -- ~~embedded assets~~ -- AI support with Tensorflow. Soon to come! +- [OpenGameArt.org](https://opengameart.org/) diff --git a/game/paddle.go b/game/paddle.go index b58c941..548a992 100644 --- a/game/paddle.go +++ b/game/paddle.go @@ -1,6 +1,10 @@ package game import ( + "math" + "math/rand" + "time" + ebiten "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/inpututil" ) @@ -10,6 +14,13 @@ const ( paddleHeight = 80 paddleShift = 50 paddleAcceleration = 1 + + aiMaxSpeed = 6.0 + aiDeadZone = 6.0 + aiAimError = 18.0 + aiIdleAimError = 10.0 + aiReactionMin = 4 + aiReactionJitter = 6 ) type paddle struct { @@ -19,6 +30,10 @@ type paddle struct { isComputer *bool score *int upKey, downKey ebiten.Key + + aiTargetY float64 + aiCooldown int + aiRandom *rand.Rand } func newPaddle(player int, score *int, mode *bool) *paddle { @@ -36,6 +51,8 @@ func newPaddle(player int, score *int, mode *bool) *paddle { p.downKey = ebiten.KeyDown } p.y = windowHeight/2 - paddleHeight/2 + p.aiTargetY = p.y + p.aiRandom = rand.New(rand.NewSource(time.Now().UnixNano() + int64(player*101))) p.image = ebiten.NewImage(paddleWidth, paddleHeight) p.image.Fill(objectColor) p.ghostImage = ebiten.NewImage(paddleWidth, paddleHeight) @@ -46,7 +63,7 @@ func newPaddle(player int, score *int, mode *bool) *paddle { func (p *paddle) update(g *Gong) { if g.state == play { if *p.isComputer { - p.y = g.ball.y - paddleHeight/2 + p.updateComputer(g) } else { if inpututil.KeyPressDuration(p.upKey) > 0 { p.yVelocity -= paddleAcceleration @@ -54,19 +71,17 @@ func (p *paddle) update(g *Gong) { if inpututil.KeyPressDuration(p.downKey) > 0 { p.yVelocity += paddleAcceleration } - p.y += p.yVelocity + } - if p.y < 0 { - p.y = 1.0 - p.yVelocity = 0 - playSound(ping) - } else if p.y+paddleHeight > windowHeight { - p.y = windowHeight - paddleHeight - 1.0 - p.yVelocity = 0 - playSound(ping) - } - + if p.y < 0 { + p.y = 1.0 + p.yVelocity = 0 + playSound(ping) + } else if p.y+paddleHeight > windowHeight { + p.y = windowHeight - paddleHeight - 1.0 + p.yVelocity = 0 + playSound(ping) } // inelastic collision @@ -99,3 +114,84 @@ func (p *paddle) update(g *Gong) { } p.visible = g.state == play || g.state == interrupt } + +func (p *paddle) updateComputer(g *Gong) { + if p.aiCooldown > 0 { + p.aiCooldown-- + } + + if p.aiCooldown == 0 { + p.aiTargetY = p.nextAITargetY(g) + p.aiCooldown = aiReactionMin + p.aiRandom.Intn(aiReactionJitter+1) + } + + delta := p.aiTargetY - p.y + if math.Abs(delta) < aiDeadZone { + p.yVelocity *= 0.7 + p.y += p.yVelocity + return + } + + p.yVelocity = clamp(delta*0.20, -aiMaxSpeed, aiMaxSpeed) + p.y += p.yVelocity +} + +func (p *paddle) nextAITargetY(g *Gong) float64 { + if p.isBallApproaching(g.ball) { + return p.predictBallY(g.ball) - paddleHeight/2 + p.randomError(aiAimError) + } + idleTarget := float64(windowHeight/2-paddleHeight/2) + p.randomError(aiIdleAimError) + return idleTarget +} + +func (p *paddle) isBallApproaching(b *ball) bool { + if p.player == leftPlayer { + return b.xVelocity < 0 + } + return b.xVelocity > 0 +} + +func (p *paddle) predictBallY(b *ball) float64 { + targetX := p.x + paddleWidth/2 + if p.player == leftPlayer { + targetX += ballRadius + } else { + targetX -= ballRadius + } + + if b.xVelocity == 0 { + return b.y + } + timeToReach := (targetX - b.x) / b.xVelocity + if timeToReach <= 0 { + return b.y + } + + predictedY := b.y + b.yVelocity*timeToReach + minY := float64(ballRadius) + maxY := float64(windowHeight - ballRadius) + + for predictedY < minY || predictedY > maxY { + if predictedY < minY { + predictedY = minY + (minY - predictedY) + } else { + predictedY = maxY - (predictedY - maxY) + } + } + + return predictedY +} + +func (p *paddle) randomError(max float64) float64 { + return (p.aiRandom.Float64()*2 - 1) * max +} + +func clamp(v, min, max float64) float64 { + if v < min { + return min + } + if v > max { + return max + } + return v +} diff --git a/go.mod b/go.mod index 1221057..c45ebb1 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,17 @@ module github.com/jenska/gong -go 1.16 +go 1.22 require ( github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/hajimehoshi/ebiten/v2 v2.0.6 golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb ) + +require ( + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200707082815-5321531c36a2 // indirect + github.com/hajimehoshi/oto v0.6.8 // indirect + golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect + golang.org/x/mobile v0.0.0-20210208171126-f462b3930c8f // indirect + golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 // indirect +)