Add game state and physics

This commit is contained in:
Jared Miller 2025-12-04 18:24:16 -05:00
parent 2c551b53ff
commit 485c168b39
4 changed files with 412 additions and 26 deletions

18
TODO.md
View file

@ -9,15 +9,15 @@
## phase 2: single-player simulation ## phase 2: single-player simulation
- [ ] define GameState, Player, Projectile structs - [x] define GameState, Player, Projectile structs
- [ ] terrain generation (random jagged line) - [x] terrain generation (fixed pattern for now)
- [ ] cannon aiming (angle adjustment with keys) - [x] cannon aiming (angle adjustment with keys)
- [ ] power adjustment - [x] power adjustment
- [ ] fire projectile - [x] fire projectile
- [ ] projectile physics (gravity, movement) - [x] projectile physics (gravity, movement)
- [ ] terrain collision detection - [x] terrain collision detection
- [ ] player hit detection - [x] player hit detection
- [ ] turn switching after shot resolves - [x] turn switching after shot resolves
## phase 3: rendering ## phase 3: rendering

241
src/game.zig Normal file
View file

@ -0,0 +1,241 @@
// 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

@ -3,41 +3,113 @@ const rl = @import("raylib");
const Fixed = @import("fixed.zig").Fixed; const Fixed = @import("fixed.zig").Fixed;
const trig = @import("trig.zig"); const trig = @import("trig.zig");
const terrain_mod = @import("terrain.zig");
const game = @import("game.zig");
const SCREEN_WIDTH = 800; const Terrain = terrain_mod.Terrain;
const SCREEN_HEIGHT = 600; const GameState = game.GameState;
const Input = game.Input;
const SCREEN_WIDTH = terrain_mod.SCREEN_WIDTH;
const SCREEN_HEIGHT = terrain_mod.SCREEN_HEIGHT;
// colors (vector/oscilloscope aesthetic) // colors (vector/oscilloscope aesthetic)
const BG_COLOR = rl.Color{ .r = 10, .g = 10, .b = 18, .a = 255 }; 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 CYAN = rl.Color{ .r = 0, .g = 255, .b = 255, .a = 255 };
const MAGENTA = rl.Color{ .r = 255, .g = 0, .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 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 { pub fn main() !void {
rl.initWindow(SCREEN_WIDTH, SCREEN_HEIGHT, "lockstep"); rl.initWindow(@intCast(SCREEN_WIDTH), @intCast(SCREEN_HEIGHT), "lockstep artillery");
defer rl.closeWindow(); defer rl.closeWindow();
rl.setTargetFPS(60); rl.setTargetFPS(60);
// test fixed-point math // initialize game
const angle = Fixed.ZERO; const terrain = terrain_mod.generateFixed();
const sin_val = trig.sin(angle); var state = game.initGame(&terrain);
const cos_val = trig.cos(angle);
while (!rl.windowShouldClose()) { 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);
// render
rl.beginDrawing(); rl.beginDrawing();
defer rl.endDrawing(); defer rl.endDrawing();
rl.clearBackground(BG_COLOR); rl.clearBackground(BG_COLOR);
// draw some test lines drawDebugInfo(&state);
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);
} }
} }
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;
}
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);
}

73
src/terrain.zig Normal file
View file

@ -0,0 +1,73 @@
// 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);
}