From 7ad9d2f24133af4e33f173fdfdd489d94dc8f4d8 Mon Sep 17 00:00:00 2001 From: Jared Tyler Miller Date: Sun, 14 Dec 2025 22:45:26 -0500 Subject: [PATCH] Remove lockstep code we don't need --- build.zig | 31 +--- src/fixed.zig | 147 ------------------ src/game.zig | 241 ------------------------------ src/main.zig | 385 ------------------------------------------------ src/terrain.zig | 73 --------- src/trig.zig | 76 ---------- 6 files changed, 4 insertions(+), 949 deletions(-) delete mode 100644 src/fixed.zig delete mode 100644 src/game.zig delete mode 100644 src/main.zig delete mode 100644 src/terrain.zig delete mode 100644 src/trig.zig diff --git a/build.zig b/build.zig index d6a8329..187670a 100644 --- a/build.zig +++ b/build.zig @@ -9,29 +9,6 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); - const exe = b.addExecutable(.{ - .name = "lofivor", - .root_module = b.createModule(.{ - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = optimize, - }), - }); - - exe.root_module.addImport("raylib", raylib_dep.module("raylib")); - exe.linkLibrary(raylib_dep.artifact("raylib")); - - b.installArtifact(exe); - - const run_cmd = b.addRunArtifact(exe); - run_cmd.step.dependOn(b.getInstallStep()); - if (b.args) |args| { - run_cmd.addArgs(args); - } - - const run_step = b.step("run", "run the game"); - run_step.dependOn(&run_cmd.step); - // sandbox executable const sandbox_exe = b.addExecutable(.{ .name = "sandbox", @@ -56,14 +33,14 @@ pub fn build(b: *std.Build) void { const sandbox_step = b.step("sandbox", "run the sandbox stress test"); sandbox_step.dependOn(&sandbox_run_cmd.step); + // make sandbox the default run target + const run_step = b.step("run", "run the sandbox"); + run_step.dependOn(&sandbox_run_cmd.step); + // test step (doesn't need raylib) const test_step = b.step("test", "run unit tests"); const test_files = [_][]const u8{ - "src/fixed.zig", - "src/trig.zig", - "src/terrain.zig", - "src/game.zig", "src/sandbox.zig", }; diff --git a/src/fixed.zig b/src/fixed.zig deleted file mode 100644 index 4fc80b1..0000000 --- a/src/fixed.zig +++ /dev/null @@ -1,147 +0,0 @@ -// fixed-point math module -// 32.32 format: 32 integer bits, 32 fractional bits -// deterministic across all platforms - no floats in simulation - -const std = @import("std"); - -pub const Fixed = struct { - raw: i64, - - pub const FRAC_BITS = 32; - pub const SCALE: i64 = 1 << FRAC_BITS; - - // common constants - pub const ZERO: Fixed = .{ .raw = 0 }; - pub const ONE: Fixed = .{ .raw = SCALE }; - pub const HALF: Fixed = .{ .raw = SCALE >> 1 }; - pub const TWO: Fixed = .{ .raw = SCALE << 1 }; - pub const NEG_ONE: Fixed = .{ .raw = -SCALE }; - - // mathematical constants (precomputed) - pub const PI: Fixed = .{ .raw = 13493037705 }; // pi * 2^32 - pub const TWO_PI: Fixed = .{ .raw = 26986075409 }; // 2*pi * 2^32 - pub const HALF_PI: Fixed = .{ .raw = 6746518852 }; // pi/2 * 2^32 - - // game constants - pub const GRAVITY: Fixed = .{ .raw = 42949673 }; // ~0.01 - pub const WIND_FACTOR: Fixed = .{ .raw = 4294967 }; // ~0.001 - pub const MAX_POWER: Fixed = .{ .raw = 100 << 32 }; - - pub fn fromInt(n: i32) Fixed { - return .{ .raw = @as(i64, n) << FRAC_BITS }; - } - - pub fn fromFloat(comptime f: f64) Fixed { - return .{ .raw = @intFromFloat(f * @as(f64, SCALE)) }; - } - - // only for rendering - never use in simulation! - pub fn toFloat(self: Fixed) f32 { - return @as(f32, @floatFromInt(self.raw)) / @as(f32, @floatFromInt(SCALE)); - } - - pub fn toInt(self: Fixed) i32 { - return @intCast(self.raw >> FRAC_BITS); - } - - pub fn add(a: Fixed, b: Fixed) Fixed { - return .{ .raw = a.raw + b.raw }; - } - - pub fn sub(a: Fixed, b: Fixed) Fixed { - return .{ .raw = a.raw - b.raw }; - } - - pub fn mul(a: Fixed, b: Fixed) Fixed { - // widen to i128 to avoid overflow - const wide = @as(i128, a.raw) * @as(i128, b.raw); - return .{ .raw = @intCast(wide >> FRAC_BITS) }; - } - - pub fn div(a: Fixed, b: Fixed) Fixed { - const wide = @as(i128, a.raw) << FRAC_BITS; - return .{ .raw = @intCast(@divTrunc(wide, b.raw)) }; - } - - pub fn neg(self: Fixed) Fixed { - return .{ .raw = -self.raw }; - } - - pub fn abs(self: Fixed) Fixed { - return .{ .raw = if (self.raw < 0) -self.raw else self.raw }; - } - - pub fn lessThan(a: Fixed, b: Fixed) bool { - return a.raw < b.raw; - } - - pub fn greaterThan(a: Fixed, b: Fixed) bool { - return a.raw > b.raw; - } - - pub fn lessThanOrEqual(a: Fixed, b: Fixed) bool { - return a.raw <= b.raw; - } - - pub fn greaterThanOrEqual(a: Fixed, b: Fixed) bool { - return a.raw >= b.raw; - } - - pub fn eq(a: Fixed, b: Fixed) bool { - return a.raw == b.raw; - } - - pub fn min(a: Fixed, b: Fixed) Fixed { - return if (a.raw < b.raw) a else b; - } - - pub fn max(a: Fixed, b: Fixed) Fixed { - return if (a.raw > b.raw) a else b; - } - - pub fn clamp(self: Fixed, lo: Fixed, hi: Fixed) Fixed { - return max(lo, min(hi, self)); - } - - // format for debug printing - pub fn format( - self: Fixed, - comptime fmt: []const u8, - options: std.fmt.FormatOptions, - writer: anytype, - ) !void { - _ = fmt; - _ = options; - try writer.print("{d:.4}", .{self.toFloat()}); - } -}; - -test "fixed point basic ops" { - const a = Fixed.fromInt(10); - const b = Fixed.fromInt(3); - - // add - try std.testing.expectEqual(@as(i32, 13), a.add(b).toInt()); - - // sub - try std.testing.expectEqual(@as(i32, 7), a.sub(b).toInt()); - - // mul - try std.testing.expectEqual(@as(i32, 30), a.mul(b).toInt()); - - // div - try std.testing.expectEqual(@as(i32, 3), a.div(b).toInt()); -} - -test "fixed point fractional" { - const half = Fixed.HALF; - const one = Fixed.ONE; - const two = Fixed.TWO; - - try std.testing.expectEqual(@as(i32, 0), half.toInt()); - try std.testing.expectEqual(@as(i32, 1), one.toInt()); - try std.testing.expectEqual(@as(i32, 2), two.toInt()); - - // half + half = one - try std.testing.expect(half.add(half).eq(one)); -} diff --git a/src/game.zig b/src/game.zig deleted file mode 100644 index cab15e6..0000000 --- a/src/game.zig +++ /dev/null @@ -1,241 +0,0 @@ -// game state and simulation -// fully deterministic - no floats, no hashmaps, no system time - -const std = @import("std"); -const Fixed = @import("fixed.zig").Fixed; -const trig = @import("trig.zig"); -const Terrain = @import("terrain.zig").Terrain; -const SCREEN_WIDTH = @import("terrain.zig").SCREEN_WIDTH; - -// simulation constants -const ANGLE_SPEED: Fixed = Fixed.fromFloat(0.02); // radians per tick when adjusting -const POWER_SPEED: Fixed = Fixed.ONE; // units per tick when adjusting -const MIN_ANGLE: Fixed = Fixed.ZERO; -const MAX_ANGLE: Fixed = Fixed.PI; -const MIN_POWER: Fixed = Fixed.ZERO; -const MAX_POWER: Fixed = Fixed.fromInt(100); -const DAMAGE: i32 = 50; -const HIT_RADIUS: Fixed = Fixed.fromInt(20); -const PROJECTILE_SPEED_FACTOR: Fixed = Fixed.fromFloat(0.15); - -pub const Player = struct { - x: Fixed, - y: Fixed, // sits on terrain - cannon_angle: Fixed, // radians, 0 to PI (0=right, PI=left) - power: Fixed, // 0 to 100 - health: i32, - alive: bool, -}; - -pub const Projectile = struct { - x: Fixed, - y: Fixed, - vx: Fixed, - vy: Fixed, -}; - -pub const GameState = struct { - tick: u32, - current_turn: u8, // 0 or 1 - wind: Fixed, - players: [2]Player, - projectile: ?Projectile, - terrain: *const Terrain, - rng_state: u64, -}; - -pub const Input = struct { - angle_delta: i8, // -1, 0, +1 - power_delta: i8, // -1, 0, +1 - fire: bool, - - pub const NONE: Input = .{ - .angle_delta = 0, - .power_delta = 0, - .fire = false, - }; -}; - -pub fn initGame(terrain: *const Terrain) GameState { - const p1_x = Fixed.fromInt(100); - const p2_x = Fixed.fromInt(700); - - return .{ - .tick = 0, - .current_turn = 0, - .wind = Fixed.ZERO, - .players = .{ - .{ - .x = p1_x, - .y = terrain.heightAt(p1_x), - .cannon_angle = Fixed.fromFloat(0.785), // ~45 degrees - .power = Fixed.fromInt(50), - .health = 100, - .alive = true, - }, - .{ - .x = p2_x, - .y = terrain.heightAt(p2_x), - .cannon_angle = Fixed.fromFloat(2.356), // ~135 degrees - .power = Fixed.fromInt(50), - .health = 100, - .alive = true, - }, - }, - .projectile = null, - .terrain = terrain, - .rng_state = 0, - }; -} - -pub fn simulate(state: *GameState, inputs: [2]Input) void { - const current = state.current_turn; - const input = inputs[current]; - var player = &state.players[current]; - - // apply input only when no projectile in flight - if (state.projectile == null) { - // adjust angle - const angle_adj = ANGLE_SPEED.mul(Fixed.fromInt(input.angle_delta)); - player.cannon_angle = player.cannon_angle.add(angle_adj).clamp(MIN_ANGLE, MAX_ANGLE); - - // adjust power - const power_adj = POWER_SPEED.mul(Fixed.fromInt(input.power_delta)); - player.power = player.power.add(power_adj).clamp(MIN_POWER, MAX_POWER); - - // fire - if (input.fire) { - const cos_a = trig.cos(player.cannon_angle); - const sin_a = trig.sin(player.cannon_angle); - const speed = player.power.mul(PROJECTILE_SPEED_FACTOR); - - // flip vx for player 1 (facing right uses positive cos) - // player 0 at x=100 faces right, player 1 at x=700 faces left - const vx = if (current == 0) speed.mul(cos_a) else speed.mul(cos_a).neg(); - - state.projectile = .{ - .x = player.x, - .y = player.y.add(Fixed.fromInt(20)), // spawn above player - .vx = vx, - .vy = speed.mul(sin_a), - }; - } - } - - // update projectile physics - if (state.projectile) |*proj| { - // gravity (downward) - proj.vy = proj.vy.sub(Fixed.GRAVITY); - - // wind - proj.vx = proj.vx.add(state.wind.mul(Fixed.WIND_FACTOR)); - - // movement - proj.x = proj.x.add(proj.vx); - proj.y = proj.y.add(proj.vy); - - var hit = false; - - // terrain collision - const terrain_y = state.terrain.heightAt(proj.x); - if (proj.y.lessThan(terrain_y)) { - hit = true; - } - - // player collision - for (&state.players) |*p| { - if (p.alive and hitTest(proj, p)) { - p.health -= DAMAGE; - if (p.health <= 0) p.alive = false; - hit = true; - } - } - - // out of bounds (left/right) - if (proj.x.lessThan(Fixed.ZERO) or proj.x.greaterThan(Fixed.fromInt(@intCast(SCREEN_WIDTH)))) { - hit = true; - } - - // out of bounds (too high - prevent infinite flight) - if (proj.y.greaterThan(Fixed.fromInt(2000))) { - hit = true; - } - - if (hit) { - state.projectile = null; - state.current_turn = 1 - current; - } - } - - state.tick += 1; -} - -fn hitTest(proj: *const Projectile, player: *const Player) bool { - const dx = proj.x.sub(player.x).abs(); - const dy = proj.y.sub(player.y).abs(); - return dx.lessThan(HIT_RADIUS) and dy.lessThan(HIT_RADIUS); -} - -// tests - -test "initGame creates valid state" { - const terrain = @import("terrain.zig").generateFixed(); - const state = initGame(&terrain); - - try std.testing.expectEqual(@as(u32, 0), state.tick); - try std.testing.expectEqual(@as(u8, 0), state.current_turn); - try std.testing.expect(state.projectile == null); - try std.testing.expect(state.players[0].alive); - try std.testing.expect(state.players[1].alive); -} - -test "simulate advances tick" { - const terrain = @import("terrain.zig").generateFixed(); - var state = initGame(&terrain); - - simulate(&state, .{ Input.NONE, Input.NONE }); - try std.testing.expectEqual(@as(u32, 1), state.tick); - - simulate(&state, .{ Input.NONE, Input.NONE }); - try std.testing.expectEqual(@as(u32, 2), state.tick); -} - -test "fire creates projectile" { - const terrain = @import("terrain.zig").generateFixed(); - var state = initGame(&terrain); - - const fire_input: Input = .{ .angle_delta = 0, .power_delta = 0, .fire = true }; - simulate(&state, .{ fire_input, Input.NONE }); - - try std.testing.expect(state.projectile != null); -} - -test "projectile moves" { - const terrain = @import("terrain.zig").generateFixed(); - var state = initGame(&terrain); - - // fire - const fire_input: Input = .{ .angle_delta = 0, .power_delta = 0, .fire = true }; - simulate(&state, .{ fire_input, Input.NONE }); - - const initial_x = state.projectile.?.x; - const initial_y = state.projectile.?.y; - - // advance - simulate(&state, .{ Input.NONE, Input.NONE }); - - // projectile should have moved - try std.testing.expect(!state.projectile.?.x.eq(initial_x) or !state.projectile.?.y.eq(initial_y)); -} - -test "angle adjustment" { - const terrain = @import("terrain.zig").generateFixed(); - var state = initGame(&terrain); - - const initial_angle = state.players[0].cannon_angle; - - const up_input: Input = .{ .angle_delta = 1, .power_delta = 0, .fire = false }; - simulate(&state, .{ up_input, Input.NONE }); - - try std.testing.expect(state.players[0].cannon_angle.greaterThan(initial_angle)); -} diff --git a/src/main.zig b/src/main.zig deleted file mode 100644 index 1614627..0000000 --- a/src/main.zig +++ /dev/null @@ -1,385 +0,0 @@ -const std = @import("std"); -const rl = @import("raylib"); - -const Fixed = @import("fixed.zig").Fixed; -const trig = @import("trig.zig"); -const terrain_mod = @import("terrain.zig"); -const game = @import("game.zig"); - -const Terrain = terrain_mod.Terrain; -const GameState = game.GameState; -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 }; -const MAGENTA = rl.Color{ .r = 255, .g = 0, .b = 255, .a = 255 }; -const GREEN = rl.Color{ .r = 0, .g = 255, .b = 0, .a = 255 }; -const YELLOW = rl.Color{ .r = 255, .g = 255, .b = 0, .a = 255 }; - -pub fn main() !void { - rl.initWindow(@intCast(SCREEN_WIDTH), @intCast(SCREEN_HEIGHT), "lockstep artillery"); - 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); - - while (!rl.windowShouldClose()) { - // gather input for current player - const input = gatherInput(); - - // simulate (both players' inputs, but only current player acts) - var inputs: [2]Input = .{ Input.NONE, Input.NONE }; - inputs[state.current_turn] = input; - game.simulate(&state, inputs); - - 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(); - } -} - -fn gatherInput() Input { - var input = Input.NONE; - - // angle: up/down arrows - if (rl.isKeyDown(.up)) input.angle_delta = 1; - if (rl.isKeyDown(.down)) input.angle_delta = -1; - - // power: left/right arrows - if (rl.isKeyDown(.right)) input.power_delta = 1; - if (rl.isKeyDown(.left)) input.power_delta = -1; - - // fire: space - if (rl.isKeyPressed(.space)) input.fire = true; - - 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; - const line_height: i32 = 20; - - // tick and turn - const turn_text = std.fmt.bufPrintZ(&buf, "tick: {d} turn: player {d}", .{ state.tick, state.current_turn }) catch "?"; - rl.drawText(turn_text, 10, y, 16, rl.Color.white); - y += line_height; - - // player 0 info - const p0 = &state.players[0]; - const p0_text = std.fmt.bufPrintZ(&buf, "P0: angle={d:.2} power={d:.0} health={d}", .{ - p0.cannon_angle.toFloat(), - p0.power.toFloat(), - p0.health, - }) catch "?"; - rl.drawText(p0_text, 10, y, 16, CYAN); - y += line_height; - - // player 1 info - const p1 = &state.players[1]; - const p1_text = std.fmt.bufPrintZ(&buf, "P1: angle={d:.2} power={d:.0} health={d}", .{ - p1.cannon_angle.toFloat(), - p1.power.toFloat(), - p1.health, - }) catch "?"; - rl.drawText(p1_text, 10, y, 16, MAGENTA); - y += line_height; - - // projectile info - if (state.projectile) |proj| { - const proj_text = std.fmt.bufPrintZ(&buf, "projectile: x={d:.1} y={d:.1} vx={d:.2} vy={d:.2}", .{ - proj.x.toFloat(), - proj.y.toFloat(), - proj.vx.toFloat(), - proj.vy.toFloat(), - }) catch "?"; - rl.drawText(proj_text, 10, y, 16, YELLOW); - } else { - rl.drawText("projectile: none (press SPACE to fire)", 10, y, 16, YELLOW); - } - y += line_height; - - // controls - y += line_height; - rl.drawText("controls: UP/DOWN=angle LEFT/RIGHT=power SPACE=fire", 10, y, 14, rl.Color.gray); -} diff --git a/src/terrain.zig b/src/terrain.zig deleted file mode 100644 index 880543c..0000000 --- a/src/terrain.zig +++ /dev/null @@ -1,73 +0,0 @@ -// terrain generation and collision -// deterministic - no floats, no randomness (for now) - -const Fixed = @import("fixed.zig").Fixed; - -pub const SCREEN_WIDTH: usize = 800; -pub const SCREEN_HEIGHT: usize = 600; - -pub const Terrain = struct { - heights: [SCREEN_WIDTH]Fixed, - - pub fn heightAt(self: *const Terrain, x: Fixed) Fixed { - const ix = x.toInt(); - if (ix < 0) return Fixed.ZERO; - if (ix >= SCREEN_WIDTH) return Fixed.ZERO; - return self.heights[@intCast(ix)]; - } -}; - -// fixed pattern: parabolic hills for testing -// produces gentle rolling terrain -pub fn generateFixed() Terrain { - var t: Terrain = undefined; - for (0..SCREEN_WIDTH) |i| { - // base height + parabolic hills - // creates two bumps across the screen - const base: i32 = 100; - const x: i32 = @intCast(i); - - // two hills centered at x=200 and x=600 - const hill1 = hillHeight(x, 200, 80, 150); - const hill2 = hillHeight(x, 600, 80, 150); - - const height = base + hill1 + hill2; - t.heights[i] = Fixed.fromInt(height); - } - return t; -} - -// parabolic hill: peak at center, width determines spread -fn hillHeight(x: i32, center: i32, peak: i32, width: i32) i32 { - const dist = x - center; - if (dist < -width or dist > width) return 0; - // parabola: peak * (1 - (dist/width)^2) - const ratio_sq = @divTrunc(dist * dist * 100, width * width); - return @max(0, peak - @divTrunc(peak * ratio_sq, 100)); -} - -const std = @import("std"); - -test "terrain heightAt bounds" { - const t = generateFixed(); - - // valid positions return height - const h = t.heightAt(Fixed.fromInt(400)); - try std.testing.expect(h.raw > 0); - - // out of bounds returns zero - try std.testing.expect(t.heightAt(Fixed.fromInt(-10)).eq(Fixed.ZERO)); - try std.testing.expect(t.heightAt(Fixed.fromInt(900)).eq(Fixed.ZERO)); -} - -test "terrain has hills" { - const t = generateFixed(); - - // hill peaks should be higher than edges - const edge = t.heightAt(Fixed.fromInt(0)).toInt(); - const peak1 = t.heightAt(Fixed.fromInt(200)).toInt(); - const peak2 = t.heightAt(Fixed.fromInt(600)).toInt(); - - try std.testing.expect(peak1 > edge); - try std.testing.expect(peak2 > edge); -} diff --git a/src/trig.zig b/src/trig.zig deleted file mode 100644 index c82cd24..0000000 --- a/src/trig.zig +++ /dev/null @@ -1,76 +0,0 @@ -// trig lookup tables - generated at comptime -// no @sin/@cos at runtime - fully deterministic - -const std = @import("std"); -const Fixed = @import("fixed.zig").Fixed; - -pub const TABLE_SIZE = 1024; - -// precomputed sin table at comptime -pub const sin_table: [TABLE_SIZE]Fixed = blk: { - @setEvalBranchQuota(10000); - var table: [TABLE_SIZE]Fixed = undefined; - for (0..TABLE_SIZE) |i| { - const angle = @as(f64, @floatFromInt(i)) * std.math.pi * 2.0 / @as(f64, TABLE_SIZE); - const s = @sin(angle); - table[i] = .{ .raw = @intFromFloat(s * @as(f64, Fixed.SCALE)) }; - } - break :blk table; -}; - -// precomputed cos table at comptime -pub const cos_table: [TABLE_SIZE]Fixed = blk: { - @setEvalBranchQuota(10000); - var table: [TABLE_SIZE]Fixed = undefined; - for (0..TABLE_SIZE) |i| { - const angle = @as(f64, @floatFromInt(i)) * std.math.pi * 2.0 / @as(f64, TABLE_SIZE); - const c = @cos(angle); - table[i] = .{ .raw = @intFromFloat(c * @as(f64, Fixed.SCALE)) }; - } - break :blk table; -}; - -// lookup sin from fixed-point angle (radians) -pub fn sin(angle: Fixed) Fixed { - // normalize to [0, 2*pi) - var a = @mod(angle.raw, Fixed.TWO_PI.raw); - if (a < 0) a += Fixed.TWO_PI.raw; - - // convert to table index - // idx = (a / TWO_PI) * TABLE_SIZE = (a * TABLE_SIZE) / TWO_PI - const scaled = @as(u128, @intCast(a)) * TABLE_SIZE; - const idx = @as(usize, @intCast(scaled / @as(u128, @intCast(Fixed.TWO_PI.raw)))) % TABLE_SIZE; - - return sin_table[idx]; -} - -// lookup cos from fixed-point angle (radians) -pub fn cos(angle: Fixed) Fixed { - // normalize to [0, 2*pi) - var a = @mod(angle.raw, Fixed.TWO_PI.raw); - if (a < 0) a += Fixed.TWO_PI.raw; - - // convert to table index - const scaled = @as(u128, @intCast(a)) * TABLE_SIZE; - const idx = @as(usize, @intCast(scaled / @as(u128, @intCast(Fixed.TWO_PI.raw)))) % TABLE_SIZE; - - return cos_table[idx]; -} - -test "trig tables" { - // sin(0) ~= 0 - const sin_0 = sin(Fixed.ZERO); - try std.testing.expect(sin_0.abs().raw < Fixed.fromFloat(0.01).raw); - - // sin(pi/2) ~= 1 - const sin_half_pi = sin(Fixed.HALF_PI); - try std.testing.expect(sin_half_pi.sub(Fixed.ONE).abs().raw < Fixed.fromFloat(0.01).raw); - - // cos(0) ~= 1 - const cos_0 = cos(Fixed.ZERO); - try std.testing.expect(cos_0.sub(Fixed.ONE).abs().raw < Fixed.fromFloat(0.01).raw); - - // cos(pi/2) ~= 0 - const cos_half_pi = cos(Fixed.HALF_PI); - try std.testing.expect(cos_half_pi.abs().raw < Fixed.fromFloat(0.01).raw); -}