From 935998abb4800f3a6220a25dbe42ab8a9f8faf9f Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Thu, 4 Dec 2025 18:38:57 -0500 Subject: [PATCH] Add entity and UI rendering --- TODO.md | 16 +-- shaders/blur.fs | 32 ++++++ src/main.zig | 276 +++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 shaders/blur.fs diff --git a/TODO.md b/TODO.md index c8cd9d3..afa467b 100644 --- a/TODO.md +++ b/TODO.md @@ -21,14 +21,14 @@ ## phase 3: rendering -- [ ] draw terrain as connected line segments -- [ ] draw players as geometric shapes -- [ ] draw cannon angle indicator -- [ ] draw power meter -- [ ] draw projectile with trail (last N positions) -- [ ] implement bloom shader (blur.fs) -- [ ] render-to-texture pipeline for glow effect -- [ ] explosion effect (expanding circle) +- [x] draw terrain as connected line segments +- [x] draw players as geometric shapes +- [x] draw cannon angle indicator +- [x] draw power meter +- [x] draw projectile with trail (last N positions) +- [x] implement bloom shader (blur.fs) +- [x] render-to-texture pipeline for glow effect +- [x] explosion effect (expanding circle) ## phase 4: local two-player diff --git a/shaders/blur.fs b/shaders/blur.fs new file mode 100644 index 0000000..247d04b --- /dev/null +++ b/shaders/blur.fs @@ -0,0 +1,32 @@ +#version 330 + +in vec2 fragTexCoord; +in vec4 fragColor; + +uniform sampler2D texture0; +uniform vec4 colDiffuse; + +// blur direction: (1.0, 0.0) for horizontal, (0.0, 1.0) for vertical +uniform vec2 direction; +uniform vec2 resolution; + +out vec4 finalColor; + +void main() +{ + vec2 texelSize = 1.0 / resolution; + vec4 sum = vec4(0.0); + + // 9-tap gaussian blur + float weights[9] = float[]( + 0.0162, 0.0540, 0.1216, 0.1945, 0.2270, + 0.1945, 0.1216, 0.0540, 0.0162 + ); + + for (int i = -4; i <= 4; i++) { + vec2 offset = direction * float(i) * texelSize * 2.0; + sum += texture(texture0, fragTexCoord + offset) * weights[i + 4]; + } + + finalColor = sum * colDiffuse * fragColor; +} diff --git a/src/main.zig b/src/main.zig index c05119d..1614627 100644 --- a/src/main.zig +++ b/src/main.zig @@ -13,6 +13,66 @@ const Input = game.Input; const SCREEN_WIDTH = terrain_mod.SCREEN_WIDTH; const SCREEN_HEIGHT = terrain_mod.SCREEN_HEIGHT; +// trail ring buffer for projectile +const TRAIL_LENGTH = 20; +var trail_positions: [TRAIL_LENGTH]struct { x: f32, y: f32 } = undefined; +var trail_count: usize = 0; +var trail_head: usize = 0; +var last_proj_exists: bool = false; + +// explosion animation state +const Explosion = struct { + x: f32, + y: f32, + radius: f32, + max_radius: f32, + alpha: u8, +}; +const MAX_EXPLOSIONS = 4; +var explosions: [MAX_EXPLOSIONS]?Explosion = .{ null, null, null, null }; + +fn spawnExplosion(x: f32, y: f32) void { + for (&explosions) |*slot| { + if (slot.* == null) { + slot.* = .{ + .x = x, + .y = y, + .radius = 5, + .max_radius = 40, + .alpha = 255, + }; + return; + } + } +} + +fn updateExplosions() void { + for (&explosions) |*slot| { + if (slot.*) |*exp| { + exp.radius += 2; + if (exp.alpha > 8) { + exp.alpha -= 8; + } else { + slot.* = null; + } + } + } +} + +fn drawExplosions() void { + for (explosions) |maybe_exp| { + if (maybe_exp) |exp| { + const color = rl.Color{ .r = 255, .g = 200, .b = 50, .a = exp.alpha }; + rl.drawCircleLines(@intFromFloat(exp.x), @intFromFloat(exp.y), exp.radius, color); + // inner circle + if (exp.radius > 10) { + const inner_color = rl.Color{ .r = 255, .g = 255, .b = 200, .a = exp.alpha / 2 }; + rl.drawCircleLines(@intFromFloat(exp.x), @intFromFloat(exp.y), exp.radius * 0.6, inner_color); + } + } + } +} + // colors (vector/oscilloscope aesthetic) const BG_COLOR = rl.Color{ .r = 10, .g = 10, .b = 18, .a = 255 }; const CYAN = rl.Color{ .r = 0, .g = 255, .b = 255, .a = 255 }; @@ -25,6 +85,41 @@ pub fn main() !void { defer rl.closeWindow(); rl.setTargetFPS(60); + // load blur shader + const blur_shader = rl.loadShader(null, "shaders/blur.fs") catch |err| { + std.debug.print("warning: could not load blur shader: {}\n", .{err}); + @panic("shader load failed"); + }; + defer rl.unloadShader(blur_shader); + + // get shader uniform locations + const direction_loc = rl.getShaderLocation(blur_shader, "direction"); + const resolution_loc = rl.getShaderLocation(blur_shader, "resolution"); + + // set resolution uniform (doesn't change) + const resolution = [2]f32{ @floatFromInt(SCREEN_WIDTH), @floatFromInt(SCREEN_HEIGHT) }; + rl.setShaderValue(blur_shader, resolution_loc, &resolution, .vec2); + + // create render textures for bloom pipeline + const game_tex = rl.loadRenderTexture(@intCast(SCREEN_WIDTH), @intCast(SCREEN_HEIGHT)) catch |err| { + std.debug.print("warning: could not load render texture: {}\n", .{err}); + @panic("render texture load failed"); + }; + defer rl.unloadRenderTexture(game_tex); + const blur_tex = rl.loadRenderTexture(@intCast(SCREEN_WIDTH), @intCast(SCREEN_HEIGHT)) catch |err| { + std.debug.print("warning: could not load render texture: {}\n", .{err}); + @panic("render texture load failed"); + }; + defer rl.unloadRenderTexture(blur_tex); + + // source rectangle (flip Y for render texture) + const src_rect = rl.Rectangle{ + .x = 0, + .y = 0, + .width = @floatFromInt(SCREEN_WIDTH), + .height = @floatFromInt(-@as(i32, SCREEN_HEIGHT)), + }; + // initialize game const terrain = terrain_mod.generateFixed(); var state = game.initGame(&terrain); @@ -38,13 +133,53 @@ pub fn main() !void { inputs[state.current_turn] = input; game.simulate(&state, inputs); - // render - rl.beginDrawing(); - defer rl.endDrawing(); + const screen_h: i32 = @intCast(SCREEN_HEIGHT); + // update animations + updateTrail(&state, screen_h); + updateExplosions(); + + // 1. draw game to texture + rl.beginTextureMode(game_tex); rl.clearBackground(BG_COLOR); + drawTerrain(state.terrain); + for (0..2) |i| { + drawPlayer(&state.players[i], i, screen_h); + } + drawProjectile(&state, screen_h); + drawExplosions(); + + rl.endTextureMode(); + + // 2. horizontal blur pass + rl.beginTextureMode(blur_tex); + rl.beginShaderMode(blur_shader); + const h_dir = [2]f32{ 1.0, 0.0 }; + rl.setShaderValue(blur_shader, direction_loc, &h_dir, .vec2); + rl.drawTextureRec(game_tex.texture, src_rect, .{ .x = 0, .y = 0 }, rl.Color.white); + rl.endShaderMode(); + rl.endTextureMode(); + + // 3. final composite: original + vertical blur (additive) + rl.beginDrawing(); + + // draw original game + rl.drawTextureRec(game_tex.texture, src_rect, .{ .x = 0, .y = 0 }, rl.Color.white); + + // additive blend the blurred version (vertical blur of horizontal blur) + rl.beginBlendMode(.additive); + rl.beginShaderMode(blur_shader); + const v_dir = [2]f32{ 0.0, 1.0 }; + rl.setShaderValue(blur_shader, direction_loc, &v_dir, .vec2); + rl.drawTextureRec(blur_tex.texture, src_rect, .{ .x = 0, .y = 0 }, rl.Color.white); + rl.endShaderMode(); + rl.endBlendMode(); + + // draw UI on top (not affected by bloom) drawDebugInfo(&state); + + rl.endDrawing(); } } @@ -65,6 +200,141 @@ fn gatherInput() Input { return input; } +const Player = game.Player; + +// player rendering constants +const PLAYER_BASE_WIDTH: f32 = 30; +const PLAYER_BASE_HEIGHT: f32 = 15; +const CANNON_LENGTH: f32 = 25; +const CANNON_THICKNESS: f32 = 3; + +fn getPlayerColor(idx: usize) rl.Color { + return if (idx == 0) CYAN else MAGENTA; +} + +fn drawPlayer(player: *const Player, idx: usize, screen_h: i32) void { + if (!player.alive) return; + + const color = getPlayerColor(idx); + const px = player.x.toFloat(); + const py = @as(f32, @floatFromInt(screen_h)) - player.y.toFloat(); + + // base rectangle + const base_x = px - PLAYER_BASE_WIDTH / 2; + const base_y = py - PLAYER_BASE_HEIGHT; + rl.drawRectangleLines( + @intFromFloat(base_x), + @intFromFloat(base_y), + @intFromFloat(PLAYER_BASE_WIDTH), + @intFromFloat(PLAYER_BASE_HEIGHT), + color, + ); + + // turret circle on top + const turret_y = py - PLAYER_BASE_HEIGHT; + rl.drawCircleLines(@intFromFloat(px), @intFromFloat(turret_y), 8, color); + + // cannon barrel + // angle: 0 = right, PI = left + // for player 1 (idx=1), flip the direction + const angle = player.cannon_angle.toFloat(); + const dir: f32 = if (idx == 0) 1 else -1; + const cannon_end_x = px + dir * @cos(angle) * CANNON_LENGTH; + const cannon_end_y = turret_y - @sin(angle) * CANNON_LENGTH; + + rl.drawLineEx( + .{ .x = px, .y = turret_y }, + .{ .x = cannon_end_x, .y = cannon_end_y }, + CANNON_THICKNESS, + color, + ); + + // power meter (bar below player) + const power_bar_width: f32 = 40; + const power_bar_height: f32 = 4; + const power_y = py + 5; + const power_x = px - power_bar_width / 2; + const power_pct = player.power.toFloat() / 100.0; + + // outline + rl.drawRectangleLines( + @intFromFloat(power_x), + @intFromFloat(power_y), + @intFromFloat(power_bar_width), + @intFromFloat(power_bar_height), + color, + ); + + // filled portion + rl.drawRectangle( + @intFromFloat(power_x + 1), + @intFromFloat(power_y + 1), + @intFromFloat((power_bar_width - 2) * power_pct), + @intFromFloat(power_bar_height - 2), + color, + ); +} + +fn updateTrail(state: *const GameState, screen_h: i32) void { + if (state.projectile) |proj| { + // add new position + const x = proj.x.toFloat(); + const y = @as(f32, @floatFromInt(screen_h)) - proj.y.toFloat(); + + trail_positions[trail_head] = .{ .x = x, .y = y }; + trail_head = (trail_head + 1) % TRAIL_LENGTH; + if (trail_count < TRAIL_LENGTH) trail_count += 1; + + last_proj_exists = true; + } else { + // projectile gone - spawn explosion at last position + if (last_proj_exists and trail_count > 0) { + const last_idx = (trail_head + TRAIL_LENGTH - 1) % TRAIL_LENGTH; + spawnExplosion(trail_positions[last_idx].x, trail_positions[last_idx].y); + trail_count = 0; + trail_head = 0; + } + last_proj_exists = false; + } +} + +fn drawProjectile(state: *const GameState, screen_h: i32) void { + // draw trail (fading) + if (trail_count > 1) { + var i: usize = 0; + while (i < trail_count - 1) : (i += 1) { + const idx = (trail_head + TRAIL_LENGTH - trail_count + i) % TRAIL_LENGTH; + const next_idx = (idx + 1) % TRAIL_LENGTH; + + const alpha: u8 = @intFromFloat(255.0 * @as(f32, @floatFromInt(i + 1)) / @as(f32, @floatFromInt(trail_count))); + const trail_color = rl.Color{ .r = 255, .g = 255, .b = 0, .a = alpha }; + + rl.drawLineEx( + .{ .x = trail_positions[idx].x, .y = trail_positions[idx].y }, + .{ .x = trail_positions[next_idx].x, .y = trail_positions[next_idx].y }, + 2, + trail_color, + ); + } + } + + // draw current projectile + if (state.projectile) |proj| { + const x = proj.x.toFloat(); + const y = @as(f32, @floatFromInt(screen_h)) - proj.y.toFloat(); + rl.drawCircle(@intFromFloat(x), @intFromFloat(y), 4, YELLOW); + } +} + +fn drawTerrain(terrain: *const Terrain) void { + const screen_h: i32 = @intCast(SCREEN_HEIGHT); + for (0..SCREEN_WIDTH - 1) |x| { + const y1 = screen_h - terrain.heights[x].toInt(); + const y2 = screen_h - terrain.heights[x + 1].toInt(); + rl.drawLine(@intCast(x), y1, @intCast(x + 1), y2, GREEN); + } +} + fn drawDebugInfo(state: *const GameState) void { var buf: [256]u8 = undefined; var y: i32 = 10;