const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; const clamp = std.math.clamp; const log = std.log.scoped(.game); const sokol = @import("sokol"); const sapp = sokol.app; const Assets = @import("./assets.zig"); const Tilemap = Assets.Tilemap; const Engine = @import("./engine/root.zig"); const Nanoseconds = Engine.Nanoseconds; const imgui = Engine.imgui; const Vec2 = Engine.Vec2; const Vec4 = Engine.Math.Vec4; const Rect = Engine.Math.Rect; const rgb = Engine.Math.rgb; const rgba = Engine.Math.rgba; const Range = Engine.Math.Range; const TextureId = Engine.Graphics.TextureId; const AudioId = Engine.Audio.Data.Id; const Sprite = Engine.Graphics.Sprite; const RaycastTileIterator = @import("./raycast_tile_iterator.zig"); const Game = @This(); const world_size = Vec2.init(20 * 16, 15 * 16); const RNGState = std.Random.DefaultPrng; const Kinetic = struct { pos: Vec2 = .zero, vel: Vec2 = .zero, acc: Vec2 = .zero, const UpdateOptions = struct { max_speed: ?f32 = null, friction: ?f32 = null, }; pub fn update(self: *Kinetic, dt: f32, opts: UpdateOptions) void { self.vel = self.vel.add(self.acc.multiplyScalar(dt)); if (opts.max_speed) |max_speed| { self.vel = self.vel.limitLength(max_speed); } if (opts.friction) |friction| { const friction_force = std.math.pow(f32, 1 - friction, dt); self.vel = self.vel.multiplyScalar(friction_force); } self.pos = self.pos.add(self.vel.multiplyScalar(dt)); } }; const Player = struct { kinetic: Kinetic = .{}, money: u32 = 0, health: u32 = 0, max_health: u32 = 0, last_shot_at: ?Nanoseconds = null, invincible_until: ?Nanoseconds = null, pub fn getRect(self: Player) Rect { return getCenteredRect(self.kinetic.pos, 16); } }; const Bullet = struct { kinetic: Kinetic, size: f32 = 5, dir: Vec2, speed: f32, dead: bool = false }; const Enemy = struct { kinetic: Kinetic, speed: f32, size: f32, dead: bool = false }; const Wave = struct { started_at: Nanoseconds, duration: Nanoseconds, enemies_spawned: u32, total_enemies: u32, const Info = struct { enemies: u32, duration_s: u32, starts_at_s: u32, }; }; const Pickup = struct { const Kind = enum { money }; pos: Vec2, kind: Kind, }; const invincibility_duration_s = 0.5; const pickup_spawn_duration_s = Range.init(1, 5); const wave_infos = [_]Wave.Info{ .{ .enemies = 10, .duration_s = 10, .starts_at_s = 0 } }; arena: std.heap.ArenaAllocator, gpa: Allocator, rng: RNGState, assets: *Assets, player: Player = .{}, bullets: std.ArrayList(Bullet) = .empty, enemies: std.ArrayList(Enemy) = .empty, pickups: std.ArrayList(Pickup) = .empty, next_pickup_spawn_at: Nanoseconds, wave_timer: Nanoseconds = 0, waves: std.ArrayList(Wave) = .empty, spawned_waves: std.ArrayList(usize) = .empty, pub fn init(gpa: Allocator, seed: u64, assets: *Assets) !Game { var arena = std.heap.ArenaAllocator.init(gpa); errdefer arena.deinit(); var rng = RNGState.init(seed); const next_pickup_spawn_at_s = pickup_spawn_duration_s.random(rng.random()); const next_pickup_spawn_at: Nanoseconds = @intFromFloat(next_pickup_spawn_at_s * std.time.ns_per_s); return Game{ .arena = arena, .gpa = gpa, .assets = assets, .next_pickup_spawn_at = next_pickup_spawn_at, .player = .{ .kinetic = .{ .pos = .init(50, 50), }, .health = 3, .max_health = 3 }, .rng = rng }; } pub fn deinit(self: *Game) void { self.arena.deinit(); self.bullets.deinit(self.gpa); self.enemies.deinit(self.gpa); self.waves.deinit(self.gpa); self.pickups.deinit(self.gpa); self.spawned_waves.deinit(self.gpa); } fn getCenteredRect(pos: Vec2, size: f32) Rect { return .{ .pos = pos.sub(Vec2.init(size, size).multiplyScalar(0.5)), .size = .init(size, size), }; } fn spawnEnemy(self: *Game) !void { const spawn_area_margin = 20; const spawn_area_size = 10; const top_spawn_area = (Rect{ .pos = .init(0, -spawn_area_size - spawn_area_margin), .size = .init(world_size.x, spawn_area_size) }).growX(spawn_area_size); const bottom_spawn_area = (Rect{ .pos = .init(0, world_size.y + spawn_area_margin), .size = .init(world_size.x, spawn_area_size) }).growX(spawn_area_size); const left_spawn_area = (Rect{ .pos = .init(-spawn_area_margin-spawn_area_size, 0), .size = .init(spawn_area_size, world_size.y) }).growY(spawn_area_size); const right_spawn_area = (Rect{ .pos = .init(world_size.x + spawn_area_margin, 0), .size = .init(spawn_area_size, world_size.y) }).growY(spawn_area_size); const spawn_areas = [_]Rect{ top_spawn_area, bottom_spawn_area, left_spawn_area, right_spawn_area }; const rand = self.rng.random(); const spawn_area = spawn_areas[rand.uintLessThan(usize, spawn_areas.len)]; const pos = Vec2.initRandomRect(rand, spawn_area); try self.enemies.append(self.gpa, .{ .kinetic = .{ .pos = pos }, .speed = 10, .size = 20 }); } fn spawnPickup(self: *Game) !void { const margin = 10; const spawn_area = (Rect{ .pos = .zero, .size = world_size }).grow(-margin); const pos = Vec2.initRandomRect(self.rng.random(), spawn_area); try self.pickups.append(self.gpa, Pickup{ .pos = pos, .kind = .money }); } pub fn tick(self: *Game, frame: *Engine.Frame) !void { const dt = frame.deltaTime(); self.wave_timer += frame.dt_ns; const wave_timer_s = @divFloor(self.wave_timer, std.time.ns_per_s); for (0.., wave_infos) |i, wave_info| { if (std.mem.indexOfScalar(usize, self.spawned_waves.items, i) != null) { continue; } if (wave_info.starts_at_s > wave_timer_s) { continue; } try self.spawned_waves.append(self.gpa, i); try self.waves.append(self.gpa, .{ .started_at = self.wave_timer, .duration = @as(u64, wave_info.duration_s) * std.time.ns_per_s, .enemies_spawned = 0, .total_enemies = wave_info.enemies }); } { var index: usize = 0; while (index < self.waves.items.len) { var wave_complete = false; const wave = &self.waves.items[index]; const wave_time_passed: f32 = @floatFromInt(self.wave_timer - wave.started_at); const wave_duration: f32 = @floatFromInt(wave.duration); const percent_complete = std.math.clamp(wave_time_passed / wave_duration, 0, 1); const wave_total_enemies: f32 = @floatFromInt(wave.total_enemies); const expected_enemies: u32 = @intFromFloat(wave_total_enemies * percent_complete); while (wave.enemies_spawned < expected_enemies) { try self.spawnEnemy(); wave.enemies_spawned += 1; } if (wave.enemies_spawned == wave.total_enemies) { wave_complete = true; } if (wave_complete) { _ = self.waves.swapRemove(index); } else { index += 1; } } } if (self.wave_timer >= self.next_pickup_spawn_at) { const next_pickup_spawn_at_s = pickup_spawn_duration_s.random(self.rng.random()); const next_pickup_spawn_at: Nanoseconds = @intFromFloat(next_pickup_spawn_at_s * std.time.ns_per_s); self.next_pickup_spawn_at = self.wave_timer + next_pickup_spawn_at; try self.spawnPickup(); } frame.graphics.canvas_size = world_size; if (frame.isKeyPressed(.F3)) { frame.show_debug = !frame.show_debug; } if (frame.isKeyPressed(.ESCAPE)) { sapp.requestQuit(); } frame.drawRectangle(.{ .rect = .init(0, 0, world_size.x, world_size.y), .color = rgb(20, 20, 20) }); var dir = Vec2.init(0, 0); if (frame.isKeyDown(.W)) { dir.y -= 1; } if (frame.isKeyDown(.S)) { dir.y += 1; } if (frame.isKeyDown(.A)) { dir.x -= 1; } if (frame.isKeyDown(.D)) { dir.x += 1; } dir = dir.normalized(); const acceleration = 1500; self.player.kinetic.acc = dir.multiplyScalar(acceleration); self.player.kinetic.update(dt, .{ .friction = 0.99988, .max_speed = 400 }); if (frame.input.mouse_position) |mouse| { const cursor_size = Vec2.init(10, 10); frame.drawRectangle(.{ .rect = .{ .pos = mouse.sub(cursor_size.divideScalar(2)), .size = cursor_size, }, .color = rgba(255, 200, 200, 0.5) }); const bullet_dir = mouse.sub(self.player.kinetic.pos).normalized(); var cooldown_complete = true; if (self.player.last_shot_at) |last_shot_at| { const cooldown_ns = std.time.ns_per_ms * 500; cooldown_complete = frame.time_ns > last_shot_at + cooldown_ns; } if (frame.isMouseDown(.left) and cooldown_complete) { self.player.last_shot_at = frame.time_ns; try self.bullets.append(self.gpa, .{ .kinetic = .{ .pos = self.player.kinetic.pos, }, .dir = bullet_dir, .speed = 50 }); } } frame.drawRectangle(.{ .rect = self.player.getRect(), .color = rgb(255, 255, 255) }); for (self.bullets.items) |*bullet| { bullet.kinetic.vel = bullet.dir.multiplyScalar(bullet.speed); bullet.kinetic.update(dt, .{}); const bullet_rect = getCenteredRect(bullet.kinetic.pos, bullet.size); for (self.enemies.items) |*enemy| { const enemy_rect = getCenteredRect(enemy.kinetic.pos, enemy.size); if (enemy_rect.hasOverlap(bullet_rect)) { enemy.dead = true; bullet.dead = true; } } frame.drawRectangle(.{ .rect = bullet_rect, .color = rgb(200, 20, 255) }); } for (self.enemies.items) |*enemy| { const dir_to_player = self.player.kinetic.pos.sub(enemy.kinetic.pos).normalized(); enemy.kinetic.vel = dir_to_player.multiplyScalar(50); enemy.kinetic.update(dt, .{}); const enemy_rect = getCenteredRect(enemy.kinetic.pos, enemy.size); if (enemy_rect.hasOverlap(self.player.getRect())) { var is_invincible = false; if (self.player.invincible_until) |invincible_until| { is_invincible = frame.time_ns < invincible_until; } if (self.player.health > 0 and !is_invincible) { self.player.health -= 1; var invincible_until = frame.time_ns; invincible_until += @as(Nanoseconds, @intFromFloat(invincibility_duration_s * std.time.ns_per_s)); self.player.invincible_until = invincible_until; } } frame.drawRectangle(.{ .rect = enemy_rect, .color = rgb(20, 200, 20) }); } { var index: usize = 0; while (index < self.pickups.items.len) { var destroy: bool = false; const pickup = self.pickups.items[index]; const pickup_rect = Rect.initCentered(pickup.pos.x, pickup.pos.y, 10, 10); if (pickup_rect.hasOverlap(self.player.getRect())) { switch (pickup.kind) { .money => { self.player.money += 1; } } destroy = true; } frame.drawRectangle(.{ .rect = pickup_rect, .color = rgb(20, 20, 200) }); if (destroy) { _ = self.pickups.swapRemove(index); } else { index += 1; } } } { var i: usize = 0; while (i < self.bullets.items.len) { if (self.bullets.items[i].dead) { _ = self.bullets.swapRemove(i); } else { i += 1; } } } { var i: usize = 0; while (i < self.enemies.items.len) { if (self.enemies.items[i].dead) { _ = self.enemies.swapRemove(i); } else { i += 1; } } } const text_opts = Engine.Frame.DrawTextOptions{ .font = self.assets.font_id.get(.regular) }; frame.drawTextFormat(.init(10, 10), text_opts, "{d:02}:{d:02}", .{ @divFloor(wave_timer_s, 60), @mod(wave_timer_s, 60) }); frame.drawTextFormat(.init(10, 30), text_opts, "{d}", .{ self.player.money }); frame.drawTextFormat(.init(10, 50), text_opts, "{d}/{d}", .{ self.player.health, self.player.max_health }); } pub fn debug(self: *Game) !void { if (!imgui.beginWindow(.{ .name = "Game", .pos = Vec2.init(20, 20), .size = Vec2.init(200, 200), })) { return; } defer imgui.endWindow(); if (imgui.button("Spawn enemy")) { try self.spawnEnemy(); } const time_left_til_pickup = self.next_pickup_spawn_at - self.wave_timer; imgui.textFmt("Waves: {}\n", .{self.waves.items.len}); imgui.textFmt("Bullets: {}\n", .{self.bullets.items.len}); imgui.textFmt("Enemies: {}\n", .{self.enemies.items.len}); imgui.textFmt("Time until next pickup: {d:.2}s\n", .{@as(f32, @floatFromInt(time_left_til_pickup)) / std.time.ns_per_s}); }