Remove lockstep code we don't need

This commit is contained in:
Jared Tyler Miller 2025-12-14 22:45:26 -05:00 committed by Jared Miller
parent 32654035f8
commit 7ad9d2f241
6 changed files with 4 additions and 949 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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