lofivor/src/game.zig

241 lines
7.1 KiB
Zig

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