Complete phase 1 setup with simple window

This commit is contained in:
Jared Miller 2025-12-04 18:06:10 -05:00
parent 71e182ac15
commit a441305539
6 changed files with 320 additions and 4 deletions

View file

@ -2,10 +2,10 @@
## phase 1: foundation ## phase 1: foundation
- [ ] set up build.zig with raylib-zig dependency - [x] set up build.zig with raylib-zig dependency
- [ ] create fixed-point math module (Fixed struct, add/sub/mul/div) - [x] create fixed-point math module (Fixed struct, add/sub/mul/div)
- [ ] create trig lookup tables (sin/cos at comptime) - [x] create trig lookup tables (sin/cos at comptime)
- [ ] basic window opens with raylib - [x] basic window opens with raylib
## phase 2: single-player simulation ## phase 2: single-player simulation

34
build.zig Normal file
View file

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

16
build.zig.zon Normal file
View file

@ -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",
},
}

147
src/fixed.zig Normal file
View file

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

43
src/main.zig Normal file
View file

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

76
src/trig.zig Normal file
View file

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