From a4413055395a4e90d79ce75aa6c294d9e7061b29 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Thu, 4 Dec 2025 18:06:10 -0500 Subject: [PATCH] Complete phase 1 setup with simple window --- TODO.md | 8 +-- build.zig | 34 ++++++++++++ build.zig.zon | 16 ++++++ src/fixed.zig | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 43 +++++++++++++++ src/trig.zig | 76 ++++++++++++++++++++++++++ 6 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 src/fixed.zig create mode 100644 src/main.zig create mode 100644 src/trig.zig diff --git a/TODO.md b/TODO.md index 3451611..6c197ca 100644 --- a/TODO.md +++ b/TODO.md @@ -2,10 +2,10 @@ ## phase 1: foundation -- [ ] set up build.zig with raylib-zig dependency -- [ ] create fixed-point math module (Fixed struct, add/sub/mul/div) -- [ ] create trig lookup tables (sin/cos at comptime) -- [ ] basic window opens with raylib +- [x] set up build.zig with raylib-zig dependency +- [x] create fixed-point math module (Fixed struct, add/sub/mul/div) +- [x] create trig lookup tables (sin/cos at comptime) +- [x] basic window opens with raylib ## phase 2: single-player simulation diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..24171cd --- /dev/null +++ b/build.zig @@ -0,0 +1,34 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const raylib_dep = b.dependency("raylib_zig", .{ + .target = target, + .optimize = optimize, + }); + + const exe = b.addExecutable(.{ + .name = "lockstep", + .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); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..5b1dc6c --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,16 @@ +.{ + .name = .lockstep, + .version = "0.0.1", + .fingerprint = 0x9de74c19d031cdc9, + .dependencies = .{ + .raylib_zig = .{ + .url = "git+https://github.com/raylib-zig/raylib-zig?ref=devel#a4d18b2d1cf8fdddec68b5b084535fca0475f466", + .hash = "raylib_zig-5.6.0-dev-KE8REL5MBQAf3p497t52Xw9P7ojndIkVOWPXnLiLLw2P", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/src/fixed.zig b/src/fixed.zig new file mode 100644 index 0000000..4fc80b1 --- /dev/null +++ b/src/fixed.zig @@ -0,0 +1,147 @@ +// 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/main.zig b/src/main.zig new file mode 100644 index 0000000..c646904 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,43 @@ +const std = @import("std"); +const rl = @import("raylib"); + +const Fixed = @import("fixed.zig").Fixed; +const trig = @import("trig.zig"); + +const SCREEN_WIDTH = 800; +const SCREEN_HEIGHT = 600; + +// 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 }; + +pub fn main() !void { + rl.initWindow(SCREEN_WIDTH, SCREEN_HEIGHT, "lockstep"); + defer rl.closeWindow(); + rl.setTargetFPS(60); + + // test fixed-point math + const angle = Fixed.ZERO; + const sin_val = trig.sin(angle); + const cos_val = trig.cos(angle); + + while (!rl.windowShouldClose()) { + rl.beginDrawing(); + defer rl.endDrawing(); + + rl.clearBackground(BG_COLOR); + + // draw some test lines + rl.drawLine(100, 500, 700, 500, GREEN); + rl.drawLine(200, 300, 200, 500, CYAN); + rl.drawLine(600, 300, 600, 500, MAGENTA); + + // show fixed-point values + var buf: [128]u8 = undefined; + const text = std.fmt.bufPrintZ(&buf, "sin(0)={d:.3} cos(0)={d:.3}", .{ sin_val.toFloat(), cos_val.toFloat() }) catch "?"; + rl.drawText(text, 10, 10, 20, rl.Color.white); + rl.drawText("lockstep artillery - phase 1 complete", 10, 40, 20, GREEN); + } +} diff --git a/src/trig.zig b/src/trig.zig new file mode 100644 index 0000000..c82cd24 --- /dev/null +++ b/src/trig.zig @@ -0,0 +1,76 @@ +// 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); +}