Add game state and physics
This commit is contained in:
parent
2c551b53ff
commit
485c168b39
4 changed files with 412 additions and 26 deletions
18
TODO.md
18
TODO.md
|
|
@ -9,15 +9,15 @@
|
|||
|
||||
## phase 2: single-player simulation
|
||||
|
||||
- [ ] define GameState, Player, Projectile structs
|
||||
- [ ] terrain generation (random jagged line)
|
||||
- [ ] cannon aiming (angle adjustment with keys)
|
||||
- [ ] power adjustment
|
||||
- [ ] fire projectile
|
||||
- [ ] projectile physics (gravity, movement)
|
||||
- [ ] terrain collision detection
|
||||
- [ ] player hit detection
|
||||
- [ ] turn switching after shot resolves
|
||||
- [x] define GameState, Player, Projectile structs
|
||||
- [x] terrain generation (fixed pattern for now)
|
||||
- [x] cannon aiming (angle adjustment with keys)
|
||||
- [x] power adjustment
|
||||
- [x] fire projectile
|
||||
- [x] projectile physics (gravity, movement)
|
||||
- [x] terrain collision detection
|
||||
- [x] player hit detection
|
||||
- [x] turn switching after shot resolves
|
||||
|
||||
## phase 3: rendering
|
||||
|
||||
|
|
|
|||
241
src/game.zig
Normal file
241
src/game.zig
Normal 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));
|
||||
}
|
||||
104
src/main.zig
104
src/main.zig
|
|
@ -3,41 +3,113 @@ 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 SCREEN_WIDTH = 800;
|
||||
const SCREEN_HEIGHT = 600;
|
||||
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;
|
||||
|
||||
// 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(SCREEN_WIDTH, SCREEN_HEIGHT, "lockstep");
|
||||
rl.initWindow(@intCast(SCREEN_WIDTH), @intCast(SCREEN_HEIGHT), "lockstep artillery");
|
||||
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);
|
||||
// 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);
|
||||
|
||||
// render
|
||||
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);
|
||||
drawDebugInfo(&state);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
73
src/terrain.zig
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue