Remove lockstep code we don't need
This commit is contained in:
parent
32654035f8
commit
7ad9d2f241
6 changed files with 4 additions and 949 deletions
31
build.zig
31
build.zig
|
|
@ -9,29 +9,6 @@ pub fn build(b: *std.Build) void {
|
||||||
.optimize = optimize,
|
.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
|
// sandbox executable
|
||||||
const sandbox_exe = b.addExecutable(.{
|
const sandbox_exe = b.addExecutable(.{
|
||||||
.name = "sandbox",
|
.name = "sandbox",
|
||||||
|
|
@ -56,14 +33,14 @@ pub fn build(b: *std.Build) void {
|
||||||
const sandbox_step = b.step("sandbox", "run the sandbox stress test");
|
const sandbox_step = b.step("sandbox", "run the sandbox stress test");
|
||||||
sandbox_step.dependOn(&sandbox_run_cmd.step);
|
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)
|
// test step (doesn't need raylib)
|
||||||
const test_step = b.step("test", "run unit tests");
|
const test_step = b.step("test", "run unit tests");
|
||||||
|
|
||||||
const test_files = [_][]const u8{
|
const test_files = [_][]const u8{
|
||||||
"src/fixed.zig",
|
|
||||||
"src/trig.zig",
|
|
||||||
"src/terrain.zig",
|
|
||||||
"src/game.zig",
|
|
||||||
"src/sandbox.zig",
|
"src/sandbox.zig",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
147
src/fixed.zig
147
src/fixed.zig
|
|
@ -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));
|
|
||||||
}
|
|
||||||
241
src/game.zig
241
src/game.zig
|
|
@ -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));
|
|
||||||
}
|
|
||||||
385
src/main.zig
385
src/main.zig
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
76
src/trig.zig
76
src/trig.zig
|
|
@ -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);
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue