diff --git a/TODO.md b/TODO.md index 6c197ca..c8cd9d3 100644 --- a/TODO.md +++ b/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 diff --git a/src/game.zig b/src/game.zig new file mode 100644 index 0000000..cab15e6 --- /dev/null +++ b/src/game.zig @@ -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)); +} diff --git a/src/main.zig b/src/main.zig index c646904..c05119d 100644 --- a/src/main.zig +++ b/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); - - // 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); + drawDebugInfo(&state); } } + +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); +} diff --git a/src/terrain.zig b/src/terrain.zig new file mode 100644 index 0000000..880543c --- /dev/null +++ b/src/terrain.zig @@ -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); +}