| 1 | // Copyright (c) 2025 Delyan Angelov. All rights reserved. |
| 2 | // The use of this source code is governed by an MIT license that |
| 3 | // can be found in the LICENSE file. |
| 4 | module main |
| 5 | |
| 6 | import gg |
| 7 | import math |
| 8 | import rand |
| 9 | import os.asset |
| 10 | import math.vec |
| 11 | |
| 12 | type V2 = vec.Vec2[f32] |
| 13 | |
| 14 | fn rads(degrees f32) f32 { |
| 15 | return f32(math.radians(degrees)) |
| 16 | } |
| 17 | |
| 18 | fn (a V2) offset(angle f32, scale f32) V2 { |
| 19 | return a + V2{math.cosf(angle) * scale, math.sinf(angle) * scale} |
| 20 | } |
| 21 | |
| 22 | fn (mut a V2) wrap(b V2) { |
| 23 | a.x = f32(math.mod(a.x + b.x, b.x)) |
| 24 | a.y = f32(math.mod(a.y + b.y, b.y)) |
| 25 | } |
| 26 | |
| 27 | fn V2.random(b V2) V2 { |
| 28 | return V2{rand.f32() * b.x, rand.f32() * b.y} |
| 29 | } |
| 30 | |
| 31 | struct Body { |
| 32 | mut: |
| 33 | pos V2 |
| 34 | vel V2 |
| 35 | rotation f32 |
| 36 | radius f32 |
| 37 | active bool = true |
| 38 | } |
| 39 | |
| 40 | struct Bullet { |
| 41 | Body |
| 42 | } |
| 43 | |
| 44 | struct Asteroid { |
| 45 | Body |
| 46 | mut: |
| 47 | segments int |
| 48 | offsets []f32 |
| 49 | points []f32 |
| 50 | rotation_step f32 = 1.0 |
| 51 | } |
| 52 | |
| 53 | struct Player { |
| 54 | Body |
| 55 | mut: |
| 56 | is_engine_on bool |
| 57 | bullets int = 99 |
| 58 | bullets_limit int = 99 |
| 59 | fuel int = 9999 |
| 60 | fuel_limit int = 9999 |
| 61 | cooldown int |
| 62 | cooldown_frames int = 15 |
| 63 | points []f32 = []f32{len: 8} |
| 64 | } |
| 65 | |
| 66 | struct Game { |
| 67 | mut: |
| 68 | gg &gg.Context = unsafe { nil } |
| 69 | screen V2 = V2{800, 600} |
| 70 | player Player |
| 71 | bullets []Bullet |
| 72 | asteroids []Asteroid |
| 73 | score int |
| 74 | highscore int |
| 75 | ships int = 5 |
| 76 | level int = 1 |
| 77 | is_up bool |
| 78 | |
| 79 | msg Message |
| 80 | } |
| 81 | |
| 82 | @[params] |
| 83 | struct Message { |
| 84 | mut: |
| 85 | frames int |
| 86 | text string |
| 87 | size int = 40 |
| 88 | color gg.Color |
| 89 | align gg.HorizontalAlign = .center |
| 90 | } |
| 91 | |
| 92 | fn (mut a Asteroid) setup() { |
| 93 | a.segments = int(a.radius / 10) * 10 |
| 94 | a.offsets = []f32{len: a.segments} |
| 95 | a.points = []f32{len: 2 * a.segments} |
| 96 | for i in 0 .. a.segments { |
| 97 | a.offsets[i] = a.radius + 25 * (0.5 - rand.f32()) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | fn (mut p Player) reset(screen V2) { |
| 102 | p.bullets, p.fuel = p.bullets_limit, p.fuel_limit |
| 103 | p.pos, p.vel = screen.div_scalar(2), V2{0, 0} |
| 104 | p.radius, p.rotation = 15, -90 |
| 105 | p.active = true |
| 106 | } |
| 107 | |
| 108 | fn (mut game Game) handle_input() { |
| 109 | mut p := &game.player |
| 110 | p.is_engine_on = false |
| 111 | if game.gg.is_key_down(.escape) { |
| 112 | exit(0) |
| 113 | } |
| 114 | if !p.active { |
| 115 | return |
| 116 | } |
| 117 | game.is_up = game.gg.is_key_down(.up) || game.gg.is_key_down(.w) |
| 118 | is_fire := game.gg.is_key_down(.space) |
| 119 | is_left := game.gg.is_key_down(.left) || game.gg.is_key_down(.a) |
| 120 | is_right := game.gg.is_key_down(.right) || game.gg.is_key_down(.d) |
| 121 | if is_fire && p.cooldown <= 0 && p.active { |
| 122 | game.add_bullet() |
| 123 | p.cooldown = p.cooldown_frames |
| 124 | } |
| 125 | if p.fuel >= 10 { |
| 126 | if game.is_up && p.active { |
| 127 | angle := rads(p.rotation) |
| 128 | p.vel += V2{0, 0}.offset(angle, 0.1) |
| 129 | p.fuel -= 10 |
| 130 | p.is_engine_on = true |
| 131 | } |
| 132 | } |
| 133 | if p.fuel >= 1 { |
| 134 | if is_left { |
| 135 | p.rotation -= 2 |
| 136 | p.fuel-- |
| 137 | } |
| 138 | if is_right { |
| 139 | p.rotation += 2 |
| 140 | p.fuel-- |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | fn (mut game Game) update() { |
| 146 | game.msg.frames = int_max(0, game.msg.frames - 1) |
| 147 | mut p := &game.player |
| 148 | p.cooldown = int_max(0, p.cooldown - 1) |
| 149 | p.pos += p.vel |
| 150 | p.pos.wrap(game.screen) |
| 151 | for mut b in game.bullets { |
| 152 | if !b.active { |
| 153 | continue |
| 154 | } |
| 155 | b.pos += b.vel |
| 156 | if b.pos.x < 0 || b.pos.x > game.screen.x || b.pos.y < 0 || b.pos.y > game.screen.y { |
| 157 | b.active = false |
| 158 | } |
| 159 | } |
| 160 | for mut a in game.asteroids { |
| 161 | if !a.active { |
| 162 | continue |
| 163 | } |
| 164 | a.pos += a.vel |
| 165 | a.pos.wrap(game.screen) |
| 166 | a.rotation += a.rotation_step |
| 167 | if p.active && p.pos.distance(a.pos) <= (p.radius + a.radius) { |
| 168 | // player/asteroids collision |
| 169 | p.active = false |
| 170 | a.active = false |
| 171 | game.split_asteroid(a, p.vel.mul_scalar(0.5)) |
| 172 | game.player.reset(game.screen) |
| 173 | game.score += 50 |
| 174 | game.show_message(text: 'Your ship was destroyed.', color: gg.red, frames: 90) |
| 175 | game.ships-- |
| 176 | if game.ships <= 0 { |
| 177 | game.ships = 5 |
| 178 | game.score = 0 |
| 179 | game.asteroids = [] |
| 180 | game.add_asteroids(10) |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | for mut b in game.bullets { |
| 185 | if !b.active { |
| 186 | continue |
| 187 | } |
| 188 | for mut a in game.asteroids { |
| 189 | if !a.active { |
| 190 | continue |
| 191 | } |
| 192 | if b.active && b.pos.distance(a.pos) <= (b.radius + a.radius) { |
| 193 | // bullet/asteroid collision |
| 194 | b.active = false |
| 195 | a.active = false |
| 196 | game.score += 100 |
| 197 | game.split_asteroid(a, b.vel.mul_scalar(0.2)) |
| 198 | } |
| 199 | } |
| 200 | } |
| 201 | if game.bullets.any(!it.active) { |
| 202 | game.bullets = game.bullets.filter(it.active) |
| 203 | } |
| 204 | if game.asteroids.any(!it.active) { |
| 205 | game.asteroids = game.asteroids.filter(it.active) |
| 206 | } |
| 207 | if game.asteroids.len == 0 { |
| 208 | game.level++ |
| 209 | game.ships++ |
| 210 | game.player.reset(game.screen) |
| 211 | game.add_asteroids(10) |
| 212 | game.show_message(text: 'YOU WIN', color: gg.green, frames: 90) |
| 213 | } |
| 214 | game.highscore = int_max(game.score, game.highscore) |
| 215 | } |
| 216 | |
| 217 | fn (mut game Game) show_message(params Message) { |
| 218 | game.msg = params |
| 219 | } |
| 220 | |
| 221 | fn (mut game Game) split_asteroid(a &Asteroid, vel V2) { |
| 222 | if a.radius < 30 { |
| 223 | return |
| 224 | } |
| 225 | shrink_factor := 0.5 + 0.3 * rand.f32() |
| 226 | mut a1 := Asteroid{ |
| 227 | ...*a |
| 228 | active: true |
| 229 | radius: a.radius * shrink_factor |
| 230 | rotation_step: -a.rotation_step |
| 231 | } |
| 232 | mut a2 := Asteroid{ |
| 233 | ...*a |
| 234 | active: true |
| 235 | radius: a.radius * (1 - shrink_factor) |
| 236 | rotation_step: 2 * a.rotation_step |
| 237 | } |
| 238 | a1.vel = a1.vel.mul_scalar(shrink_factor) * vel |
| 239 | a2.vel = a2.vel.mul_scalar(1 - shrink_factor) * (V2{0, 0} - vel) |
| 240 | a1.setup() |
| 241 | a2.setup() |
| 242 | game.asteroids << a1 |
| 243 | game.asteroids << a2 |
| 244 | } |
| 245 | |
| 246 | fn (mut game Game) draw() { |
| 247 | ws := gg.window_size() |
| 248 | game.screen = V2{ws.width, ws.height} |
| 249 | game.gg.draw_rect_filled(0, 0, game.screen.x, game.screen.y, gg.rgba(20, 20, 20, 255)) |
| 250 | game.draw_ship() |
| 251 | for b in game.bullets { |
| 252 | game.gg.draw_circle_filled(b.pos.x, b.pos.y, 3, gg.yellow) |
| 253 | } |
| 254 | for mut a in game.asteroids { |
| 255 | game.draw_asteroid(mut a) |
| 256 | } |
| 257 | scenter := game.screen.div_scalar(2) |
| 258 | label1 := 'Level: ${game.level} Ships: ${game.ships}' |
| 259 | game.gg.draw_text(5, 10, label1, size: 20, color: gg.white, align: .left) |
| 260 | label2 := 'B: ${game.player.bullets:02} F: ${game.player.fuel:04}' |
| 261 | game.gg.draw_text(int(scenter.x), 10, label2, size: 20, color: gg.green, align: .center) |
| 262 | label3 := 'Score: ${game.score} Highscore: ${game.highscore}' |
| 263 | game.gg.draw_text(int(game.screen.x) - 5, 10, label3, size: 20, color: gg.white, align: .right) |
| 264 | if game.msg.frames > 0 { |
| 265 | game.gg.draw_text(int(scenter.x), int(scenter.y / 2), game.msg.text, |
| 266 | size: game.msg.size |
| 267 | color: game.msg.color |
| 268 | align: game.msg.align |
| 269 | ) |
| 270 | } |
| 271 | label4 := 'Use arrows + space to control your ship. Use Escape to end the game.' |
| 272 | game.gg.draw_text(int(game.screen.x - 5), int(game.screen.y - 20), label4, |
| 273 | size: 16 |
| 274 | color: gg.gray |
| 275 | align: .right |
| 276 | ) |
| 277 | } |
| 278 | |
| 279 | fn (game &Game) draw_ship() { |
| 280 | mut p := &game.player |
| 281 | if !p.active { |
| 282 | return |
| 283 | } |
| 284 | angle := rads(p.rotation) |
| 285 | p1 := p.pos.offset(angle, p.radius) |
| 286 | p2 := p.pos.offset(angle + 2.5, 0.6 * p.radius) |
| 287 | p3 := p.pos.offset(angle, -0.3 * p.radius) |
| 288 | p4 := p.pos.offset(angle - 2.5, 0.6 * p.radius) |
| 289 | p.points[0] = p1.x |
| 290 | p.points[1] = p1.y |
| 291 | p.points[2] = p2.x |
| 292 | p.points[3] = p2.y |
| 293 | p.points[4] = p3.x |
| 294 | p.points[5] = p3.y |
| 295 | p.points[6] = p4.x |
| 296 | p.points[7] = p4.y |
| 297 | game.gg.draw_convex_poly(p.points, gg.white) |
| 298 | if p.is_engine_on { |
| 299 | engine := p.pos.offset(angle + math.pi, 0.7 * p.radius) |
| 300 | game.gg.draw_circle_filled(engine.x, engine.y, 6, gg.yellow) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | fn (mut game Game) draw_asteroid(mut a Asteroid) { |
| 305 | if !a.active { |
| 306 | return |
| 307 | } |
| 308 | game.gg.draw_circle_filled(a.pos.x, a.pos.y, a.radius, gg.rgba(235, 235, 255, 215)) |
| 309 | for i in 0 .. a.segments { |
| 310 | angle := rads(a.rotation + f32(i) * 360 / a.segments) |
| 311 | p := a.pos.offset(angle, a.offsets[i]) |
| 312 | a.points[i * 2], a.points[i * 2 + 1] = p.x, p.y |
| 313 | } |
| 314 | game.gg.draw_convex_poly(a.points, gg.rgba(155, 155, 148, 245)) |
| 315 | } |
| 316 | |
| 317 | fn (mut game Game) add_bullet() { |
| 318 | if game.player.bullets <= 0 { |
| 319 | return |
| 320 | } |
| 321 | game.player.bullets-- |
| 322 | angle := rads(game.player.rotation) |
| 323 | game.bullets << Bullet{ |
| 324 | pos: game.player.pos |
| 325 | radius: 3 |
| 326 | vel: game.player.vel.offset(angle, 10) |
| 327 | active: true |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | fn (mut game Game) add_asteroids(count int) { |
| 332 | for _ in 0 .. count { |
| 333 | mut npos := V2{} |
| 334 | new_asteroid_loop: for { |
| 335 | npos = V2.random(game.screen) |
| 336 | for a in game.asteroids { |
| 337 | if a.pos.distance(npos) < (a.radius + 30) { |
| 338 | continue new_asteroid_loop |
| 339 | } |
| 340 | } |
| 341 | if game.player.pos.distance(npos) < 5 * (game.player.radius + 30) { |
| 342 | continue new_asteroid_loop |
| 343 | } |
| 344 | break |
| 345 | } |
| 346 | radius := 50 + 50 * (0.5 - rand.f32()) |
| 347 | mut asteroid := Asteroid{ |
| 348 | pos: npos |
| 349 | vel: (V2.random(game.screen) - V2.random(game.screen)) / game.screen |
| 350 | radius: radius |
| 351 | rotation: 360 * rand.f32() |
| 352 | rotation_step: 2 * (0.5 - rand.f32()) |
| 353 | active: true |
| 354 | } |
| 355 | asteroid.setup() |
| 356 | game.asteroids << asteroid |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | fn on_frame(mut game Game) { |
| 361 | if game.gg.timer.elapsed().milliseconds() > 15 { |
| 362 | game.gg.timer.restart() |
| 363 | game.handle_input() |
| 364 | game.update() |
| 365 | } |
| 366 | game.gg.begin() |
| 367 | game.draw() |
| 368 | game.gg.end() |
| 369 | } |
| 370 | |
| 371 | fn main() { |
| 372 | mut game := &Game{} |
| 373 | mut fpath := asset.get_path('../assets', 'fonts/RobotoMono-Regular.ttf') |
| 374 | game.player.reset(game.screen) |
| 375 | game.add_asteroids(10) |
| 376 | game.gg = gg.new_context( |
| 377 | window_title: 'V Asteroids' |
| 378 | width: int(game.screen.x) |
| 379 | height: int(game.screen.y) |
| 380 | frame_fn: on_frame |
| 381 | user_data: game |
| 382 | sample_count: 2 |
| 383 | font_path: fpath |
| 384 | ) |
| 385 | game.gg.run() |
| 386 | } |
| 387 | |