├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── README.md ├── cmd └── game.go ├── go.mod ├── go.sum ├── internal ├── audio │ └── audio.go ├── config │ └── config.go ├── game │ ├── game.go │ ├── helicopters.go │ ├── images.go │ ├── intro.go │ ├── paratroopers.go │ └── turret.go └── utils │ ├── store.go │ └── utils.go ├── resources ├── audio │ ├── gameover.ogg │ ├── hit.ogg │ ├── intro.ogg │ └── shoot.ogg └── embed.go └── screenshot.png /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Go CI 2 | 3 | on: 4 | push: 5 | branches: [ "*" ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | go-version: [1.23] 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v3 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: ${{ matrix.go-version }} 21 | 22 | - name: Install dependencies 23 | run: go mod tidy 24 | 25 | - name: Build 26 | run: go build -v cmd/ 27 | 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.test 2 | *.out 3 | go.work 4 | go.work.sum 5 | .env 6 | .gamedata 7 | .DS_Store 8 | 9 | dist/ 10 | bin/ 11 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | project_name: paragopher 4 | 5 | builds: 6 | - main: ./cmd/game.go 7 | goos: 8 | - darwin 9 | - windows 10 | goarch: 11 | - amd64 12 | - arm64 13 | # https://github.com/hajimehoshi/ebiten/issues/1162 14 | # Can't build linux, can't build amd64 macs from Apple Silicon. 15 | ignore: 16 | - goos: darwin 17 | goarch: amd64 18 | 19 | release: 20 | github: 21 | owner: ystepanoff 22 | name: ParaGopher 23 | prerelease: auto 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Yegor Stepanov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Version](https://img.shields.io/github/go-mod/go-version/ystepanoff/ParaGopher)](https://go.dev) 2 | [![Forks](https://img.shields.io/github/forks/ystepanoff/ParaGopher?style=flat&color=green)](https://github.com/ystepanoff/ParaGopher/forks) 3 | [![Issues](https://img.shields.io/github/issues/ystepanoff/ParaGopher?color=green)](https://github.com/ystepanoff/ParaGopher/issues) 4 | [![License](https://img.shields.io/github/license/ystepanoff/ParaGopher)](https://github.com/ystepanoff/ParaGopher/blob/main/LICENSE) 5 | [![Powered by Ebiten](https://img.shields.io/badge/Powered%20By-Ebitengine™-1abc9c)](https://ebitengine.org/) 6 | 7 | # ParaGopher 8 | 9 | ![ParaGopher](./screenshot.png) 10 | 11 | **ParaGopher** is a retro-style arcade game written in Go using [Ebitengine](https://ebitengine.org). 12 | Inspired by the classic [Paratrooper](https://en.wikipedia.org/wiki/Paratrooper_(video_game)) IBM PC game (1982), 13 | the game allows the player to control a turret that must defend the base against incoming paratroopers. Tilt the turret, 14 | shoot down threats, and prevent paratroopers from reaching your base! 15 | 16 | ## Running the game 17 | Ensure you have Go installed. You can download it from [https://go.dev/dl/](https://go.dev/dl/). 18 | ``` 19 | git clone https://github.com/ystepanoff/ParaGopher 20 | cd ParaGopher 21 | go run cmd/game.go 22 | ``` 23 | 24 | Alternatively, visit the [Releases](https://github.com/ystepanoff/ParaGopher/releases) section, which contains pre-built binaries 25 | for some platforms. 26 | 27 | ## Controls 28 | * Left Arrow (`←`): Rotate turret barrel to the left. 29 | * Right Arrow (`→`): Rotate turret barrel to the right. 30 | * Space: Shoot bullets from the turret. 31 | * Escape (`Esc`): Quit the game. 32 | 33 | ## Contributions 34 | Contributions are welcome! Whether it is reporting bugs, suggesting features, or submitting pull requests, your help is appreciated. 35 | -------------------------------------------------------------------------------- /cmd/game.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/hajimehoshi/ebiten/v2" 8 | "github.com/ystepanoff/paragopher/internal/config" 9 | "github.com/ystepanoff/paragopher/internal/game" 10 | ) 11 | 12 | func main() { 13 | ebiten.SetWindowSize(config.ScreenWidth, config.ScreenHeight) 14 | ebiten.SetWindowTitle("ParaGopher") 15 | ebiten.SetTPS(120) 16 | 17 | g := game.NewGame() 18 | if err := ebiten.RunGame(g); err != nil { 19 | if err == config.ErrQuit { 20 | os.Exit(0) 21 | } 22 | log.Fatal(err) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ystepanoff/paragopher 2 | 3 | go 1.23 4 | 5 | require github.com/hajimehoshi/ebiten/v2 v2.8.6 6 | 7 | require ( 8 | github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 // indirect 9 | github.com/ebitengine/hideconsole v1.0.0 // indirect 10 | github.com/ebitengine/oto/v3 v3.3.2 // indirect 11 | github.com/ebitengine/purego v0.8.0 // indirect 12 | github.com/go-text/typesetting v0.2.0 // indirect 13 | github.com/jezek/xgb v1.1.1 // indirect 14 | github.com/jfreymuth/oggvorbis v1.0.5 // indirect 15 | github.com/jfreymuth/vorbis v1.0.2 // indirect 16 | golang.org/x/image v0.20.0 // indirect 17 | golang.org/x/sync v0.8.0 // indirect 18 | golang.org/x/sys v0.25.0 // indirect 19 | golang.org/x/text v0.18.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 h1:Gk1XUEttOk0/hb6Tq3WkmutWa0ZLhNn/6fc6XZpM7tM= 2 | github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325/go.mod h1:ulhSQcbPioQrallSuIzF8l1NKQoD7xmMZc5NxzibUMY= 3 | github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= 4 | github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= 5 | github.com/ebitengine/oto/v3 v3.3.2 h1:VTWBsKX9eb+dXzaF4jEwQbs4yWIdXukJ0K40KgkpYlg= 6 | github.com/ebitengine/oto/v3 v3.3.2/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U= 7 | github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE= 8 | github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 9 | github.com/go-text/typesetting v0.2.0 h1:fbzsgbmk04KiWtE+c3ZD4W2nmCRzBqrqQOvYlwAOdho= 10 | github.com/go-text/typesetting v0.2.0/go.mod h1:2+owI/sxa73XA581LAzVuEBZ3WEEV2pXeDswCH/3i1I= 11 | github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66 h1:GUrm65PQPlhFSKjLPGOZNPNxLCybjzjYBzjfoBGaDUY= 12 | github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= 13 | github.com/hajimehoshi/bitmapfont/v3 v3.2.0 h1:0DISQM/rseKIJhdF29AkhvdzIULqNIIlXAGWit4ez1Q= 14 | github.com/hajimehoshi/bitmapfont/v3 v3.2.0/go.mod h1:8gLqGatKVu0pwcNCJguW3Igg9WQqVXF0zg/RvrGQWyg= 15 | github.com/hajimehoshi/ebiten/v2 v2.8.6 h1:Dkd/sYI0TYyZRCE7GVxV59XC+WCi2BbGAbIBjXeVC1U= 16 | github.com/hajimehoshi/ebiten/v2 v2.8.6/go.mod h1:cCQ3np7rdmaJa1ZnvslraVlpxNb3wCjEnAP1LHNyXNA= 17 | github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= 18 | github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= 19 | github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= 20 | github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= 21 | github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= 22 | github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= 23 | github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= 24 | github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 25 | golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw= 26 | golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= 27 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 28 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 29 | golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= 30 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 31 | golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= 32 | golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 33 | -------------------------------------------------------------------------------- /internal/audio/audio.go: -------------------------------------------------------------------------------- 1 | package audio 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | 7 | "github.com/hajimehoshi/ebiten/v2/audio" 8 | "github.com/hajimehoshi/ebiten/v2/audio/vorbis" 9 | "github.com/ystepanoff/paragopher/resources" 10 | ) 11 | 12 | const sampleRate = 32000 13 | 14 | type SoundProfile struct { 15 | ctx *audio.Context 16 | IntroPlayer *audio.Player 17 | ShootPlayer *audio.Player 18 | HitPlayer *audio.Player 19 | GameOverPlayer *audio.Player 20 | } 21 | 22 | func getPlayer(ctx *audio.Context, soundBytes []byte) *audio.Player { 23 | decoded, err := vorbis.DecodeWithSampleRate( 24 | sampleRate, 25 | bytes.NewReader(soundBytes), 26 | ) 27 | if err != nil { 28 | log.Fatalf("Failed to decode OGG: %v", err) 29 | } 30 | player, err := ctx.NewPlayer(decoded) 31 | if err != nil { 32 | log.Fatalf("Failed to create player: %v", err) 33 | } 34 | return player 35 | } 36 | 37 | func NewSoundProfile() *SoundProfile { 38 | ctx := audio.NewContext(sampleRate) 39 | return &SoundProfile{ 40 | ctx: ctx, 41 | IntroPlayer: getPlayer(ctx, resources.IntroSoundBytes), 42 | ShootPlayer: getPlayer(ctx, resources.ShootSoundBytes), 43 | HitPlayer: getPlayer(ctx, resources.HitSoundBytes), 44 | GameOverPlayer: getPlayer(ctx, resources.GameOverSoundBytes), 45 | } 46 | } 47 | 48 | func Play(player *audio.Player) { 49 | if err := player.Rewind(); err != nil { 50 | log.Fatalf("Failed to rewind audio player: %v", err) 51 | } 52 | player.Play() 53 | } 54 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "image/color" 6 | ) 7 | 8 | const ( 9 | ScreenWidth = 800 10 | ScreenHeight = 600 11 | 12 | BarrelAngleMin = -70.0 13 | BarrelAngleMax = 70.0 14 | BarrelAngleStep = 0.5 15 | 16 | HelicopterSpawnChance = 0.004 17 | HelicopterSpeed = 0.75 18 | HelicopterDropRate = 2 19 | HelicopterBodyWidth = 30.0 20 | HelicopterBodyHeight = 10.0 21 | HelicopterTailWidth = 16.0 22 | HelicopterTailHeight = 3.0 23 | HelicopterRotorLen = 20.0 24 | 25 | ParatrooperSpawnChance = 0.008 26 | ParatrooperFallSpeed = 0.6 27 | ParatrooperWalkSpeed = 0.25 28 | 29 | BulletSpeed = 5.0 30 | BulletRadius = 2.0 31 | ShotCooldown = 200 32 | 33 | GroundY = 600 34 | ) 35 | 36 | var ( 37 | BaseWidth = float32(ScreenWidth) / 10.0 38 | BaseHeight = float32(ScreenHeight) / 10.0 39 | 40 | ParatrooperWidth = float32(10.0) 41 | ParatrooperHeight = BaseHeight / 3.0 42 | ParachuteRadius = float32(10.0) 43 | 44 | ColourTeal = color.RGBA{R: 101, G: 247, B: 246, A: 255} 45 | ColourPink = color.RGBA{R: 255, G: 82, B: 242, A: 255} 46 | ColourMagenta = color.RGBA{R: 255, G: 0, B: 255, A: 255} 47 | ColourWhite = color.RGBA{R: 255, G: 255, B: 255, A: 255} 48 | ColourBlack = color.RGBA{R: 0, G: 0, B: 0, A: 255} 49 | ColourLightGrey = color.RGBA{R: 50, G: 50, B: 50, A: 255} 50 | ColourDarkGrey = color.RGBA{R: 25, G: 25, B: 25, A: 255} 51 | TransparentBlack = color.RGBA{R: 0, G: 0, B: 0, A: 0} 52 | SemiTransparentBlack = color.RGBA{R: 0, G: 0, B: 0, A: 225} 53 | 54 | ErrQuit = errors.New("user quit the game") 55 | ) 56 | -------------------------------------------------------------------------------- /internal/game/game.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/hajimehoshi/ebiten/v2" 9 | "github.com/hajimehoshi/ebiten/v2/ebitenutil" 10 | "github.com/hajimehoshi/ebiten/v2/vector" 11 | "github.com/ystepanoff/paragopher/internal/audio" 12 | "github.com/ystepanoff/paragopher/internal/config" 13 | "github.com/ystepanoff/paragopher/internal/utils" 14 | ) 15 | 16 | type Game struct { 17 | Score int 18 | gameData *utils.GameData 19 | soundProfile *audio.SoundProfile 20 | 21 | showIntro bool 22 | introStep int 23 | lastIntroStep time.Time 24 | 25 | showExitDialog bool 26 | showGameOverDialog bool 27 | 28 | barrelAngle float64 29 | barrelImage *ebiten.Image 30 | turretBaseImage *ebiten.Image 31 | helicopterImage *ebiten.Image 32 | paratrooperImage *ebiten.Image 33 | paratrooperLandedImage *ebiten.Image 34 | paratrooperFellImage *ebiten.Image 35 | bulletImage *ebiten.Image 36 | 37 | bullets []*Bullet 38 | lastShot time.Time 39 | helicopters []*Helicopter 40 | paratroopers []*Paratrooper 41 | } 42 | 43 | func NewGame() *Game { 44 | gameData, err := utils.LoadData() 45 | if err != nil { 46 | log.Println("Error loading game data!") 47 | gameData = &utils.GameData{} 48 | } 49 | game := &Game{ 50 | bullets: make([]*Bullet, 0), 51 | lastShot: time.Now(), 52 | gameData: gameData, 53 | soundProfile: audio.NewSoundProfile(), 54 | showIntro: true, 55 | } 56 | game.initTurretImage() 57 | game.initBarrelImage() 58 | game.initBulletImage() 59 | game.initHelicopterImage() 60 | game.initParatrooperImage() 61 | game.initParatrooperLandedImage() 62 | game.initParatrooperFellImage() 63 | game.initIntro() 64 | 65 | return game 66 | } 67 | 68 | // Ebiten Game Interface 69 | func (g *Game) Draw(screen *ebiten.Image) { 70 | if g.showIntro { 71 | g.drawIntro(screen) 72 | return 73 | } 74 | g.drawTurret(screen) 75 | g.drawBullets(screen) 76 | g.drawHelicopters(screen) 77 | g.drawParatroopers(screen) 78 | 79 | // Display Score 80 | ebitenutil.DebugPrint( 81 | screen, 82 | fmt.Sprintf("SCORE: %d HI-SCORE: %d", g.Score, g.gameData.HiScore), 83 | ) 84 | 85 | if g.showExitDialog { 86 | showYesNoDialog(screen, "Do you want to exit the game?") 87 | } 88 | 89 | if g.showGameOverDialog { 90 | showYesNoDialog(screen, "GAME OVER!\nWould you like to start again?") 91 | } 92 | } 93 | 94 | func (g *Game) Update() error { 95 | if g.showIntro { 96 | return nil 97 | } 98 | if g.showExitDialog { 99 | if ebiten.IsKeyPressed(ebiten.KeyY) { 100 | if err := utils.SaveData(g.gameData); err != nil { 101 | log.Fatalf("Failed to save game dada: %v", err) 102 | } 103 | return config.ErrQuit 104 | } 105 | if ebiten.IsKeyPressed(ebiten.KeyN) { 106 | g.showExitDialog = false 107 | } 108 | return nil 109 | } 110 | if g.showGameOverDialog { 111 | if err := utils.SaveData(g.gameData); err != nil { 112 | log.Fatalf("Failed to save game dada: %v", err) 113 | } 114 | if ebiten.IsKeyPressed(ebiten.KeyY) { 115 | g.Reset() 116 | } 117 | if ebiten.IsKeyPressed(ebiten.KeyN) { 118 | return config.ErrQuit 119 | } 120 | return nil 121 | } 122 | if ebiten.IsKeyPressed(ebiten.KeyEscape) { 123 | g.showExitDialog = true 124 | } 125 | if ebiten.IsKeyPressed(ebiten.KeyLeft) { 126 | if g.barrelAngle > config.BarrelAngleMin { 127 | g.barrelAngle -= config.BarrelAngleStep 128 | } 129 | } 130 | if ebiten.IsKeyPressed(ebiten.KeyRight) { 131 | if g.barrelAngle < config.BarrelAngleMax { 132 | g.barrelAngle += config.BarrelAngleStep 133 | } 134 | } 135 | if ebiten.IsKeyPressed(ebiten.KeySpace) { 136 | if time.Since(g.lastShot).Milliseconds() > config.ShotCooldown { 137 | g.shoot() 138 | } 139 | } 140 | 141 | g.updateBullets() 142 | g.spawnHelicopter() 143 | g.updateHelicopters() 144 | g.updateParatroopers() 145 | g.checkHits() 146 | 147 | return nil 148 | } 149 | 150 | func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { 151 | return config.ScreenWidth, config.ScreenHeight 152 | } 153 | 154 | func (g *Game) Reset() { 155 | g.soundProfile.GameOverPlayer.Pause() 156 | g.Score = 0 157 | g.showExitDialog = false 158 | g.showGameOverDialog = false 159 | g.barrelAngle = 0.0 160 | g.bullets = nil 161 | g.helicopters = nil 162 | g.paratroopers = nil 163 | } 164 | 165 | func showYesNoDialog(screen *ebiten.Image, message string) { 166 | overlay := ebiten.NewImage(screen.Bounds().Dx(), screen.Bounds().Dy()) 167 | overlay.Fill(config.SemiTransparentBlack) 168 | screen.DrawImage(overlay, nil) 169 | 170 | dialogWidth, dialogHeight := 300, 150 171 | dialogX := (screen.Bounds().Dx() - dialogWidth) / 2 172 | dialogY := (screen.Bounds().Dy() - dialogHeight) / 2 173 | dialog := ebiten.NewImage(dialogWidth, dialogHeight) 174 | dialog.Fill(config.ColourDarkGrey) 175 | 176 | vector.DrawFilledRect( 177 | dialog, 178 | 0, 179 | 0, 180 | float32(dialogWidth), 181 | 5, 182 | config.ColourBlack, 183 | false, 184 | ) 185 | vector.DrawFilledRect( 186 | dialog, 187 | 0, 188 | float32(dialogHeight-5), 189 | float32(dialogWidth), 190 | 5, 191 | config.ColourBlack, 192 | false, 193 | ) 194 | vector.DrawFilledRect( 195 | dialog, 196 | 0, 197 | 0, 198 | 5, 199 | float32(dialogHeight), 200 | config.ColourBlack, 201 | false, 202 | ) 203 | vector.DrawFilledRect( 204 | dialog, 205 | float32(dialogWidth-5), 206 | 0, 207 | 5, 208 | float32(dialogHeight), 209 | config.ColourBlack, 210 | false, 211 | ) 212 | op := &ebiten.DrawImageOptions{} 213 | op.GeoM.Translate(float64(dialogX), float64(dialogY)) 214 | screen.DrawImage(dialog, op) 215 | 216 | textX := dialogX + 50 217 | textY := dialogY + 40 218 | ebitenutil.DebugPrintAt(screen, message, textX, textY) 219 | 220 | yesText := "Y: Yes" 221 | noText := "N: No" 222 | ebitenutil.DebugPrintAt(screen, yesText, dialogX+50, dialogY+90) 223 | ebitenutil.DebugPrintAt(screen, noText, dialogX+200, dialogY+90) 224 | } 225 | -------------------------------------------------------------------------------- /internal/game/helicopters.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | 7 | "github.com/hajimehoshi/ebiten/v2" 8 | "github.com/ystepanoff/paragopher/internal/config" 9 | "github.com/ystepanoff/paragopher/internal/utils" 10 | ) 11 | 12 | type Helicopter struct { 13 | x, y float32 14 | leftToRight bool 15 | lastDrop time.Time 16 | } 17 | 18 | func (g *Game) drawHelicopter(screen *ebiten.Image, h *Helicopter) { 19 | op := &ebiten.DrawImageOptions{} 20 | dx := float64(g.helicopterImage.Bounds().Dx()) 21 | dy := float64(g.helicopterImage.Bounds().Dy()) 22 | if !h.leftToRight { 23 | op.GeoM.Scale(-1.0, 1.0) 24 | op.GeoM.Translate(dx, 0.0) 25 | } 26 | op.GeoM.Translate(float64(h.x)-dx/2.0, float64(h.y)-dy/2.0) 27 | screen.DrawImage(g.helicopterImage, op) 28 | } 29 | 30 | func (g *Game) drawHelicopters(screen *ebiten.Image) { 31 | for _, h := range g.helicopters { 32 | g.drawHelicopter(screen, h) 33 | } 34 | } 35 | 36 | func (g *Game) spawnHelicopter() { 37 | if rand.Float32() < config.HelicopterSpawnChance { 38 | startX := -float32( 39 | config.HelicopterBodyWidth + config.HelicopterTailWidth, 40 | ) 41 | startY := float32(50 + rand.Intn(50)) 42 | leftToRight := true 43 | if rand.Intn(2) == 1 { 44 | startX = config.ScreenWidth - startX 45 | leftToRight = false 46 | } 47 | g.helicopters = append(g.helicopters, &Helicopter{ 48 | x: startX, 49 | y: startY, 50 | leftToRight: leftToRight, 51 | lastDrop: time.Now(), 52 | }) 53 | } 54 | } 55 | 56 | func (g *Game) updateHelicopters() { 57 | active := make([]*Helicopter, 0, len(g.helicopters)) 58 | for _, h := range g.helicopters { 59 | vx := float32(config.HelicopterSpeed) 60 | if !h.leftToRight { 61 | vx = -vx 62 | } 63 | h.x += vx 64 | timePassed := time.Since(h.lastDrop) 65 | if timePassed > config.HelicopterDropRate*time.Second && 66 | g.canDrop(h.x) && rand.Float32() < config.ParatrooperSpawnChance { 67 | g.spawnParatrooper(h.x, h.y) 68 | h.lastDrop = time.Now() 69 | } 70 | if h.x > -100 && h.x < config.ScreenWidth+100 { 71 | active = append(active, h) 72 | } 73 | } 74 | g.helicopters = active 75 | } 76 | 77 | func (g *Game) canDrop(x float32) bool { 78 | if x < config.ParatrooperWidth/2.0 || 79 | x > config.ScreenWidth-config.ParatrooperWidth/2.0 { 80 | return false 81 | } 82 | baseX := (config.ScreenWidth - config.BaseWidth) / 2.0 83 | pX := x - config.ParatrooperWidth/2.0 84 | if utils.Overlap1D(pX, config.ParatrooperWidth, baseX, config.BaseWidth) { 85 | return false 86 | } 87 | for _, p := range g.paratroopers { 88 | if utils.Overlap1D( 89 | pX-1.0, 90 | config.ParatrooperWidth+2.0, 91 | p.x-config.ParatrooperWidth/2.0-1.0, 92 | config.ParatrooperWidth+2.0, 93 | ) { 94 | return false 95 | } 96 | } 97 | return true 98 | } 99 | -------------------------------------------------------------------------------- /internal/game/images.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "image/color" 5 | "math" 6 | 7 | "github.com/hajimehoshi/ebiten/v2" 8 | "github.com/hajimehoshi/ebiten/v2/vector" 9 | "github.com/ystepanoff/paragopher/internal/config" 10 | ) 11 | 12 | func (g *Game) initTurretImage() { 13 | w := int(config.BaseWidth) 14 | h := int(config.BaseWidth/3.0 + config.BaseHeight + 1.0) 15 | g.turretBaseImage = ebiten.NewImage(w, h) 16 | g.turretBaseImage.Fill(config.TransparentBlack) 17 | vector.DrawFilledRect( 18 | g.turretBaseImage, 19 | 0.0, 20 | float32(h)-config.BaseHeight, 21 | config.BaseWidth, 22 | config.BaseHeight, 23 | config.ColourWhite, 24 | true, 25 | ) 26 | vector.DrawFilledRect( 27 | g.turretBaseImage, 28 | config.BaseWidth/3.0, 29 | 0.0, 30 | config.BaseWidth/3.0, 31 | config.BaseWidth/3.0, 32 | config.ColourPink, 33 | false, 34 | ) 35 | } 36 | 37 | func (g *Game) initBarrelImage() { 38 | w := config.BaseWidth 39 | g.barrelImage = ebiten.NewImage(int(w), int(w)) 40 | g.barrelImage.Fill(config.TransparentBlack) 41 | 42 | rectX := w/2 - w/12 43 | rectY := w / 12 44 | rectW := w / 6 45 | rectH := w / 3 46 | vector.DrawFilledRect( 47 | g.barrelImage, 48 | rectX, 49 | rectY, 50 | rectW, 51 | rectH, 52 | config.ColourTeal, 53 | false, 54 | ) 55 | 56 | circleX := w / 2 57 | circleY := w / 2 58 | pinkCircleRadius := w / 6 59 | tealCircleRaduis := w / 24 60 | vector.DrawFilledCircle( 61 | g.barrelImage, 62 | circleX, 63 | circleY, 64 | pinkCircleRadius, 65 | config.ColourPink, 66 | true, 67 | ) 68 | vector.DrawFilledCircle( 69 | g.barrelImage, 70 | circleX, 71 | circleY, 72 | tealCircleRaduis, 73 | config.ColourTeal, 74 | true, 75 | ) 76 | topCircleX, topCircleY := w/2, w/12 77 | topCircleRadius := w / 12 78 | vector.DrawFilledCircle( 79 | g.barrelImage, 80 | topCircleX, 81 | topCircleY, 82 | topCircleRadius, 83 | config.ColourTeal, 84 | true, 85 | ) 86 | } 87 | 88 | func (g *Game) initBulletImage() { 89 | w := int(2 * config.BulletRadius) 90 | g.bulletImage = ebiten.NewImage(w, w) 91 | vector.DrawFilledCircle( 92 | g.bulletImage, 93 | config.BulletRadius, 94 | config.BulletRadius, 95 | config.BulletRadius, 96 | config.ColourWhite, 97 | true, 98 | ) 99 | } 100 | 101 | func (g *Game) initHelicopterImage() { 102 | w := int(config.HelicopterBodyWidth + config.HelicopterTailWidth) 103 | h := int(config.HelicopterBodyHeight) + 6 104 | g.helicopterImage = ebiten.NewImage(w, h) 105 | tailX := float32(0.0) 106 | tailY := float32(h-config.HelicopterTailHeight) / 2 107 | bodyX := float32(config.HelicopterTailWidth) 108 | bodyY := float32(h-config.HelicopterBodyHeight) / 2 109 | 110 | vector.DrawFilledRect( 111 | g.helicopterImage, 112 | tailX, 113 | tailY, 114 | config.HelicopterTailWidth, 115 | config.HelicopterTailHeight, 116 | config.ColourTeal, 117 | false, 118 | ) 119 | 120 | vector.DrawFilledRect( 121 | g.helicopterImage, 122 | bodyX, 123 | bodyY, 124 | config.HelicopterBodyWidth, 125 | config.HelicopterBodyHeight, 126 | config.ColourTeal, 127 | false, 128 | ) 129 | 130 | bodyCenterX := bodyX + config.HelicopterBodyWidth/2.0 131 | bodyTopY := bodyY 132 | rotorStartX := bodyCenterX - config.HelicopterRotorLen/2.0 133 | rotorStartY := bodyTopY - 2.0 134 | rotorEndX := bodyCenterX + config.HelicopterRotorLen/2.0 135 | rotorEndY := rotorStartY 136 | vector.StrokeLine( 137 | g.helicopterImage, 138 | rotorStartX, 139 | rotorStartY, 140 | rotorEndX, 141 | rotorEndY, 142 | 1.0, 143 | config.ColourMagenta, 144 | false, 145 | ) 146 | } 147 | 148 | // An ugly hack until vector.DrawFilledPath is available in Ebitengine 149 | func DrawFilledSemicircle( 150 | screen *ebiten.Image, 151 | centerX, centerY, radius float32, 152 | startAngle, endAngle float32, 153 | clr color.Color, 154 | ) { 155 | segments := 180 // Number of triangles to approximate the semicircle 156 | angleStep := (endAngle - startAngle) / float32(segments) 157 | 158 | vertices := make([]ebiten.Vertex, (segments+1)*3) 159 | indices := make([]uint16, segments*3) 160 | 161 | for i := 0; i < segments; i++ { 162 | theta1 := float64((startAngle + float32(i)*angleStep) * math.Pi / 180) 163 | theta2 := float64((startAngle + float32(i+1)*angleStep) * math.Pi / 180) 164 | 165 | v0 := ebiten.Vertex{ 166 | DstX: centerX, 167 | DstY: centerY, 168 | SrcX: 0, 169 | SrcY: 0, 170 | ColorR: float32(clr.(color.RGBA).R) / 255, 171 | ColorG: float32(clr.(color.RGBA).G) / 255, 172 | ColorB: float32(clr.(color.RGBA).B) / 255, 173 | ColorA: float32(clr.(color.RGBA).A) / 255, 174 | } 175 | 176 | v1 := ebiten.Vertex{ 177 | DstX: centerX + radius*float32(math.Cos(theta1)), 178 | DstY: centerY + radius*float32(math.Sin(theta1)), 179 | SrcX: 0, 180 | SrcY: 0, 181 | ColorR: float32(clr.(color.RGBA).R) / 255, 182 | ColorG: float32(clr.(color.RGBA).G) / 255, 183 | ColorB: float32(clr.(color.RGBA).B) / 255, 184 | ColorA: float32(clr.(color.RGBA).A) / 255, 185 | } 186 | 187 | v2 := ebiten.Vertex{ 188 | DstX: centerX + radius*float32(math.Cos(theta2)), 189 | DstY: centerY + radius*float32(math.Sin(theta2)), 190 | SrcX: 0, 191 | SrcY: 0, 192 | ColorR: float32(clr.(color.RGBA).R) / 255, 193 | ColorG: float32(clr.(color.RGBA).G) / 255, 194 | ColorB: float32(clr.(color.RGBA).B) / 255, 195 | ColorA: float32(clr.(color.RGBA).A) / 255, 196 | } 197 | 198 | vertices[i*3] = v0 199 | vertices[i*3+1] = v1 200 | vertices[i*3+2] = v2 201 | 202 | indices[i*3] = uint16(i * 3) 203 | indices[i*3+1] = uint16(i*3 + 1) 204 | indices[i*3+2] = uint16(i*3 + 2) 205 | } 206 | 207 | meshImg := ebiten.NewImage(1, 1) 208 | meshImg.Fill(config.ColourWhite) 209 | 210 | screen.DrawTriangles(vertices, indices, meshImg, nil) 211 | } 212 | 213 | func (g *Game) initParatrooperImage() { 214 | w := int(math.Max( 215 | float64(config.ParachuteRadius*2.0), 216 | float64(config.ParatrooperWidth), 217 | )) 218 | h := int(config.ParachuteRadius*2 + config.ParatrooperHeight) 219 | g.paratrooperImage = ebiten.NewImage(w, h) 220 | DrawFilledSemicircle( 221 | g.paratrooperImage, 222 | float32(w)/2.0, 223 | config.ParachuteRadius, 224 | config.ParachuteRadius, 225 | -180.0, 226 | 0.0, 227 | config.ColourTeal, 228 | ) 229 | vector.DrawFilledRect( 230 | g.paratrooperImage, 231 | (float32(w)-config.ParatrooperWidth)/2.0, 232 | config.ParachuteRadius*2.0, 233 | float32(w)-config.ParatrooperWidth, 234 | float32(h), 235 | config.ColourTeal, 236 | false, 237 | ) 238 | vector.StrokeLine( 239 | g.paratrooperImage, 240 | 2.0, 241 | config.ParachuteRadius, 242 | (float32(w)-config.ParatrooperWidth)/2.0+1.0, 243 | config.ParachuteRadius*2.0, 244 | 1.0, 245 | config.ColourTeal, 246 | false, 247 | ) 248 | vector.StrokeLine( 249 | g.paratrooperImage, 250 | float32(w)-2.0, 251 | config.ParachuteRadius, 252 | float32(w)-(float32(w)-config.ParatrooperWidth)/2.0-1.0, 253 | config.ParachuteRadius*2.0, 254 | 1.0, 255 | config.ColourTeal, 256 | false, 257 | ) 258 | } 259 | 260 | func (g *Game) initParatrooperLandedImage() { 261 | g.paratrooperLandedImage = ebiten.NewImage( 262 | int(config.ParatrooperWidth), 263 | int(config.ParatrooperHeight), 264 | ) 265 | g.paratrooperLandedImage.Fill(config.ColourTeal) 266 | } 267 | 268 | func (g *Game) initParatrooperFellImage() { 269 | g.paratrooperFellImage = ebiten.NewImage( 270 | int(config.ParatrooperWidth), 271 | int(config.ParatrooperHeight), 272 | ) 273 | g.paratrooperFellImage.Fill(config.ColourLightGrey) 274 | } 275 | -------------------------------------------------------------------------------- /internal/game/intro.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "bytes" 5 | "image/color" 6 | "log" 7 | "time" 8 | 9 | "github.com/hajimehoshi/ebiten/v2" 10 | "github.com/hajimehoshi/ebiten/v2/examples/resources/fonts" 11 | "github.com/hajimehoshi/ebiten/v2/text/v2" 12 | "github.com/ystepanoff/paragopher/internal/audio" 13 | "github.com/ystepanoff/paragopher/internal/config" 14 | ) 15 | 16 | const ( 17 | introText = "P A R A G O P H E R" 18 | 19 | introSkipText = "ENTER: skip intro" 20 | rotateText = "LEFT (←), RIGHT (→): rotate barrel" 21 | shootText = "SPACE: shoot bullets" 22 | exitText = "ESCAPE: exit the game" 23 | 24 | scaleFactor = 4 25 | ) 26 | 27 | var textFaceSource *text.GoTextFaceSource = nil 28 | 29 | var colourLayers = []color.Color{ 30 | config.ColourDarkGrey, 31 | config.ColourPink, 32 | config.ColourTeal, 33 | } 34 | 35 | func (g *Game) initIntro() { 36 | audio.Play(g.soundProfile.IntroPlayer) 37 | var err error 38 | textFaceSource, err = text.NewGoTextFaceSource( 39 | bytes.NewReader(fonts.PressStart2P_ttf), 40 | ) 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | } 45 | 46 | func (g *Game) drawIntro(screen *ebiten.Image) { 47 | fontFace := &text.GoTextFace{ 48 | Source: textFaceSource, 49 | Size: 32, 50 | } 51 | textW, textH := text.Measure(introText, fontFace, 1.0) 52 | message := introText[:g.introStep] 53 | 54 | for i, colour := range colourLayers { 55 | op := &text.DrawOptions{} 56 | op.GeoM.Translate( 57 | (config.ScreenWidth-textW)/2.0+float64((i-1)*5), 58 | (config.ScreenHeight-textH)/2.0, 59 | ) 60 | op.ColorScale.ScaleWithColor(colour) 61 | text.Draw(screen, message, fontFace, op) 62 | } 63 | 64 | fontFace.Size = 12 65 | 66 | controlsTextLines := []string{ 67 | introSkipText, 68 | rotateText, 69 | shootText, 70 | exitText, 71 | } 72 | 73 | controlsTextW, controlsTextH := 0.0, 0.0 74 | for _, s := range controlsTextLines { 75 | w, h := text.Measure(s, fontFace, 2.0) 76 | if w > controlsTextW { 77 | controlsTextW = w 78 | } 79 | if h > controlsTextH { 80 | controlsTextH = h 81 | } 82 | } 83 | 84 | for i, s := range controlsTextLines { 85 | op := &text.DrawOptions{} 86 | op.GeoM.Translate( 87 | (config.ScreenWidth-controlsTextW)/2.0, 88 | config.ScreenHeight-controlsTextH*float64( 89 | (len(controlsTextLines)-i)+3.0*i, 90 | ), 91 | ) 92 | op.ColorScale.ScaleWithColor(config.ColourWhite) 93 | text.Draw(screen, s, fontFace, op) 94 | } 95 | 96 | if g.introStep < len(introText) && 97 | time.Since(g.lastIntroStep).Milliseconds() > 300 { 98 | g.introStep++ 99 | g.lastIntroStep = time.Now() 100 | } 101 | 102 | if ebiten.IsKeyPressed(ebiten.KeyEnter) || g.isIntroFinished() { 103 | g.soundProfile.IntroPlayer.Close() 104 | g.showIntro = false 105 | } 106 | } 107 | 108 | func (g *Game) isIntroFinished() bool { 109 | return g.introStep == len(introText) && 110 | time.Since(g.lastIntroStep).Seconds() > 2 111 | } 112 | -------------------------------------------------------------------------------- /internal/game/paratroopers.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "math" 5 | "time" 6 | 7 | "github.com/hajimehoshi/ebiten/v2" 8 | "github.com/ystepanoff/paragopher/internal/audio" 9 | "github.com/ystepanoff/paragopher/internal/config" 10 | "github.com/ystepanoff/paragopher/internal/utils" 11 | ) 12 | 13 | type Paratrooper struct { 14 | x, y float32 15 | parachute bool 16 | landed bool 17 | walking bool 18 | falling bool 19 | fellAt time.Time 20 | over, under *Paratrooper 21 | } 22 | 23 | func (g *Game) drawParatrooper(screen *ebiten.Image, p *Paratrooper) { 24 | image := g.paratrooperImage 25 | if p.landed || !p.parachute { 26 | if p.landed && p.falling { 27 | image = g.paratrooperFellImage 28 | } else { 29 | image = g.paratrooperLandedImage 30 | } 31 | } 32 | op := &ebiten.DrawImageOptions{} 33 | dx := float64(image.Bounds().Dx()) 34 | dy := float64(image.Bounds().Dy()) 35 | op.GeoM.Translate(float64(p.x)-dx/2.0, float64(p.y)-dy/2.0) 36 | screen.DrawImage(image, op) 37 | } 38 | 39 | func (g *Game) drawParatroopers(screen *ebiten.Image) { 40 | for _, p := range g.paratroopers { 41 | if p.landed && p.falling { 42 | g.drawParatrooper(screen, p) 43 | } 44 | } 45 | for _, p := range g.paratroopers { 46 | if !p.landed || !p.falling { 47 | g.drawParatrooper(screen, p) 48 | } 49 | } 50 | } 51 | 52 | func (g *Game) spawnParatrooper(x, y float32) { 53 | g.paratroopers = append(g.paratroopers, &Paratrooper{ 54 | x: x, 55 | y: y, 56 | parachute: true, 57 | }) 58 | } 59 | 60 | func (g *Game) updateParatroopers() { 61 | updated := make([]*Paratrooper, 0, len(g.paratroopers)) 62 | for _, p := range g.paratroopers { 63 | if !p.landed { 64 | p.y += config.ParatrooperFallSpeed 65 | if p.falling { 66 | p.y += config.ParatrooperFallSpeed 67 | } 68 | dy := float32(g.paratrooperLandedImage.Bounds().Dy()) / 2.0 69 | if p.y >= config.GroundY-dy { 70 | p.y = config.GroundY - dy 71 | p.landed = true 72 | p.walking = true 73 | p.parachute = false 74 | p.fellAt = time.Now() 75 | } 76 | } else { 77 | if p.falling && p.landed { 78 | if time.Since(p.fellAt).Seconds() > 3 { 79 | continue 80 | } 81 | } else { 82 | g.walk(p) 83 | } 84 | } 85 | updated = append(updated, p) 86 | } 87 | g.paratroopers = updated 88 | } 89 | 90 | func (g *Game) walk(p *Paratrooper) { 91 | if g.showGameOverDialog { 92 | return 93 | } 94 | vx := float32(config.ParatrooperWalkSpeed) 95 | baseX := (config.ScreenWidth - config.BaseWidth) / 2 96 | if p.x > float32(config.ScreenWidth)/2.0 { 97 | vx = -vx 98 | } 99 | newX := p.x + vx 100 | if utils.Overlap1D( 101 | newX-config.ParatrooperWidth/2.0, 102 | config.ParatrooperWidth, 103 | baseX, 104 | config.BaseWidth, 105 | ) { 106 | if p.y >= config.ScreenHeight-config.BaseHeight { 107 | p.walking = false 108 | return 109 | } else { 110 | pinkBaseX := (float32(config.ScreenWidth) - config.BaseWidth/3.0) / 2.0 111 | pinkBaseW := config.BaseWidth / 3 112 | if utils.Overlap1D(p.x-config.ParatrooperWidth/2.0, config.ParatrooperWidth, pinkBaseX, pinkBaseW) { 113 | audio.Play(g.soundProfile.GameOverPlayer) 114 | g.showGameOverDialog = true 115 | } 116 | } 117 | } 118 | for _, q := range g.paratroopers { 119 | if (math.Abs(float64(q.x-p.x)) < 1e-6 && 120 | math.Abs(float64(q.y-p.y)) < 1e-6) || 121 | !q.landed || 122 | q.walking { 123 | continue 124 | } 125 | if utils.Overlap1D( 126 | newX-config.ParatrooperWidth/2.0, 127 | config.ParatrooperWidth, 128 | q.x-config.ParatrooperWidth/2.0, 129 | config.ParatrooperWidth, 130 | ) && math.Abs(float64(p.y-q.y)) < 1e-6 { 131 | if q.over == nil { 132 | p.x = q.x 133 | p.y = q.y - config.ParatrooperHeight 134 | q.over = p 135 | if p.under != nil { 136 | p.under.over = nil 137 | } 138 | p.under = q 139 | } else { 140 | p.walking = false 141 | } 142 | return 143 | } 144 | } 145 | p.x = newX 146 | } 147 | -------------------------------------------------------------------------------- /internal/game/turret.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "math" 5 | "time" 6 | 7 | "github.com/hajimehoshi/ebiten/v2" 8 | "github.com/ystepanoff/paragopher/internal/audio" 9 | "github.com/ystepanoff/paragopher/internal/config" 10 | "github.com/ystepanoff/paragopher/internal/utils" 11 | ) 12 | 13 | type Bullet struct { 14 | x, y float32 15 | vx, vy float32 16 | } 17 | 18 | func (g *Game) drawTurret(screen *ebiten.Image) { 19 | op := &ebiten.DrawImageOptions{} 20 | op.GeoM.Translate( 21 | float64(config.ScreenWidth-config.BaseWidth)/2.0, 22 | float64(config.ScreenHeight-g.turretBaseImage.Bounds().Dy()), 23 | ) 24 | screen.DrawImage(g.turretBaseImage, op) 25 | 26 | op = &ebiten.DrawImageOptions{} 27 | centerX := float64(config.ScreenWidth) / 2.0 28 | centerY := float64(config.ScreenHeight) 29 | centerY -= float64(config.BaseHeight) 30 | centerY -= float64(config.BaseWidth) / 3.0 31 | centerY -= float64(config.BaseWidth) / 24.0 32 | barrelW := float64(g.barrelImage.Bounds().Dx()) 33 | barrelH := float64(g.barrelImage.Bounds().Dy()) 34 | op.GeoM.Translate(-barrelW/2.0, -barrelH/2.0) 35 | op.GeoM.Rotate(g.barrelAngle * math.Pi / 180) 36 | op.GeoM.Translate(centerX, centerY) 37 | screen.DrawImage(g.barrelImage, op) 38 | } 39 | 40 | func (g *Game) drawBullets(screen *ebiten.Image) { 41 | for _, b := range g.bullets { 42 | op := &ebiten.DrawImageOptions{} 43 | op.GeoM.Translate( 44 | float64(b.x-config.BulletRadius/2.0), 45 | float64(b.y-config.BulletRadius/2.0), 46 | ) 47 | screen.DrawImage(g.bulletImage, op) 48 | } 49 | } 50 | 51 | func (g *Game) shoot() { 52 | audio.Play(g.soundProfile.ShootPlayer) 53 | 54 | barrelCircleX := float32(config.ScreenWidth) / 2.0 55 | barrelCircleY := float32(config.ScreenHeight) 56 | barrelCircleY -= config.BaseHeight 57 | barrelCircleY -= config.BaseWidth / 3.0 58 | barrelCircleY -= config.BaseWidth / 24.0 59 | 60 | width := config.BaseWidth 61 | localTipX := width / 2 62 | localTipY := width / 12 63 | angleRadians := float64(g.barrelAngle * math.Pi / 180.0) 64 | dx := float64(localTipX - width/2) 65 | dy := float64(localTipY - width/2) 66 | rx := float32(dx*math.Cos(angleRadians) - dy*math.Sin(angleRadians)) 67 | ry := float32(dx*math.Sin(angleRadians) + dy*math.Cos(angleRadians)) 68 | tipX := barrelCircleX + rx 69 | tipY := barrelCircleY + ry 70 | realAngleRadians := (90.0 - g.barrelAngle) * math.Pi / 180.0 71 | vx := float32(config.BulletSpeed * math.Cos(realAngleRadians)) 72 | vy := -float32(config.BulletSpeed * math.Sin(realAngleRadians)) 73 | g.bullets = append(g.bullets, &Bullet{ 74 | x: tipX, 75 | y: tipY, 76 | vx: vx, 77 | vy: vy, 78 | }) 79 | g.Score = max(g.Score-1, 0) 80 | g.lastShot = time.Now() 81 | } 82 | 83 | func (g *Game) updateBullets() { 84 | active := make([]*Bullet, 0, len(g.bullets)) 85 | for _, b := range g.bullets { 86 | b.x += b.vx 87 | b.y += b.vy 88 | if b.x < 0 || b.x > config.ScreenWidth || b.y < 0 || 89 | b.y > config.ScreenHeight { 90 | continue 91 | } 92 | active = append(active, b) 93 | } 94 | g.bullets = active 95 | } 96 | 97 | func (g *Game) checkHits() { 98 | activeBullets := make([]*Bullet, 0, len(g.bullets)) 99 | 100 | bulletLoop: 101 | for _, b := range g.bullets { 102 | for i, h := range g.helicopters { 103 | if utils.Overlap2D( 104 | b.x-config.BulletRadius/2.0, 105 | b.y-config.BulletRadius/2.0, 106 | config.BulletRadius, 107 | config.BulletRadius, 108 | h.x-float32(g.helicopterImage.Bounds().Dx())/2.0, 109 | h.y-float32(g.helicopterImage.Bounds().Dy())/2.0, 110 | float32(g.helicopterImage.Bounds().Dx()), 111 | float32(g.helicopterImage.Bounds().Dy()), 112 | ) { 113 | audio.Play(g.soundProfile.HitPlayer) 114 | g.helicopters = append(g.helicopters[:i], g.helicopters[i+1:]...) 115 | g.Score += 10 116 | continue bulletLoop 117 | } 118 | } 119 | for i, p := range g.paratroopers { 120 | if utils.Overlap2D( 121 | b.x-config.BulletRadius/2.0, 122 | b.y-config.BulletRadius/2.0, 123 | config.BulletRadius, 124 | config.BulletRadius, 125 | p.x-config.ParatrooperWidth/2.0, 126 | p.y, 127 | config.ParatrooperWidth, 128 | config.ParatrooperHeight, 129 | ) { 130 | audio.Play(g.soundProfile.HitPlayer) 131 | g.paratroopers = append(g.paratroopers[:i], g.paratroopers[i+1:]...) 132 | g.Score += 5 133 | if p.falling { 134 | g.Score += 5 135 | } 136 | continue bulletLoop 137 | } 138 | if p.parachute && utils.Overlap2D( 139 | b.x-config.BulletRadius/2.0, 140 | b.y-config.BulletRadius/2.0, 141 | config.BulletRadius, 142 | config.BulletRadius, 143 | p.x-config.ParachuteRadius, 144 | p.y-config.ParachuteRadius*2.0, 145 | config.ParachuteRadius*2.0, 146 | config.ParachuteRadius*2.0, 147 | ) { 148 | p.falling = true 149 | p.parachute = false 150 | continue bulletLoop 151 | } 152 | } 153 | activeBullets = append(activeBullets, b) 154 | } 155 | g.bullets = activeBullets 156 | if g.Score > g.gameData.HiScore { 157 | g.gameData.HiScore = g.Score 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /internal/utils/store.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/gob" 5 | "os" 6 | ) 7 | 8 | const dataFile = ".gamedata" 9 | 10 | type GameData struct { 11 | HiScore int 12 | } 13 | 14 | func LoadData() (*GameData, error) { 15 | gameData := &GameData{} 16 | file, err := os.Open(dataFile) 17 | if err != nil { 18 | if os.IsNotExist(err) { 19 | return gameData, nil 20 | } 21 | return gameData, err 22 | } 23 | defer file.Close() 24 | decoder := gob.NewDecoder(file) 25 | err = decoder.Decode(gameData) 26 | return gameData, err 27 | } 28 | 29 | func SaveData(gameData *GameData) error { 30 | file, err := os.Create(dataFile) 31 | if err != nil { 32 | return err 33 | } 34 | defer file.Close() 35 | encoder := gob.NewEncoder(file) 36 | return encoder.Encode(gameData) 37 | } 38 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func Overlap1D(x1, w1, x2, w2 float32) bool { 4 | if x1+w1 <= x2 || x2+w2 <= x1 { 5 | return false 6 | } 7 | return true 8 | } 9 | 10 | func Overlap2D(x1, y1, w1, h1 float32, x2, y2, w2, h2 float32) bool { 11 | if !Overlap1D(x1, w1, x2, w2) || !Overlap1D(y1, h1, y2, h2) { 12 | return false 13 | } 14 | return true 15 | } 16 | -------------------------------------------------------------------------------- /resources/audio/gameover.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ystepanoff/ParaGopher/a5ba1470248894e040170adcedb6632cb8510fd1/resources/audio/gameover.ogg -------------------------------------------------------------------------------- /resources/audio/hit.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ystepanoff/ParaGopher/a5ba1470248894e040170adcedb6632cb8510fd1/resources/audio/hit.ogg -------------------------------------------------------------------------------- /resources/audio/intro.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ystepanoff/ParaGopher/a5ba1470248894e040170adcedb6632cb8510fd1/resources/audio/intro.ogg -------------------------------------------------------------------------------- /resources/audio/shoot.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ystepanoff/ParaGopher/a5ba1470248894e040170adcedb6632cb8510fd1/resources/audio/shoot.ogg -------------------------------------------------------------------------------- /resources/embed.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import _ "embed" 4 | 5 | //go:embed audio/intro.ogg 6 | var IntroSoundBytes []byte 7 | 8 | //go:embed audio/shoot.ogg 9 | var ShootSoundBytes []byte 10 | 11 | //go:embed audio/hit.ogg 12 | var HitSoundBytes []byte 13 | 14 | //go:embed audio/gameover.ogg 15 | var GameOverSoundBytes []byte 16 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ystepanoff/ParaGopher/a5ba1470248894e040170adcedb6632cb8510fd1/screenshot.png --------------------------------------------------------------------------------