| 1 | // vtest build: !openbsd |
| 2 | module main |
| 3 | |
| 4 | import gg |
| 5 | import rand |
| 6 | import sokol.audio |
| 7 | import math |
| 8 | |
| 9 | const paddle_speed_base = f32(400.0) |
| 10 | const ball_speed_base = f32(300.0) |
| 11 | |
| 12 | // Colors |
| 13 | const color_bg = gg.Color{10, 15, 30, 255} |
| 14 | const color_foreground = gg.Color{230, 230, 230, 255} |
| 15 | const color_accent = gg.Color{0, 255, 100, 255} |
| 16 | const color_secondary = gg.Color{0, 200, 80, 255} |
| 17 | const color_text_dim = gg.Color{150, 150, 150, 200} |
| 18 | const color_line = gg.Color{100, 100, 100, 150} |
| 19 | const color_p1 = gg.Color{255, 50, 50, 255} |
| 20 | const color_p2 = gg.Color{50, 100, 255, 255} |
| 21 | |
| 22 | struct SoundManager { |
| 23 | mut: |
| 24 | ping []f32 |
| 25 | pong []f32 |
| 26 | buzz []f32 |
| 27 | } |
| 28 | |
| 29 | fn (mut sm SoundManager) init() { |
| 30 | sample_rate := f32(audio.sample_rate()) |
| 31 | |
| 32 | // Ping (Wall) |
| 33 | ping_duration := f32(0.1) |
| 34 | ping_frames := int(sample_rate * ping_duration) |
| 35 | for i in 0 .. ping_frames { |
| 36 | t := f32(i) / sample_rate |
| 37 | // Use a steeper exponential-like decay for a cleaner "pluck" |
| 38 | env := f32(math.pow(f32(1.0) - t / ping_duration, 3)) |
| 39 | sm.ping << f32(0.3) * math.sinf(t * 800.0 * 2 * math.pi) * env |
| 40 | } |
| 41 | // Absolute silence buffer to prevent clicks |
| 42 | for _ in 0 .. 100 { |
| 43 | sm.ping << 0 |
| 44 | } |
| 45 | |
| 46 | // Pong (Paddle) |
| 47 | pong_duration := f32(0.1) |
| 48 | pong_frames := int(sample_rate * pong_duration) |
| 49 | for i in 0 .. pong_frames { |
| 50 | t := f32(i) / sample_rate |
| 51 | env := f32(math.pow(f32(1.0) - t / pong_duration, 3)) |
| 52 | sm.pong << f32(0.3) * math.sinf(t * 400.0 * 2 * math.pi) * env |
| 53 | } |
| 54 | for _ in 0 .. 100 { |
| 55 | sm.pong << 0 |
| 56 | } |
| 57 | |
| 58 | // Buzz (Reset) |
| 59 | buzz_duration := f32(0.3) |
| 60 | buzz_frames := int(sample_rate * buzz_duration) |
| 61 | for i in 0 .. buzz_frames { |
| 62 | t := f32(i) / sample_rate |
| 63 | env := f32(math.pow(f32(1.0) - t / buzz_duration, 2)) |
| 64 | // Add a harmonic for a more "buzzy" feel |
| 65 | val := f32(0.25) * math.sinf(t * 100.0 * 2 * math.pi) + |
| 66 | f32(0.1) * math.sinf(t * 200.0 * 2 * math.pi) |
| 67 | sm.buzz << val * env |
| 68 | } |
| 69 | for _ in 0 .. 200 { |
| 70 | sm.buzz << 0 |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | struct App { |
| 75 | mut: |
| 76 | ctx &gg.Context = unsafe { nil } |
| 77 | width int = 800 |
| 78 | height int = 600 |
| 79 | paddle_width f32 |
| 80 | paddle_height f32 |
| 81 | ball_size f32 |
| 82 | p1_y f32 |
| 83 | p2_y f32 |
| 84 | ball_x f32 |
| 85 | ball_y f32 |
| 86 | ball_vx f32 |
| 87 | ball_vy f32 |
| 88 | p1_score int |
| 89 | p2_score int |
| 90 | paused bool |
| 91 | sounds SoundManager |
| 92 | } |
| 93 | |
| 94 | fn (mut app App) update_sizes() { |
| 95 | size := app.ctx.window_size() |
| 96 | app.width = size.width |
| 97 | app.height = size.height |
| 98 | |
| 99 | // Base scales on 800x600 |
| 100 | scale_x := f32(app.width) / 800.0 |
| 101 | scale_y := f32(app.height) / 600.0 |
| 102 | |
| 103 | app.paddle_width = 15.0 * scale_x |
| 104 | app.paddle_height = 80.0 * scale_y |
| 105 | app.ball_size = 15.0 * scale_x // Keep it circular based on width scale |
| 106 | } |
| 107 | |
| 108 | fn (mut app App) reset_ball() { |
| 109 | app.ball_x = f32(app.width) / 2 - app.ball_size / 2 |
| 110 | app.ball_y = f32(app.height) / 2 - app.ball_size / 2 |
| 111 | |
| 112 | scale_x := f32(app.width) / 800.0 |
| 113 | mut vx := ball_speed_base * scale_x |
| 114 | if rand.intn(2) or { 0 } == 0 { |
| 115 | vx = -vx |
| 116 | } |
| 117 | app.ball_vx = vx |
| 118 | app.ball_vy = (f32(rand.intn(301) or { 150 } - 150)) * (f32(app.height) / 600.0) |
| 119 | } |
| 120 | |
| 121 | fn (mut app App) reset_game() { |
| 122 | app.p1_score = 0 |
| 123 | app.p2_score = 0 |
| 124 | app.p1_y = f32(app.height) / 2 - app.paddle_height / 2 |
| 125 | app.p2_y = f32(app.height) / 2 - app.paddle_height / 2 |
| 126 | app.reset_ball() |
| 127 | app.paused = false |
| 128 | } |
| 129 | |
| 130 | fn on_frame(data voidptr) { |
| 131 | mut app := unsafe { &App(data) } |
| 132 | app.ctx.begin() |
| 133 | |
| 134 | // Draw dashed center line |
| 135 | line_segments := 20 |
| 136 | segment_height := f32(app.height) / line_segments |
| 137 | for i in 0 .. line_segments { |
| 138 | if i % 2 == 0 { |
| 139 | app.ctx.draw_rect_filled(f32(app.width) / 2 - 1, i * segment_height, 2, segment_height, |
| 140 | color_line) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // Draw paddles |
| 145 | app.ctx.draw_rounded_rect_filled(20, app.p1_y, app.paddle_width, app.paddle_height, 5, color_p1) |
| 146 | app.ctx.draw_rounded_rect_filled(f32(app.width) - 20 - app.paddle_width, app.p2_y, |
| 147 | app.paddle_width, app.paddle_height, 5, color_p2) |
| 148 | |
| 149 | // Draw ball |
| 150 | app.ctx.draw_circle_filled(app.ball_x + app.ball_size / 2, app.ball_y + app.ball_size / 2, |
| 151 | app.ball_size / 2, color_foreground) |
| 152 | |
| 153 | // Draw ball speed (10m = 800px => 1m = 80px) |
| 154 | // We scale the meter definition with width |
| 155 | meter_px := 80.0 * (f32(app.width) / 800.0) |
| 156 | speed_px := math.sqrt(app.ball_vx * app.ball_vx + app.ball_vy * app.ball_vy) |
| 157 | speed_ms := speed_px / meter_px |
| 158 | app.ctx.draw_text(app.width / 2, 20, 'Ball: ${speed_ms:.1f} m/s', |
| 159 | size: 20 |
| 160 | color: color_foreground |
| 161 | align: .center |
| 162 | ) |
| 163 | |
| 164 | // Draw scores and positions |
| 165 | // Moved up to be less obtrusive |
| 166 | app.ctx.draw_text(app.width / 4, 30, app.p1_score.str(), |
| 167 | size: 40 |
| 168 | color: color_accent |
| 169 | align: .center |
| 170 | ) |
| 171 | app.ctx.draw_text(app.width / 4, 80, 'Y: ${int(app.p1_y)}', |
| 172 | size: 16 |
| 173 | color: color_secondary |
| 174 | align: .center |
| 175 | ) |
| 176 | |
| 177 | app.ctx.draw_text(3 * app.width / 4, 30, app.p2_score.str(), |
| 178 | size: 40 |
| 179 | color: color_accent |
| 180 | align: .center |
| 181 | ) |
| 182 | app.ctx.draw_text(3 * app.width / 4, 80, 'Y: ${int(app.p2_y)}', |
| 183 | size: 16 |
| 184 | color: color_secondary |
| 185 | align: .center |
| 186 | ) |
| 187 | |
| 188 | if app.paused { |
| 189 | app.ctx.draw_text(app.width / 2, app.height / 2 - 50, 'PAUSED', |
| 190 | size: 64 |
| 191 | color: color_accent |
| 192 | align: .center |
| 193 | ) |
| 194 | app.ctx.draw_text(app.width / 2, app.height / 2 + 10, 'Press SPACE to Resume', |
| 195 | size: 20 |
| 196 | color: color_accent |
| 197 | align: .center |
| 198 | ) |
| 199 | } else { |
| 200 | app.ctx.draw_text(app.width / 2, app.height - 25, |
| 201 | 'SPACE: Pause | W/S: P1 | UP/DOWN: P2 | R: Reset', |
| 202 | size: 16 |
| 203 | color: color_text_dim |
| 204 | align: .center |
| 205 | ) |
| 206 | } |
| 207 | |
| 208 | app.ctx.end() |
| 209 | } |
| 210 | |
| 211 | fn on_event(e &gg.Event, data voidptr) { |
| 212 | mut app := unsafe { &App(data) } |
| 213 | if e.typ == .resized || e.typ == .restored { |
| 214 | app.update_sizes() |
| 215 | } |
| 216 | if e.typ == .key_down { |
| 217 | match e.key_code { |
| 218 | .space { app.paused = !app.paused } |
| 219 | .r { app.reset_game() } |
| 220 | else {} |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | fn on_update(dt f32, data voidptr) { |
| 226 | mut app := unsafe { &App(data) } |
| 227 | |
| 228 | if app.paused { |
| 229 | return |
| 230 | } |
| 231 | |
| 232 | scale_y := f32(app.height) / 600.0 |
| 233 | paddle_speed := paddle_speed_base * scale_y |
| 234 | |
| 235 | // Paddle 1 movement (W/S) |
| 236 | if app.ctx.pressed_keys[gg.KeyCode.w] { |
| 237 | app.p1_y -= paddle_speed * dt |
| 238 | } |
| 239 | if app.ctx.pressed_keys[gg.KeyCode.s] { |
| 240 | app.p1_y += paddle_speed * dt |
| 241 | } |
| 242 | |
| 243 | // Paddle 2 movement (Up/Down) |
| 244 | if app.ctx.pressed_keys[gg.KeyCode.up] { |
| 245 | app.p2_y -= paddle_speed * dt |
| 246 | } |
| 247 | if app.ctx.pressed_keys[gg.KeyCode.down] { |
| 248 | app.p2_y += paddle_speed * dt |
| 249 | } |
| 250 | |
| 251 | // Constrain paddles |
| 252 | if app.p1_y < 0 { |
| 253 | app.p1_y = 0 |
| 254 | } |
| 255 | if app.p1_y > f32(app.height) - app.paddle_height { |
| 256 | app.p1_y = f32(app.height) - app.paddle_height |
| 257 | } |
| 258 | if app.p2_y < 0 { |
| 259 | app.p2_y = 0 |
| 260 | } |
| 261 | if app.p2_y > f32(app.height) - app.paddle_height { |
| 262 | app.p2_y = f32(app.height) - app.paddle_height |
| 263 | } |
| 264 | |
| 265 | // Ball movement |
| 266 | app.ball_x += app.ball_vx * dt |
| 267 | app.ball_y += app.ball_vy * dt |
| 268 | |
| 269 | // Ball wall collision (Top/Bottom) |
| 270 | if app.ball_y <= 0 { |
| 271 | app.ball_y = 0 |
| 272 | app.ball_vy = -app.ball_vy |
| 273 | audio.push(app.sounds.ping.data, app.sounds.ping.len) |
| 274 | } else if app.ball_y >= f32(app.height) - app.ball_size { |
| 275 | app.ball_y = f32(app.height) - app.ball_size |
| 276 | app.ball_vy = -app.ball_vy |
| 277 | audio.push(app.sounds.ping.data, app.sounds.ping.len) |
| 278 | } |
| 279 | |
| 280 | // Ball paddle collision |
| 281 | // P1 |
| 282 | if app.ball_vx < 0 && app.ball_x <= 20 + app.paddle_width && app.ball_x >= 20 { |
| 283 | if app.ball_y + app.ball_size >= app.p1_y && app.ball_y <= app.p1_y + app.paddle_height { |
| 284 | app.ball_x = 20 + app.paddle_width |
| 285 | app.ball_vx = -app.ball_vx * 1.05 // Slightly speed up |
| 286 | // Add some vertical velocity based on where it hit the paddle |
| 287 | hit_pos := (app.ball_y + app.ball_size / 2) - (app.p1_y + app.paddle_height / 2) |
| 288 | app.ball_vy += hit_pos * 5 |
| 289 | audio.push(app.sounds.pong.data, app.sounds.pong.len) |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | // P2 |
| 294 | if app.ball_vx > 0 && app.ball_x + app.ball_size >= f32(app.width) - 20 - app.paddle_width |
| 295 | && app.ball_x + app.ball_size <= f32(app.width) - 20 { |
| 296 | if app.ball_y + app.ball_size >= app.p2_y && app.ball_y <= app.p2_y + app.paddle_height { |
| 297 | app.ball_x = f32(app.width) - 20 - app.paddle_width - app.ball_size |
| 298 | app.ball_vx = -app.ball_vx * 1.05 // Slightly speed up |
| 299 | // Add some vertical velocity based on where it hit the paddle |
| 300 | hit_pos := (app.ball_y + app.ball_size / 2) - (app.p2_y + app.paddle_height / 2) |
| 301 | app.ball_vy += hit_pos * 5 |
| 302 | audio.push(app.sounds.pong.data, app.sounds.pong.len) |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | // Score |
| 307 | if app.ball_x < 0 { |
| 308 | app.p2_score++ |
| 309 | app.reset_ball() |
| 310 | audio.push(app.sounds.buzz.data, app.sounds.buzz.len) |
| 311 | } else if app.ball_x > f32(app.width) { |
| 312 | app.p1_score++ |
| 313 | app.reset_ball() |
| 314 | audio.push(app.sounds.buzz.data, app.sounds.buzz.len) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | fn main() { |
| 319 | mut app := &App{} |
| 320 | app.width = 800 |
| 321 | app.height = 600 |
| 322 | app.paddle_width = 15.0 |
| 323 | app.paddle_height = 80.0 |
| 324 | app.ball_size = 15.0 |
| 325 | app.p1_y = f32(app.height) / 2 - app.paddle_height / 2 |
| 326 | app.p2_y = f32(app.height) / 2 - app.paddle_height / 2 |
| 327 | app.reset_ball() |
| 328 | |
| 329 | audio.setup(buffer_frames: 512) |
| 330 | app.sounds.init() |
| 331 | |
| 332 | app.ctx = gg.new_context( |
| 333 | width: app.width |
| 334 | height: app.height |
| 335 | window_title: 'V Pong' |
| 336 | user_data: app |
| 337 | frame_fn: on_frame |
| 338 | event_fn: on_event |
| 339 | update_fn: on_update |
| 340 | bg_color: color_bg |
| 341 | resizable: true |
| 342 | create_window: true |
| 343 | ) |
| 344 | |
| 345 | app.ctx.run() |
| 346 | } |
| 347 | |