Add entity and UI rendering

This commit is contained in:
Jared Miller 2025-12-04 18:38:57 -05:00
parent f406495e22
commit 935998abb4
3 changed files with 313 additions and 11 deletions

16
TODO.md
View file

@ -21,14 +21,14 @@
## phase 3: rendering ## phase 3: rendering
- [ ] draw terrain as connected line segments - [x] draw terrain as connected line segments
- [ ] draw players as geometric shapes - [x] draw players as geometric shapes
- [ ] draw cannon angle indicator - [x] draw cannon angle indicator
- [ ] draw power meter - [x] draw power meter
- [ ] draw projectile with trail (last N positions) - [x] draw projectile with trail (last N positions)
- [ ] implement bloom shader (blur.fs) - [x] implement bloom shader (blur.fs)
- [ ] render-to-texture pipeline for glow effect - [x] render-to-texture pipeline for glow effect
- [ ] explosion effect (expanding circle) - [x] explosion effect (expanding circle)
## phase 4: local two-player ## phase 4: local two-player

32
shaders/blur.fs Normal file
View file

@ -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;
}

View file

@ -13,6 +13,66 @@ const Input = game.Input;
const SCREEN_WIDTH = terrain_mod.SCREEN_WIDTH; const SCREEN_WIDTH = terrain_mod.SCREEN_WIDTH;
const SCREEN_HEIGHT = terrain_mod.SCREEN_HEIGHT; 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) // colors (vector/oscilloscope aesthetic)
const BG_COLOR = rl.Color{ .r = 10, .g = 10, .b = 18, .a = 255 }; 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 }; const CYAN = rl.Color{ .r = 0, .g = 255, .b = 255, .a = 255 };
@ -25,6 +85,41 @@ pub fn main() !void {
defer rl.closeWindow(); defer rl.closeWindow();
rl.setTargetFPS(60); 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 // initialize game
const terrain = terrain_mod.generateFixed(); const terrain = terrain_mod.generateFixed();
var state = game.initGame(&terrain); var state = game.initGame(&terrain);
@ -38,13 +133,53 @@ pub fn main() !void {
inputs[state.current_turn] = input; inputs[state.current_turn] = input;
game.simulate(&state, inputs); game.simulate(&state, inputs);
// render const screen_h: i32 = @intCast(SCREEN_HEIGHT);
rl.beginDrawing();
defer rl.endDrawing();
// update animations
updateTrail(&state, screen_h);
updateExplosions();
// 1. draw game to texture
rl.beginTextureMode(game_tex);
rl.clearBackground(BG_COLOR); 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); drawDebugInfo(&state);
rl.endDrawing();
} }
} }
@ -65,6 +200,141 @@ fn gatherInput() Input {
return 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 { fn drawDebugInfo(state: *const GameState) void {
var buf: [256]u8 = undefined; var buf: [256]u8 = undefined;
var y: i32 = 10; var y: i32 = 10;