~yujiri/spacestation-defense

ce5d88b0e73da8ff83f0b54d24885c4413db9126 — Evin Yulo 1 year, 3 months ago
remove history
162 files changed, 7718 insertions(+), 0 deletions(-)

A .gitignore
A .gitmodules
A README.md
A build.zig
A client/Card.zig
A client/Component.zig
A client/Enemy.zig
A client/Entity.zig
A client/Gamestate.zig
A client/Meter.zig
A client/Missile.zig
A client/Notif.zig
A client/Player.zig
A client/RotatedRect.zig
A client/Scrap.zig
A client/Ship.zig
A client/Ui.zig
A client/Vector2HashMapContext.zig
A client/action.zig
A client/ally_helpers.zig
A client/artifact.zig
A client/artifacts/Explosion.zig
A client/artifacts/Laser.zig
A client/binding.js
A client/components/Connector.zig
A client/components/Engine.zig
A client/components/Hangar.zig
A client/components/LaserTurret.zig
A client/components/MissileTurret.zig
A client/components/PowerGenerator.zig
A client/components/ShieldGenerator.zig
A client/consts.zig
A client/emscripten_entry.c
A client/enemies/Battleship.zig
A client/enemies/Bomber.zig
A client/enemies/Corvette.zig
A client/enemies/Destroyer.zig
A client/enemies/Drone.zig
A client/enemies/Factory.zig
A client/enemies/Fighter.zig
A client/enemies/Frigate.zig
A client/enemies/Sniper.zig
A client/enemies/helpers.zig
A client/game.zig
A client/globals.zig
A client/images/cards/distress-call-bolt.png
A client/images/cards/distress-call.png
A client/images/cards/emp-bolt.png
A client/images/cards/emp.png
A client/images/cards/planetary-cannon-bolt.png
A client/images/cards/planetary-cannon.png
A client/images/cards/repair-bolt.png
A client/images/cards/repair.png
A client/images/cards/salvage.png
A client/images/cards/salvage.xcf
A client/images/cards/scavenging-party.png
A client/images/cards/scavenging-party.xcf
A client/images/cards/scrap-magnet-bolt.png
A client/images/cards/scrap-magnet.png
A client/images/cards/shield-overcharge-bolt.png
A client/images/cards/shield-overcharge.png
A client/images/components/component-base.png
A client/images/components/component-base.xcf
A client/images/components/connector.png
A client/images/components/connector.xcf
A client/images/components/engine.png
A client/images/components/engine.xcf
A client/images/components/hangar.png
A client/images/components/hangar.xcf
A client/images/components/laser-turret-barrel-off.png
A client/images/components/laser-turret-barrel-on.png
A client/images/components/laser-turret.png
A client/images/components/laser-turret.xcf
A client/images/components/missile-turret-barrel-off.png
A client/images/components/missile-turret-barrel-on.png
A client/images/components/missile-turret-off.png
A client/images/components/missile-turret.png
A client/images/components/missile-turret.xcf
A client/images/components/no-power.png
A client/images/components/no-shield.png
A client/images/components/power-generator.png
A client/images/components/power-generator.xcf
A client/images/components/power-shield-symbols.xcf
A client/images/components/shield-generator-hiding.png
A client/images/components/shield-generator-on.png
A client/images/components/shield-generator.png
A client/images/components/shield-generator.xcf
A client/images/enemies/battleship.png
A client/images/enemies/battleship.xcf
A client/images/enemies/bomber.png
A client/images/enemies/bomber.xcf
A client/images/enemies/corvette.png
A client/images/enemies/corvette.xcf
A client/images/enemies/destroyer.png
A client/images/enemies/destroyer.xcf
A client/images/enemies/drone.png
A client/images/enemies/drone.xcf
A client/images/enemies/factory.png
A client/images/enemies/factory.xcf
A client/images/enemies/fighter.png
A client/images/enemies/fighter.xcf
A client/images/enemies/frigate.png
A client/images/enemies/frigate.xcf
A client/images/enemies/sniper.png
A client/images/enemies/sniper.xcf
A client/images/explosions/medium-1.png
A client/images/explosions/medium-1.xcf
A client/images/explosions/small-1.png
A client/images/explosions/small-1.xcf
A client/images/hangar-launch-btn.png
A client/images/hangar-repair-btn.png
A client/images/missile.png
A client/images/missile.xcf
A client/images/scrap.png
A client/images/scrap.xcf
A client/images/ships/bomber.png
A client/images/ships/bomber.xcf
A client/images/ships/corvette.png
A client/images/ships/corvette.xcf
A client/images/ships/fighter.png
A client/images/ships/fighter.xcf
A client/images/ships/probe.png
A client/images/ships/probe.xcf
A client/images/ships/pylon.png
A client/images/ships/pylon.xcf
A client/index.html
A client/main_common.zig
A client/main_desktop.zig
A client/main_web.zig
A client/menu.zig
A client/network.zig
A client/pixel-square.ttf
A client/raylib
A client/rlgl.zig
A client/script.zig
A client/scripts/Score.zig
A client/scripts/Survival.zig
A client/scripts/Tutorial.zig
A client/ships/Bomber.zig
A client/ships/Corvette.zig
A client/ships/Fighter.zig
A client/ships/Probe.zig
A client/ships/Pylon.zig
A client/ships/helpers.zig
A client/thread.zig
A client/ui.zig
A client/util.zig
A common/EncryptedSocket.zig
A common/ScriptParams.zig
A common/lib.zig
A common/queue.zig
A deploy.sh
A server/Context.zig
A server/Lobby.zig
A server/Player.zig
A server/PlayerAction.zig
A server/WebsocketHandler.zig
A server/game.zig
A server/main.zig
A server/websocket
A website/global.css
A website/index.html
A  => .gitignore +3 -0
@@ 1,3 @@
zig-cache
zig-out
server/keypair.zig

A  => .gitmodules +6 -0
@@ 1,6 @@
[submodule "client/raylib"]
	path = client/raylib
	url = ssh://git@git.sr.ht/~yujiri/raylib-zig
[submodule "server/websocket"]
	path = server/websocket
	url = https://github.com/karlseguin/websocket.zig

A  => README.md +1 -0
@@ 1,1 @@
Spacestation Defense is source-available proprietary software. I won't send lawyers after anyone, but once the game starts offering paid features, please do not use access to the source code to avoid paying for them. That's all.

A  => build.zig +83 -0
@@ 1,83 @@
const Build = @import("std").Build;
const raylib = @import("client/raylib/raylib/src/build.zig");

pub fn build(b: *Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const common = b.addModule("common", .{
        .source_file = .{ .path = "common/lib.zig" },
    });
    const raylib_mod = b.addModule("raylib", .{
        .source_file = .{ .path = "client/raylib/binding.zig" },
    });
    const websocket_mod = b.addModule("websocket", .{
        .source_file = .{ .path = "server/websocket/src/websocket.zig" },
    });

    if (b.option(bool, "server", "build server") orelse false) {
        const server = b.addExecutable(.{
            .name = "server",
            .root_source_file = .{ .path = "server/main.zig" },
            .optimize = optimize,
            .target = target,
        });
        server.addModule("common", common);
        server.addModule("websocket", websocket_mod);
        b.installArtifact(server);
    } else if (target.os_tag == null or target.os_tag.? != .emscripten) {
        // Not wasm.
        const client = b.addExecutable(.{
            .name = "client",
            .root_source_file = .{ .path = "client/main_desktop.zig" },
            .optimize = optimize,
            .target = target,
        });
        client.addModule("common", common);
        client.addModule("raylib", raylib_mod);
        client.addLibraryPath(.{ .path = "/usr/lib" });
        client.linkLibrary(raylib.addRaylib(b, target, optimize, .{}));
        b.installArtifact(client);
    } else {
        // Wasm.
        const client = b.addStaticLibrary(.{
            .name = "client",
            .root_source_file = .{ .path = "client/main_web.zig" },
            .optimize = optimize,
            .target = target,
        });
        client.addModule("common", common);
        client.addModule("raylib", raylib_mod);
        client.addIncludePath(.{ .path = "../emsdk/upstream/emscripten/cache/sysroot/include" });
        b.installArtifact(client);
        const raylib_lib = raylib.addRaylib(b, target, optimize, .{});
        b.installArtifact(raylib_lib);
        const emcc = b.addSystemCommand(&.{
            "emcc",
            "client/emscripten_entry.c",
            "zig-out/lib/libclient.a",
            "zig-out/lib/libraylib.a",
            "-o",
            "zig-out/lib/index.html",
            "-Iclient/raylib/raylib/src",
            "--shell-file",
            "client/index.html",
            "--js-library",
            "client/binding.js",
            "-sERROR_ON_UNDEFINED_SYMBOLS=0",
            "-DPLATFORM_WEB",
            "-sUSE_GLFW=3",
            "-sASSERTIONS=2",
            "-sALLOW_MEMORY_GROWTH=1",
            // Without this it panics when allocating.
            "-sUSE_OFFSET_CONVERTER",
            "-sEXPORTED_FUNCTIONS=['_malloc', '_free', '_main', '_zigMain', '_init', '_websocketRecv']",
            "-sEXPORTED_RUNTIME_METHODS=ccall,cwrap",
            "-O1",
            "-Os",
        });
        emcc.step.dependOn(&client.step);
        emcc.step.dependOn(&raylib_lib.step);
        b.getInstallStep().dependOn(&emcc.step);
    }
}

A  => client/Card.zig +112 -0
@@ 1,112 @@
const raylib = @import("raylib");
const Texture = raylib.Texture;
const common = @import("common");

id: Id,
kind: Kind,

pub const Id = u32;

pub const Kind = enum {
    repair,
    salvage,
    planetary_cannon,
    shield_overcharge,
    scavenging_party,
    emp,
    scrap_magnet,
    distress_call,
    pub const TargetMode = enum { entity, pos };
    pub fn targetMode(self: Kind) TargetMode {
        return switch (self) {
            .repair => .pos,
            .salvage => .entity,
            .planetary_cannon => .entity,
            .shield_overcharge => .pos,
            .scavenging_party => .pos,
            .emp => .pos,
            .scrap_magnet => .pos,
            .distress_call => .pos,
        };
    }
    pub fn name(self: Kind) []const u8 {
        return switch (self) {
            .repair => "Repair",
            .salvage => "Salvage",
            .planetary_cannon => "Planetary Cannon",
            .shield_overcharge => "Shield Overcharge",
            .scavenging_party => "Scavenging Party",
            .emp => "EMP",
            .scrap_magnet => "Scrap Magnet",
            .distress_call => "Distress Call",
        };
    }
    pub fn description(self: Kind) []const u8 {
        return switch (self) {
            .repair => "Restore all station components to full hull.",
            .salvage => "Sacrifice a station component. Draw 3 cards.",
            .planetary_cannon => "Deal 100 damage to 1 enemy.",
            .shield_overcharge => "Station component shields recharge 5x as fast for 30 seconds.",
            .scavenging_party => "For 1 minute, enemies drop 3x as much scrap, and Probes collect it 3x as fast.",
            .emp => "Completely disable all enitities, including allies, within a radius for 15 seconds.",
            .scrap_magnet => "Instantly collect all scrap within a radius.",
            .distress_call => "Command sends 2 Corvettes, 1 Fighter, 1 Bomber and 1 Pylon that stay for 60 seconds.",
        };
    }
    pub fn texture(self: Kind) Texture {
        return switch (self) {
            .repair => repair_texture,
            .salvage => salvage_texture,
            .planetary_cannon => planetary_cannon_texture,
            .shield_overcharge => shield_overcharge_texture,
            .scavenging_party => scavenging_party_texture,
            .emp => emp_texture,
            .scrap_magnet => scrap_magnet_texture,
            .distress_call => distress_call_texture,
        };
    }
};

pub const planetary_cannon_damage = 100;
pub const shield_overcharge_duration = 30 * common.cycles_per_s;
pub const scavenging_party_duration = 60 * common.cycles_per_s;
pub const emp_range = 216;
pub const emp_duration = 15 * common.cycles_per_s;
pub const scrap_magnet_range = 216;
pub const distress_call_duration = 60 * common.cycles_per_s;

var repair_texture: Texture = undefined;
var salvage_texture: Texture = undefined;
var planetary_cannon_texture: Texture = undefined;
var shield_overcharge_texture: Texture = undefined;
var scavenging_party_texture: Texture = undefined;
var emp_texture: Texture = undefined;
var scrap_magnet_texture: Texture = undefined;
var distress_call_texture: Texture = undefined;

pub fn loadAssets() void {
    const repair_file = @embedFile("images/cards/repair.png");
    const repair_image = raylib.LoadImageFromMemory(".png", repair_file, repair_file.len);
    repair_texture = raylib.LoadTextureFromImage(repair_image);
    const salvage_file = @embedFile("images/cards/salvage.png");
    const salvage_image = raylib.LoadImageFromMemory(".png", salvage_file, salvage_file.len);
    salvage_texture = raylib.LoadTextureFromImage(salvage_image);
    const planetary_cannon_file = @embedFile("images/cards/planetary-cannon.png");
    const planetary_cannon_image = raylib.LoadImageFromMemory(".png", planetary_cannon_file, planetary_cannon_file.len);
    planetary_cannon_texture = raylib.LoadTextureFromImage(planetary_cannon_image);
    const shield_overcharge_file = @embedFile("images/cards/shield-overcharge.png");
    const shield_overcharge_image = raylib.LoadImageFromMemory(".png", shield_overcharge_file, shield_overcharge_file.len);
    shield_overcharge_texture = raylib.LoadTextureFromImage(shield_overcharge_image);
    const scavenging_party_file = @embedFile("images/cards/scavenging-party.png");
    const scavenging_party_image = raylib.LoadImageFromMemory(".png", scavenging_party_file, scavenging_party_file.len);
    scavenging_party_texture = raylib.LoadTextureFromImage(scavenging_party_image);
    const emp_file = @embedFile("images/cards/emp.png");
    const emp_image = raylib.LoadImageFromMemory(".png", emp_file, emp_file.len);
    emp_texture = raylib.LoadTextureFromImage(emp_image);
    const scrap_magnet_file = @embedFile("images/cards/scrap-magnet.png");
    const scrap_magnet_image = raylib.LoadImageFromMemory(".png", scrap_magnet_file, scrap_magnet_file.len);
    scrap_magnet_texture = raylib.LoadTextureFromImage(scrap_magnet_image);
    const distress_call_file = @embedFile("images/cards/distress-call.png");
    const distress_call_image = raylib.LoadImageFromMemory(".png", distress_call_file, distress_call_file.len);
    distress_call_texture = raylib.LoadTextureFromImage(distress_call_image);
}

A  => client/Component.zig +197 -0
@@ 1,197 @@
const std = @import("std");
const mem = std.mem;
const meta = std.meta;
const Allocator = mem.Allocator;
const ArrayList = std.ArrayList;
const AutoHashMap = std.AutoHashMap;
const raylib = @import("raylib");
const Texture = raylib.Texture;
const Vector2 = raylib.Vector2;
const util = @import("util.zig");
const Entity = @import("Entity.zig");
const Gamestate = @import("Gamestate.zig");
const Meter = @import("Meter.zig");
const Connector = @import("components/Connector.zig");
const Engine = @import("components/Engine.zig");
const Hangar = @import("components/Hangar.zig");
const LaserTurret = @import("components/LaserTurret.zig");
const MissileTurret = @import("components/MissileTurret.zig");
const PowerGenerator = @import("components/PowerGenerator.zig");
const ShieldGenerator = @import("components/ShieldGenerator.zig");
const Component = @This();

const KindDetail = union(enum) {
    connector: Connector,
    power_generator: PowerGenerator,
    shield_generator: ShieldGenerator,
    laser_turret: LaserTurret,
    missile_turret: MissileTurret,
    engine: Engine,
    hangar: Hangar,
};
pub const Kind = meta.Tag(KindDetail);

pub fn DetailOfKind(comptime kind: Kind) type {
    const kind_name = @tagName(kind);
    for (@typeInfo(KindDetail).Union.fields) |field| {
        if (mem.eql(u8, field.name, kind_name)) return field.type;
    }
}

pub const hull = 50;
pub const size = Vector2{ .x = 72, .y = 72 };
pub var base_texture: Texture = undefined;
pub var no_power_texture: Texture = undefined;
pub var no_shield_texture: Texture = undefined;

kind: KindDetail,
shielded: bool = true,

pub fn moveDir(comptime dir: []const u8, pos: Vector2, rot: f32) Vector2 {
    // zig fmt: off
    const diff: Vector2 = if (mem.eql(u8, dir, "up")) .{ .x = 0, .y = -size.y }
        else if (mem.eql(u8, dir, "down")) .{ .x = 0, .y = size.y }
        else if (mem.eql(u8, dir, "left")) .{ .x = -size.x, .y = 0 }
        else .{ .x = size.x, .y = 0 };
    // zig fmt: on
    return pos.add(diff.rotate(rot));
}

pub fn init(kind: Kind) Component {
    return Component{ .kind = switch (kind) {
        .connector => .connector,
        .power_generator => .{ .power_generator = .{} },
        .shield_generator => .{ .shield_generator = .{} },
        .laser_turret => .{ .laser_turret = .{} },
        .missile_turret => .{ .missile_turret = .{} },
        .engine => .{ .engine = .{} },
        .hangar => .{ .hangar = .{} },
    } };
}

fn FromKindType(comptime p: type) type {
    const p_info = @typeInfo(p).Pointer;
    return if (p_info.is_const) *const Component else *Component;
}
pub fn fromKind(p: anytype) FromKindType(@TypeOf(p)) {
    const t = @typeInfo(@TypeOf(p)).Pointer.child;
    var kind = switch (t) {
        void => @fieldParentPtr(KindDetail, "connector", p),
        PowerGenerator => @fieldParentPtr(KindDetail, "power_generator", p),
        ShieldGenerator => @fieldParentPtr(KindDetail, "shield_generator", p),
        LaserTurret => @fieldParentPtr(KindDetail, "laser_turret", p),
        MissileTurret => @fieldParentPtr(KindDetail, "missile_turret", p),
        Engine => @fieldParentPtr(KindDetail, "engine", p),
        Hangar => @fieldParentPtr(KindDetail, "hangar", p),
        else => @compileError("invalid component kind"),
    };
    return @fieldParentPtr(Component, "kind", kind);
}

pub fn tick(self: *Component, gamestate: *Gamestate) !void {
    try switch (self.kind) {
        .connector => {},
        .power_generator => PowerGenerator.tick(gamestate),
        .shield_generator => |*sg| sg.tick(gamestate),
        .laser_turret => |*lt| lt.tick(gamestate),
        .missile_turret => |*mt| mt.tick(gamestate),
        .engine => |*e| e.tick(gamestate),
        .hangar => |*h| h.tick(gamestate),
    };
}

pub fn draw(self: *const Component) void {
    const e = Entity.fromKind(self);
    switch (self.kind) {
        .connector => Connector.draw(e.pos, e.rot),
        .power_generator => PowerGenerator.draw(e.pos, e.rot),
        .shield_generator => ShieldGenerator.draw(e.pos, e.rot),
        .hangar => Hangar.draw(e.pos, e.rot),
        .engine => Engine.draw(e.pos, e.rot),
        inline else => |*k| k.draw(),
    }
    if (self.power() != null and !self.power().?)
        util.drawTextureRotated(no_power_texture, e.pos, size, e.rot);
    if (!self.shielded)
        util.drawTextureRotated(no_shield_texture, e.pos, size, e.rot);
}

pub fn typeName(kind: Kind) []const u8 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).name,
    };
}
pub fn description(kind: Kind) []const u8 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).description,
    };
}
pub fn texture(kind: Kind) Texture {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).texture,
    };
}

pub fn cost(kind: Kind) f32 {
    return switch (kind) {
        .connector => 50,
        .power_generator => 75,
        .shield_generator => 100,
        .laser_turret => 100,
        .missile_turret => 100,
        .engine => 50,
        .hangar => 50,
    };
}

pub fn target(self: Component) ?Entity.Id {
    return switch (self.kind) {
        .laser_turret => |lt| lt.target,
        .missile_turret => |mt| mt.target,
        else => null,
    };
}
pub fn setTarget(self: *Component, t: ?Entity.Id) void {
    switch (self.kind) {
        .laser_turret => |*lt| lt.target = t,
        .missile_turret => |*mt| mt.target = t,
        else => {},
    }
}
pub fn power(self: Component) ?bool {
    return switch (self.kind) {
        .shield_generator => |sg| sg.on,
        .laser_turret => |lt| lt.on,
        .missile_turret => |mt| mt.on,
        .hangar => |h| h.on,
        else => null,
    };
}
pub fn setPower(self: *Component, v: bool) void {
    return switch (self.kind) {
        .shield_generator => |*sg| sg.on = v,
        .laser_turret => |*lt| lt.on = v,
        .missile_turret => |*mt| mt.on = v,
        .hangar => |*h| h.on = v,
        else => {},
    };
}
pub fn range(kind: Kind) ?f32 {
    return switch (kind) {
        .laser_turret => LaserTurret.range,
        .missile_turret => MissileTurret.range,
        else => null,
    };
}

pub fn loadAssets() void {
    const base_file = @embedFile("images/components/component-base.png");
    var base_image = raylib.LoadImageFromMemory(".png", base_file, base_file.len);
    base_texture = raylib.LoadTextureFromImage(base_image);
    const no_power_file = @embedFile("images/components/no-power.png");
    var no_power_image = raylib.LoadImageFromMemory(".png", no_power_file, no_power_file.len);
    no_power_texture = raylib.LoadTextureFromImage(no_power_image);
    const no_shield_file = @embedFile("images/components/no-shield.png");
    var no_shield_image = raylib.LoadImageFromMemory(".png", no_shield_file, no_shield_file.len);
    no_shield_texture = raylib.LoadTextureFromImage(no_shield_image);
}

A  => client/Enemy.zig +162 -0
@@ 1,162 @@
const std = @import("std");
const mem = std.mem;
const meta = std.meta;
const raylib = @import("raylib");
const Rectangle = raylib.Rectangle;
const Texture = raylib.Texture;
const Vector2 = raylib.Vector2;
const util = @import("./util.zig");
const Meter = @import("Meter.zig");
const Entity = @import("Entity.zig");
const Gamestate = @import("Gamestate.zig");
const Drone = @import("enemies/Drone.zig");
const Fighter = @import("enemies/Fighter.zig");
const Bomber = @import("enemies/Bomber.zig");
const Corvette = @import("enemies/Corvette.zig");
const Sniper = @import("enemies/Sniper.zig");
const Frigate = @import("enemies/Frigate.zig");
const Factory = @import("enemies/Factory.zig");
const Destroyer = @import("enemies/Destroyer.zig");
const Battleship = @import("enemies/Battleship.zig");
const Pylon = @import("ships/Pylon.zig");
const Enemy = @This();

const KindDetail = union(enum) {
    drone: Drone,
    fighter: Fighter,
    bomber: Bomber,
    corvette: Corvette,
    sniper: Sniper,
    frigate: Frigate,
    factory: Factory,
    destroyer: Destroyer,
    battleship: Battleship,
};
pub const Kind = meta.Tag(KindDetail);

pub fn DetailOfKind(comptime kind: Kind) type {
    const kind_name = @tagName(kind);
    for (@typeInfo(KindDetail).Union.fields) |field| {
        if (mem.eql(u8, field.name, kind_name)) return field.type;
    }
}

kind: KindDetail,

pub fn init(kind: meta.Tag(KindDetail)) Enemy {
    return Enemy{ .kind = switch (kind) {
        .drone => .{ .drone = .{} },
        .fighter => .{ .fighter = .{} },
        .bomber => .{ .bomber = .{} },
        .corvette => .{ .corvette = .{} },
        .sniper => .{ .sniper = .{} },
        .frigate => .{ .frigate = .{} },
        .factory => .{ .factory = .{} },
        .destroyer => .{ .destroyer = .{} },
        .battleship => .{ .battleship = .{} },
    } };
}

fn FromKindType(comptime p: type) type {
    const p_info = @typeInfo(p).Pointer;
    return if (p_info.is_const) *const Enemy else *Enemy;
}
pub fn fromKind(p: anytype) FromKindType(@TypeOf(p)) {
    const t = @typeInfo(@TypeOf(p)).Pointer.child;
    const kind = switch (t) {
        Drone => @fieldParentPtr(KindDetail, "drone", p),
        Fighter => @fieldParentPtr(KindDetail, "fighter", p),
        Bomber => @fieldParentPtr(KindDetail, "bomber", p),
        Corvette => @fieldParentPtr(KindDetail, "corvette", p),
        Sniper => @fieldParentPtr(KindDetail, "sniper", p),
        Frigate => @fieldParentPtr(KindDetail, "frigate", p),
        Factory => @fieldParentPtr(KindDetail, "factory", p),
        Destroyer => @fieldParentPtr(KindDetail, "destroyer", p),
        Battleship => @fieldParentPtr(KindDetail, "battleship", p),
        else => unreachable,
    };
    return @fieldParentPtr(Enemy, "kind", kind);
}

pub fn tick(self: *Enemy, gamestate: *Gamestate) !void {
    switch (self.kind) {
        inline else => |*k| try k.tick(gamestate),
    }
}

pub fn draw(self: *const Enemy) void {
    const e = Entity.fromKind(self);
    switch (self.kind) {
        .drone => Drone.draw(e.pos, e.rot),
        .fighter => Fighter.draw(e.pos, e.rot),
        .bomber => Bomber.draw(e.pos, e.rot),
        .corvette => Corvette.draw(e.pos, e.rot),
        .sniper => Sniper.draw(e.pos, e.rot),
        .frigate => Frigate.draw(e.pos, e.rot),
        .factory => Factory.draw(e.pos, e.rot),
        .destroyer => Destroyer.draw(e.pos, e.rot),
        .battleship => Battleship.draw(e.pos, e.rot),
    }
}

pub fn size(kind: Kind) Vector2 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).size,
    };
}
pub fn hull(kind: Kind) f32 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).hull,
    };
}
pub fn scrap(kind: Kind) f32 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).scrap,
    };
}
pub fn typeName(kind: Kind) []const u8 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).name,
    };
}
pub fn description(kind: Kind) []const u8 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).description,
    };
}
pub fn texture(kind: Kind) Texture {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).texture,
    };
}
pub fn speed(kind: Kind) f32 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).speed,
    };
}
pub fn range(kind: Kind) ?f32 {
    return switch (kind) {
        .factory => null,
        inline else => |k| DetailOfKind(k).range,
    };
}

pub fn takeDamage(self: *Enemy, gamestate: *Gamestate, amount: f32) !void {
    const entity = Entity.fromKind(self);
    const pylon_nearby = pylon: {
        for (gamestate.entities.items) |e| {
            if (e.dead) continue;
            const ship = util.variant(e.kind, .ship) orelse continue;
            if (ship.kind != .pylon) continue;
            if (e.pos.distance(entity.pos) <= Pylon.range) break :pylon true;
        }
        break :pylon false;
    };
    entity.hull.current -= if (pylon_nearby) amount * 1.5 else amount;
    if (entity.hull.current <= 0) {
        var scrap_amount = scrap(self.kind);
        if (gamestate.scavenging_party > 0) scrap_amount *= 3;
        try gamestate.scraps.append(.{ .pos = entity.pos, .amount = scrap_amount });
        gamestate.score += @intFromFloat(scrap_amount);
    }
}

A  => client/Entity.zig +503 -0
@@ 1,503 @@
const std = @import("std");
const hash_map = std.hash_map;
const meta = std.meta;
const math = std.math;
const ArrayList = std.ArrayList;
const HashMap = std.HashMap;
const Order = math.Order;
const PriorityQueue = std.PriorityQueue;
const raylib = @import("raylib");
const Color = raylib.Color;
const Rectangle = raylib.Rectangle;
const Texture = raylib.Texture;
const Vector2 = raylib.Vector2;
const util = @import("util.zig");
const Card = @import("Card.zig");
const Component = @import("Component.zig");
const Enemy = @import("Enemy.zig");
const Gamestate = @import("Gamestate.zig");
const Meter = @import("Meter.zig");
const RotatedRect = @import("RotatedRect.zig");
const ShieldGenerator = @import("components/ShieldGenerator.zig");
const Ship = @import("Ship.zig");
const Vector2HashMapContext = @import("Vector2HashMapContext.zig");
const Entity = @This();

pub const Id = u32;
pub const KindDetail = union(enum) {
    component: Component,
    ship: Ship,
    enemy: Enemy,
};

id: Id,
pos: Vector2,
hull: Meter,
// Stuff starts pointing up.
rot: f32 = @as(f32, -math.pi) / 2,
kind: KindDetail,
dead: bool = false,
emp: u32 = 0,

pub fn init(id: Id, pos: Vector2, kind: KindDetail) Entity {
    const hull = switch (kind) {
        .component => Component.hull,
        .ship => |s| Ship.hull(s.kind),
        .enemy => |e| Enemy.hull(e.kind),
    };
    return Entity{
        .id = id,
        .pos = pos,
        .kind = kind,
        .hull = .{ .current = hull, .max = hull },
    };
}
pub fn size(self: Entity) Vector2 {
    return switch (self.kind) {
        .component => Component.size,
        .enemy => |e| Enemy.size(e.kind),
        .ship => |s| Ship.size(s.kind),
    };
}
pub fn draw(self: Entity) void {
    switch (self.kind) {
        inline else => |*kind| kind.draw(),
    }
}
pub fn typeName(self: Entity) []const u8 {
    return switch (self.kind) {
        .component => |c| Component.typeName(c.kind),
        .enemy => |e| Enemy.typeName(e.kind),
        .ship => |s| Ship.typeName(s.kind),
    };
}
pub fn description(self: Entity) []const u8 {
    return switch (self.kind) {
        .component => |c| Component.description(c.kind),
        .enemy => |e| Enemy.description(e.kind),
        .ship => |s| Ship.description(s.kind),
    };
}
pub fn texture(self: Entity) Texture {
    return switch (self.kind) {
        .component => |c| Component.texture(c.kind),
        .ship => |s| Ship.texture(s.kind),
        .enemy => |e| Enemy.texture(e.kind),
    };
}

pub fn tick(self: *Entity, gamestate: *Gamestate) !void {
    if (self.emp > 0) {
        self.emp -= 1;
        return;
    }
    try switch (self.kind) {
        inline else => |*kind| kind.tick(gamestate),
    };
}
pub fn target(self: Entity) ?Id {
    return switch (self.kind) {
        .component => |c| c.target(),
        .ship => |s| s.target(),
        else => null,
    };
}
pub fn speed(self: Entity) ?f32 {
    return switch (self.kind) {
        .ship => |s| Ship.speed(s.kind),
        .enemy => |e| Enemy.speed(e.kind),
        else => null,
    };
}
pub fn range(self: Entity) ?f32 {
    return switch (self.kind) {
        .component => |c| Component.range(c.kind),
        .ship => |s| Ship.range(s.kind),
        .enemy => |e| Enemy.range(e.kind),
    };
}
pub fn rotatedRect(self: Entity) RotatedRect {
    return .{ .pos = self.pos, .size = self.size(), .rot = self.rot };
}
pub fn move(self: *Entity, gamestate: Gamestate, toward: Vector2) void {
    const new_rot = self.pos.angleTo(toward);
    const new_pos = self.pos.moveTowards(toward, self.speed().?);
    const new_rect = RotatedRect{ .pos = new_pos, .size = self.size(), .rot = new_rot };
    for (gamestate.entities.items) |blocker| {
        if (blocker.dead or blocker.id == self.id) continue;
        if (new_rect.overlapsCorner(blocker.rotatedRect())) {
            // Something can move onto a blocked position if it would be farther
            // from the blocker than it currently is. This is so that if something
            // gets stuck on something else, it can still move away.
            if (blocker.pos.distance(new_pos) <= blocker.pos.distance(self.pos)) return;
        }
    }
    self.rot = new_rot;
    self.pos = new_pos;
}

pub fn takeDamage(self: *Entity, gamestate: *Gamestate, amount: f32) !void {
    switch (self.kind) {
        .component => |c| {
            if (c.shielded)
                self.hull.current -= gamestate.shield.drain(amount)
            else
                self.hull.current -= amount;
            if (self.hull.current <= 0 and c.kind == .shield_generator)
                gamestate.shield.max -= ShieldGenerator.capacity;
        },
        .enemy => |*e| try e.takeDamage(gamestate, amount),
        .ship => self.hull.current -= amount,
    }
}

fn FromKindType(comptime p: type) type {
    const p_info = @typeInfo(p).Pointer;
    return if (p_info.is_const) *const Entity else *Entity;
}
pub fn fromKind(p: anytype) FromKindType(@TypeOf(p)) {
    const t = @typeInfo(@TypeOf(p)).Pointer.child;
    var kind = switch (t) {
        Component => @fieldParentPtr(KindDetail, "component", p),
        Enemy => @fieldParentPtr(KindDetail, "enemy", p),
        Ship => @fieldParentPtr(KindDetail, "ship", p),
        else => @compileError("invalid entity kind"),
    };
    return @fieldParentPtr(Entity, "kind", kind);
}

const Range = struct { min: f32, max: f32 };

fn angleRange(self: Entity, other: Entity) Range {
    const topleft = Vector2{ .x = other.pos.x - other.size().x / 2, .y = other.pos.y - other.size().y / 2 };
    const topleft_angle = self.pos.angleTo(topleft);
    var angle_range = Range{ .min = topleft_angle, .max = topleft_angle };
    const topright = Vector2{ .x = other.pos.x + other.size().x / 2, .y = other.pos.y - other.size().y / 2 };
    const bottomleft = Vector2{ .x = other.pos.x - other.size().x / 2, .y = other.pos.y + other.size().y / 2 };
    const bottomright = Vector2{ .x = other.pos.x + other.size().x / 2, .y = other.pos.y + other.size().y / 2 };
    inline for (.{ topright, bottomleft, bottomright }) |corner| {
        const corner_angle = self.pos.angleTo(corner);
        if (angleCmp(corner_angle, angle_range.min) == .lt) angle_range.min = corner_angle;
        if (angleCmp(corner_angle, angle_range.max) == .gt) angle_range.max = corner_angle;
    }
    return angle_range;
}

fn angleCmp(a: f32, b: f32) Order {
    // If the difference > half a circle, we're measuring the wrong way.
    return if (@fabs(a - b) > math.pi) math.order(b, a) else math.order(a, b);
}
fn angleSub(a: f32, b: f32) f32 {
    return if (a - b < 0) a - b + math.tau else a - b;
}
fn angleAdd(a: f32, b: f32) f32 {
    return if (a + b > math.pi) a + b - math.tau else a + b;
}

fn angleRangeCmp(min: f32, inner: f32, max: f32) Order {
    if (angleCmp(inner, min) == .lt) return .lt;
    if (angleCmp(inner, max) == .gt) return .gt;
    return .eq;
}

// Returns false if the goal is outside laser range.
pub fn canSee(self: Entity, gamestate: Gamestate, other: Entity, max_range: f32) !?Vector2 {
    if (self.pos.distance(other.pos) > max_range) return null;
    const target_range = self.angleRange(other);
    var target_distance = self.pos.distance(other.pos);
    var ranges = ArrayList(Range).init(gamestate.alloc);
    try ranges.append(target_range);
    next_blocker: for (gamestate.entities.items) |blocker| {
        if (blocker.dead or blocker.id == self.id or blocker.id == other.id) continue;
        if (self.pos.distance(blocker.pos) > target_distance) continue;
        const blocker_range = self.angleRange(blocker);
        var i: usize = 0;
        while (i < ranges.items.len) {
            var r = &ranges.items[i];
            const min_cmp = angleRangeCmp(r.min, blocker_range.min, r.max);
            const max_cmp = angleRangeCmp(r.min, blocker_range.max, r.max);
            if (min_cmp == .eq and max_cmp == .eq) {
                // Split this range.
                const min_half = Range{ .min = r.min, .max = blocker_range.min };
                const max_half = Range{ .min = blocker_range.max, .max = r.max };
                r.* = min_half;
                try ranges.append(max_half);
                // A blocker can't be contained within one range and also overlapping another.
                continue :next_blocker;
            }
            if (min_cmp == .lt and max_cmp != .lt) {
                r.min = blocker_range.max;
            }
            if (max_cmp == .gt and min_cmp != .gt) {
                r.max = blocker_range.min;
            }
            // If either of those happened, this range might've been eliminated.
            if (angleCmp(angleAdd(r.min, math.pi * math.floatEps(f32)), r.max) == .gt) {
                _ = ranges.swapRemove(i);
                if (ranges.items.len == 0) return null;
            } else i += 1;
        }
    }
    const r = ranges.items[0];
    const angle = angleAdd(r.min, angleSub(r.max, r.min) / 2);
    const movement = (Vector2{ .x = 1, .y = 0 }).rotate(angle).scale(target_distance);
    return self.pos.add(movement);
}

pub const PathfindMode = enum {
    combat_ship,
    noncombat_ship,
    enemy,
    pub fn ignores(self: PathfindMode, blocker: Entity) bool {
        return switch (self) {
            .noncombat_ship => false,
            .combat_ship => blocker.kind == .enemy,
            .enemy => blocker.kind != .enemy,
        };
    }
};

// Named in honor of a friend who gave me this algorithm 🙂
fn pathBlockerChloe(
    blockers: []const RotatedRect,
    self_pos: Vector2,
    self_size: Vector2,
    goal: Vector2,
) ?RotatedRect {
    const angle = self_pos.angleTo(goal);
    const self_rotated = self_pos.rotate(-angle);
    const line_start = self_rotated.x;
    const y_offset = self_rotated.y;
    const line_end = line_start + self_pos.distance(goal);
    for (blockers) |blocker| {
        // Un-rotate the blocker.
        var blocker_copy = blocker;
        blocker_copy.pos = blocker_copy.pos.rotate(-angle);
        blocker_copy.rot -= angle;
        var seen_pos: bool = false;
        var seen_neg: bool = false;
        for (blocker_copy.getCorners()) |corner| {
            const y_diff = corner.y - y_offset;
            const x_aligned = corner.x > line_start and corner.x < line_end;
            const close_enough = @fabs(y_diff) < self_size.y / 2;
            if (x_aligned) {
                if (y_diff > 0) seen_pos = true else seen_neg = true;
            }
            if (x_aligned and close_enough or seen_pos and seen_neg) return blocker;
        }
    }
    return null;
}

// This impl is based on moving to corners of obstacles.
pub fn pathfind(self: Entity, gamestate: Gamestate, goal: Vector2, mode: PathfindMode) !?Vector2 {
    var blockers = try ArrayList(RotatedRect).initCapacity(gamestate.alloc, gamestate.entities.items.len);
    defer blockers.deinit();
    for (gamestate.entities.items) |e| {
        if (!e.dead and e.id != self.id and !mode.ignores(e) and !meta.eql(e.pos, goal))
            blockers.appendAssumeCapacity(e.rotatedRect());
    }
    const Corner = struct {
        const Corner = @This();
        pos: Vector2,
        score: f32,
        fn cmp(context: void, a: Corner, b: Corner) Order {
            _ = context;
            return math.order(a.score, b.score);
        }
    };
    const Blocker = struct {
        const Blocker = @This();
        rect: RotatedRect,
        from: Vector2,
        score: f32,
        fn cmp(context: void, a: Blocker, b: Blocker) Order {
            _ = context;
            return math.order(a.score, b.score);
        }
    };
    var blocker_fringe = PriorityQueue(Blocker, void, Blocker.cmp).init(gamestate.alloc, {});
    var corner_fringe = PriorityQueue(Corner, void, Corner.cmp).init(gamestate.alloc, {});
    var came_from = HashMap(
        Vector2,
        Vector2,
        Vector2HashMapContext,
        hash_map.default_max_load_percentage,
    ).init(gamestate.alloc);
    var best_to = HashMap(
        Vector2,
        f32,
        Vector2HashMapContext,
        hash_map.default_max_load_percentage,
    ).init(gamestate.alloc);
    const BlockerAndFrom = struct {
        pos: Vector2,
        from: Vector2,
    };
    var checked_blockers = HashMap(
        BlockerAndFrom,
        void,
        struct {
            pub fn eql(ctx: @This(), a: BlockerAndFrom, b: BlockerAndFrom) bool {
                return ctx.hash(a) == ctx.hash(b);
            }
            pub fn hash(_: @This(), v: BlockerAndFrom) u64 {
                // We need to fit 4 f32s into a u64 without likely collisions. So, convert them to ints,
                // mod them to within i16, and then bitshift them so they all cover different parts of the u64.
                const x: i64 = @intFromFloat(@rem(v.pos.x, math.maxInt(i16)));
                const y: i64 = @intFromFloat(@rem(v.pos.y, math.maxInt(i16)));
                const from_x: i64 = @intFromFloat(@rem(v.from.x, math.maxInt(i16)));
                const from_y: i64 = @intFromFloat(@rem(v.from.y, math.maxInt(i16)));
                return @bitCast((x << 48) + (y << 32) + (from_x << 16) + from_y);
            }
        },
        hash_map.default_max_load_percentage,
    ).init(gamestate.alloc);
    // Setup.
    try best_to.put(self.pos, 0);
    const initial_blocker = pathBlockerChloe(blockers.items, self.pos, self.size(), goal) orelse return goal;
    if (!(try checked_blockers.getOrPut(.{ .pos = initial_blocker.pos, .from = self.pos })).found_existing) {
        const score = self.pos.distance(initial_blocker.pos) + initial_blocker.pos.distance(goal);
        try blocker_fringe.add(.{ .rect = initial_blocker, .from = self.pos, .score = score });
    }
    while (blocker_fringe.len > 0 or corner_fringe.len > 0) {
        while (corner_fringe.len > 0) {
            const corner = corner_fringe.remove();
            if (pathBlockerChloe(blockers.items, corner.pos, self.size(), goal)) |blocker| {
                // If we were here, something else would block us from the goal. Queue this new blocker.
                const result = try checked_blockers.getOrPut(.{ .pos = blocker.pos, .from = corner.pos });
                if (!result.found_existing) {
                    const score = best_to.get(corner.pos).? + corner.pos.distance(blocker.pos) + blocker.pos.distance(goal);
                    try blocker_fringe.add(.{ .rect = blocker, .score = score, .from = corner.pos });
                }
            } else {
                // We can get straight to the goal from here. So, unwind
                // the path we got here from and return the first step.
                var backtrack = corner.pos;
                while (came_from.get(backtrack)) |previous| {
                    if (meta.eql(previous, self.pos)) return backtrack;
                    backtrack = previous;
                }
                unreachable;
            }
        }
        // Ran out of corners. Explore around a new blocker.
        if (blocker_fringe.len == 0) break;
        const blocker = blocker_fringe.remove();
        try checked_blockers.put(.{ .pos = blocker.rect.pos, .from = blocker.from }, {});
        for (blocker.rect.getCorners()) |corner| {
            // Move position out from the blocker so we're not colliding with the same one.
            const self_max_size = @max(self.size().x, self.size().y);
            const out_angle = blocker.rect.pos.angleTo(corner);
            const out_movement = (Vector2{ .x = 1, .y = 0 }).rotate(out_angle).scale(self_max_size);
            const corner_out = corner.add(out_movement);
            if (pathBlockerChloe(blockers.items, blocker.from, self.size(), corner_out)) |corner_blocker| {
                // Can't get to this corner of the blocker. Queue this new blocker for exploration.
                const score = best_to.get(blocker.from).? + blocker.from.distance(corner_blocker.pos) + corner_blocker.pos.distance(goal);
                const result = try checked_blockers.getOrPut(.{ .pos = corner_blocker.pos, .from = blocker.from });
                if (!result.found_existing)
                    try blocker_fringe.add(.{ .rect = corner_blocker, .from = blocker.from, .score = score });
            } else {
                // Check if we can shortcut to this corner.
                var from = blocker.from;
                var backtrack = blocker.from;
                while (came_from.get(backtrack)) |previous| {
                    if (pathBlockerChloe(blockers.items, previous, self.size(), corner_out) == null) {
                        from = previous;
                    }
                    backtrack = previous;
                }
                var tentative_best_to = from.distance(corner_out) + best_to.get(from).?;
                // Check if we've already reached this corner.
                if (best_to.get(corner_out)) |previous_best_to| {
                    if (tentative_best_to < previous_best_to) {
                        // We've already seen this corner, but this is a faster path to it.
                        try best_to.put(corner_out, tentative_best_to);
                        try came_from.put(corner_out, from);
                    }
                    continue;
                }
                // It's a new corner.
                try best_to.put(corner_out, tentative_best_to);
                try came_from.put(corner_out, from);
                // We can get to this corner. Queue it for exploring what we can reach from there.
                const score = corner_out.distance(goal);
                try corner_fringe.add(.{ .pos = corner_out, .score = score });
            }
        }
    }
    // Failed to find a path. Just go straight toward it.
    return goal;
}

pub const ClosestKind = enum {
    component,
    ship,
    ally,
    enemy,
    pub fn is(self: ClosestKind, e: Entity) bool {
        return switch (self) {
            .component => e.kind == .component,
            .ship => e.kind == .ship,
            .ally => e.kind == .ship or e.kind == .component,
            .enemy => e.kind == .enemy,
        };
    }
};

pub fn closest(self: Entity, gamestate: Gamestate, kind: ClosestKind) ?Vector2 {
    var closest_pos: ?Vector2 = null;
    var closest_dist = math.inf(f32);
    for (gamestate.entities.items) |e| {
        if (e.dead or e.id == self.id or !kind.is(e)) continue;
        const distance = self.pos.distance(e.pos);
        if (distance < closest_dist) {
            closest_dist = distance;
            closest_pos = e.pos;
        }
    }
    return closest_pos;
}

pub fn avoidMissiles(self: *Entity, gamestate: Gamestate) bool {
    for (gamestate.missiles.items) |missile| {
        if ((missile.team == .enemy) == (self.kind == .enemy)) continue;
        if (self.pos.distance(missile.pos) < 100) {
            self.avoidMissile(gamestate, missile.pos);
            return true;
        }
    }
    return false;
}

fn avoidMissile(self: *Entity, gamestate: Gamestate, missile: Vector2) void {
    const missile_angle = self.pos.angleTo(missile);
    // Run parallel to it.
    const dodge_angle = missile_angle + math.pi / 2.0;
    const movement = (Vector2{ .x = self.speed().?, .y = 0 }).rotate(dodge_angle);
    self.move(gamestate, self.pos.add(movement));
}

pub const Target = struct {
    entity: *Entity,
    hit_point: Vector2,
};

pub fn findTarget(
    self: Entity,
    gamestate: Gamestate,
    attack_range: f32,
    targetDesirability: anytype,
) !?Target {
    var best_target: ?Target = null;
    var best: f32 = 0;
    for (gamestate.entities.items) |*e| {
        if (e.dead) continue;
        const desirability = targetDesirability(e.*) orelse continue;
        if (desirability <= best) continue;
        const hit_point = try self.canSee(gamestate, e.*, attack_range) orelse continue;
        best = desirability;
        best_target = .{ .entity = e, .hit_point = hit_point };
    }
    return best_target;
}

A  => client/Gamestate.zig +477 -0
@@ 1,477 @@
const std = @import("std");
const hash_map = std.hash_map;
const math = std.math;
const meta = std.meta;
const Allocator = std.mem.Allocator;
const ArrayList = std.ArrayList;
const HashMap = std.HashMap;
const Random = std.rand.Random;
const Thread = std.Thread;
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const common = @import("common");
const LobbyExternal = common.LobbyExternal;
const PlayerExternal = common.PlayerExternal;
const PlayerId = common.PlayerId;
const util = @import("util.zig");
const Action = @import("action.zig").Action;
const Artifact = @import("artifact.zig").Artifact;
const Card = @import("Card.zig");
const Component = @import("Component.zig");
const Enemy = @import("Enemy.zig");
const Engine = @import("components/Engine.zig");
const Entity = @import("Entity.zig");
const Meter = @import("Meter.zig");
const Missile = @import("Missile.zig");
const Notif = @import("Notif.zig");
const Player = @import("Player.zig");
const PowerGenerator = @import("components/PowerGenerator.zig");
const RotatedRect = @import("RotatedRect.zig");
const Scrap = @import("Scrap.zig");
const Script = @import("script.zig").Script;
const ShieldGenerator = @import("components/ShieldGenerator.zig");
const Ship = @import("Ship.zig");
const Vector2HashMapContext = @import("Vector2HashMapContext.zig");
const Probe = @import("ships/Probe.zig");
const Gamestate = @This();

players: ArrayList(Player),
last_draw_index: usize = 0,
power: Meter = .{ .current = 0, .max = 0 },
shield: Meter = .{ .current = 0, .max = 0 },
engine_state: Engine.State = .off,
entities: ArrayList(Entity),
missiles: ArrayList(Missile),
scraps: ArrayList(Scrap),
notifs: ArrayList(Notif),
dialogue: ?[]const u8 = null,
scrap: f32 = 0,
artifacts: ArrayList(Artifact),
script: Script,
alloc: Allocator,
rng: Random,
shield_overcharge: u32 = 0,
scavenging_party: u32 = 0,
over: bool = false,
paused: bool = false,
tutorial_freeze: bool = false,
score: u32 = 0,

pub fn init(alloc: Allocator, rng: Random) Gamestate {
    return .{
        .players = ArrayList(Player).init(alloc),
        .entities = ArrayList(Entity).init(alloc),
        .missiles = ArrayList(Missile).init(alloc),
        .scraps = ArrayList(Scrap).init(alloc),
        .artifacts = ArrayList(Artifact).init(alloc),
        .notifs = ArrayList(Notif).init(alloc),
        .alloc = alloc,
        .rng = rng,
        .script = undefined,
    };
}

pub fn clone(self: *Gamestate) !*Gamestate {
    var notifs = try ArrayList(Notif).initCapacity(self.alloc, self.notifs.items.len);
    for (self.notifs.items) |notif| {
        notifs.appendAssumeCapacity(try notif.clone(self.alloc));
    }
    var copy = try self.alloc.create(Gamestate);
    copy.* = .{
        .players = self.players,
        .entities = try self.entities.clone(),
        .missiles = try self.missiles.clone(),
        .scraps = try self.scraps.clone(),
        .artifacts = try self.artifacts.clone(),
        .notifs = notifs,
        .alloc = self.alloc,
        .rng = self.rng,
        .script = self.script,
        .dialogue = self.dialogue,
    };
    return copy;
}

pub fn fromLobby(alloc: Allocator, rng: Random, lobby: LobbyExternal) !Gamestate {
    var gamestate = init(alloc, rng);
    for (lobby.players.slice()) |player| try gamestate.players.append(.{
        .id = player.id,
        .name = player.name,
        .cards = ArrayList(Card).init(alloc),
    });
    gamestate.script = Script.fromParams(lobby.settings.script);
    try gamestate.script.init(&gamestate);
    return gamestate;
}

pub fn initTutorial(alloc: Allocator, rng: Random, player: PlayerExternal) !Gamestate {
    var gamestate = init(alloc, rng);
    try gamestate.players.append(.{
        .id = player.id,
        .name = player.name,
        .cards = ArrayList(Card).init(alloc),
    });
    gamestate.script = Script{ .tutorial = .{} };
    try gamestate.script.init(&gamestate);
    return gamestate;
}

pub fn deinit(self: Gamestate) void {
    for (self.players.items) |player| player.cards.deinit();
    self.players.deinit();
    self.entities.deinit();
    self.missiles.deinit();
    self.scraps.deinit();
    self.artifacts.deinit();
}

pub fn tick(self: *Gamestate) !void {
    if (self.over or self.tutorial_freeze or self.paused) return;
    for (self.entities.items) |*e| {
        if (e.dead) continue;
        if (e.hull.current > 0) try e.tick(self) else {
            e.dead = true;
            const total_size = e.size().x + e.size().y;
            try self.artifacts.append(.{ .explosion = .{
                .size = if (total_size > 144) .big else if (total_size > 72) .medium else .small,
                .pos = e.pos,
            } });
        }
    }
    var i: usize = 0;
    while (i < self.missiles.items.len) {
        var m = &self.missiles.items[i];
        try m.tick(self);
        if (m.dead) _ = self.missiles.swapRemove(i) else i += 1;
    }
    i = 0;
    while (i < self.scraps.items.len) {
        var s = self.scraps.items[i];
        if (s.amount <= 0) _ = self.scraps.swapRemove(i) else i += 1;
    }
    i = 0;
    while (i < self.artifacts.items.len) {
        var a = &self.artifacts.items[i];
        a.tick();
        if (a.isDone()) _ = self.artifacts.swapRemove(i) else i += 1;
    }
    i = 0;
    while (i < self.notifs.items.len) {
        var n = &self.notifs.items[i];
        n.time -= 1;
        if (n.time == 0) {
            self.alloc.free(n.message);
            _ = self.notifs.orderedRemove(i);
        } else i += 1;
    }
    try self.script.tick(self);
    var station_alive = false;
    for (self.entities.items) |e| {
        if (!e.dead and e.kind == .component) station_alive = true;
    }
    if (!station_alive) self.over = true;
    if (self.shield_overcharge > 0) self.shield_overcharge -= 1;
    if (self.scavenging_party > 0) self.scavenging_party -= 1;
    self.power.current = @min(self.power.current, self.power.max);
    self.shield.current = @min(self.shield.current, self.shield.max);
}

pub fn findEntity(self: Gamestate, id: ?Entity.Id) ?*Entity {
    for (self.entities.items) |*e| {
        if (e.id == id and !e.dead) return e;
    }
    return null;
}
pub fn addEntity(self: *Gamestate, entity: Entity) !void {
    if (entity.kind == .component) {
        switch (entity.kind.component.kind) {
            .shield_generator => {
                self.shield.current += ShieldGenerator.capacity;
                self.shield.max += ShieldGenerator.capacity;
            },
            .power_generator => {
                self.power.current += PowerGenerator.capacity;
                self.power.max += PowerGenerator.capacity;
            },
            else => {},
        }
    }
    for (self.entities.items) |*e| {
        if (e.dead) {
            e.* = entity;
            return;
        }
    }
    try self.entities.append(entity);
}

pub fn findPlayer(self: Gamestate, player_id: PlayerId) ?*Player {
    for (self.players.items) |*p| {
        if (meta.eql(p.id, player_id)) return p;
    }
    return null;
}

pub fn applyAction(self: *Gamestate, player_id: PlayerId, action: Action) !void {
    if (self.over) return;
    switch (action) {
        .quit => {
            var i = player: for (self.players.items, 0..) |p, i| {
                if (meta.eql(p.id, player_id)) break :player i;
            } else return;
            const p = self.players.orderedRemove(i);
            for (p.cards.items) |card| try self.drawCard(card.kind);
            try self.notifs.append(try Notif.new(self.alloc, Notif.player_left, .{p.name.slice()}));
        },
        .pause => if (self.players.items.len == 1) {
            self.paused = !self.paused;
        },
        .move => |info| {
            var e = self.findEntity(info.id) orelse return;
            if (e.kind == .ship) {
                var pos = Vector2{ .x = info.x, .y = info.y };
                e.kind.ship.destination = .{ .pos = .{ .pos = pos, .stay = info.stay } };
            }
        },
        .assign_target => |info| {
            var e = self.findEntity(info.id) orelse return;
            // TODO change this when we move away from writeStruct for actions.
            var target_id = if (info.target == 0) null else info.target;
            switch (e.kind) {
                .component => |*c| c.setTarget(target_id),
                .ship => |*s| s.setTarget(target_id),
                else => {},
            }
        },
        .set_power => |info| {
            var e = self.findEntity(info.id) orelse return;
            if (e.kind == .component) e.kind.component.setPower(info.on);
        },
        .set_shield => |info| {
            var e = self.findEntity(info.id) orelse return;
            if (e.kind == .component) e.kind.component.shielded = info.on;
        },
        .engine_control => |info| self.engine_state = info.state,
        .build_ship => |info| {
            if (self.scrap < Ship.cost(info.kind)) return;
            self.scrap -= Ship.cost(info.kind);
            try self.addEntity(Entity.init(
                self.rng.int(Entity.Id),
                .{ .x = info.x, .y = info.y },
                .{ .ship = Ship.init(info.kind) },
            ));
        },
        .build_component => |info| {
            if (self.scrap < Component.cost(info.kind)) return;
            // Find the component it's supposed to be attached to.
            const neighbor = for (self.entities.items) |e| {
                if (e.id == info.id and !e.dead and e.kind == .component) break e;
            } else return;
            // Calculate the position it would be built at.
            const offset = Vector2{ .x = Component.size.x, .y = 0 };
            const pos = neighbor.pos.add(offset.rotate(neighbor.rot + info.angle));
            const rect = RotatedRect{ .pos = pos, .rot = neighbor.rot, .size = Component.size };
            // Check for collisions.
            for (self.entities.items) |blocker| {
                if (!blocker.dead and blocker.rotatedRect().overlaps(rect)) return;
            }
            var new_e = Entity.init(
                self.rng.int(Entity.Id),
                pos,
                .{ .component = Component.init(info.kind) },
            );
            new_e.rot = neighbor.rot;
            self.scrap -= Component.cost(info.kind);
            try self.addEntity(new_e);
        },
        .play_card => |info| {
            var player = self.findPlayer(player_id) orelse return;
            const index = player.findCardIndex(info.id) orelse return;
            const valid = switch (player.cards.items[index].kind) {
                .repair => self.playCardRepair(),
                .salvage => try self.playCardSalvage(info.target),
                .planetary_cannon => try self.playCardPlanetaryCannon(info.target),
                .shield_overcharge => valid: {
                    self.shield_overcharge += Card.shield_overcharge_duration;
                    break :valid true;
                },
                .scavenging_party => valid: {
                    self.scavenging_party += Card.scavenging_party_duration;
                    break :valid true;
                },
                .emp => self.playCardEmp(.{ .x = info.x, .y = info.y }),
                .scrap_magnet => self.playCardScrapMagnet(.{ .x = info.x, .y = info.y }),
                .distress_call => try self.playCardDistressCall(.{ .x = info.x, .y = info.y }),
            };
            if (valid) _ = player.cards.orderedRemove(index);
        },
        .land_in_hangar => |info| {
            const ship_e = self.findEntity(info.ship_id) orelse return;
            const ship = if (ship_e.kind == .ship) &ship_e.kind.ship else return;
            const hangar = self.findEntity(info.hangar_id) orelse return;
            if (hangar.kind != .component or hangar.kind.component.kind != .hangar) return;
            ship.destination = .{ .land = hangar.id };
        },
        .launch_from_hangar => |info| {
            const hangar_e = self.findEntity(info.hangar_id) orelse return;
            if (hangar_e.emp > 0) return;
            const hangar_c = if (hangar_e.kind == .component) &hangar_e.kind.component else return;
            const hangar = if (hangar_c.kind == .hangar) &hangar_c.kind.hangar else return;
            for (hangar.ships.slice(), 0..) |id, i| {
                if (id != info.ship_id) continue;
                const ship = self.findEntity(id) orelse return;
                const offsets = [_]Vector2{
                    .{ .x = Component.size.x, .y = 0 },
                    .{ .x = Component.size.x, .y = Component.size.y },
                    .{ .x = 0, .y = Component.size.y },
                    .{ .x = -Component.size.x, .y = Component.size.y },
                    .{ .x = -Component.size.x, .y = 0 },
                    .{ .x = -Component.size.x, .y = -Component.size.y },
                    .{ .x = 0, .y = -Component.size.y },
                    .{ .x = Component.size.x, .y = -Component.size.y },
                };
                for (offsets) |offset| {
                    const angle = offset.angle() + hangar_e.rot;
                    const pos = hangar_e.pos.add(offset.rotate(hangar_e.rot));
                    const rect = RotatedRect{ .pos = pos, .size = ship.size(), .rot = angle };
                    for (self.entities.items) |blocker| {
                        if (!blocker.dead and blocker.rotatedRect().overlaps(rect)) break;
                    } else {
                        // Found a place the ship can fit.
                        ship.pos = pos;
                        ship.rot = angle;
                        ship.kind.ship.hangar = null;
                        _ = hangar.ships.orderedRemove(i);
                        return;
                    }
                }
            }
        },
    }
}

fn playCardRepair(self: Gamestate) bool {
    var repaired_some = false;
    for (self.entities.items) |*e| {
        if (e.kind == .component and e.hull.current < e.hull.max) {
            e.hull.current = e.hull.max;
            repaired_some = true;
        }
    }
    return repaired_some;
}
fn playCardSalvage(self: *Gamestate, id: Entity.Id) !bool {
    var target = self.findEntity(id) orelse return false;
    if (target.kind != .component) return false;
    target.dead = true;
    var n: usize = 3;
    while (n > 0) : (n -= 1) try self.drawRandomCard();
    return true;
}
fn playCardPlanetaryCannon(self: *Gamestate, id: Entity.Id) !bool {
    var target = self.findEntity(id) orelse return false;
    if (target.kind != .enemy) return false;
    try target.takeDamage(self, Card.planetary_cannon_damage);
    return true;
}
fn playCardEmp(self: *Gamestate, pos: Vector2) bool {
    for (self.entities.items) |*e| {
        if (e.pos.distance(pos) <= Card.emp_range) e.emp += Card.emp_duration;
    }
    return true;
}
fn playCardScrapMagnet(self: *Gamestate, pos: Vector2) bool {
    for (self.scraps.items) |*s| {
        if (s.pos.distance(pos) <= Card.scrap_magnet_range) {
            self.scrap += s.amount;
            s.amount = 0;
        }
    }
    return true;
}

fn playCardDistressCall(self: *Gamestate, pos: Vector2) !bool {
    const biggest_size = Ship.size(.corvette);
    const max_size = @max(biggest_size.x, biggest_size.y) + 6;
    var angle: f32 = 0;
    var distance: f32 = max_size * 2;
    var corvettes: u8 = 2;
    var fighters: u8 = 1;
    var bombers: u8 = 1;
    var pylons: u8 = 1;
    while (pylons > 0 or corvettes > 0 or fighters > 0 or bombers > 0) {
        defer {
            if (angle >= math.tau - 0.01) {
                angle = 0;
                distance += max_size * 2;
            } else angle += @as(f32, math.tau) / 5;
        }
        var ship = Ship.init(if (pylons > 0)
            .pylon
        else if (corvettes > 0)
            .corvette
        else if (fighters > 0)
            .fighter
        else
            .bomber);
        ship.time = Card.distress_call_duration;
        const entity = Entity.init(
            self.rng.int(Entity.Id),
            pos.add((Vector2{ .x = distance, .y = 0 }).rotate(angle)),
            .{ .ship = ship },
        );
        const rect = entity.rotatedRect();
        for (self.entities.items) |blocker| {
            if (blocker.rotatedRect().overlaps(rect)) continue;
        }
        try self.addEntity(entity);
        if (pylons > 0)
            pylons -= 1
        else if (corvettes > 0)
            corvettes -= 1
        else if (fighters > 0)
            fighters -= 1
        else if (bombers > 0)
            bombers -= 1;
    }
    return true;
}

pub fn drawRandomCard(self: *Gamestate) !void {
    try self.drawCard(self.rng.enumValueWithIndex(Card.Kind, u32));
}

fn drawCard(self: *Gamestate, kind: Card.Kind) !void {
    if (self.last_draw_index >= self.players.items.len) self.last_draw_index = 0;
    const player = &self.players.items[self.last_draw_index];
    try player.cards.append(.{ .id = self.rng.int(Card.Id), .kind = kind });
    self.last_draw_index += 1;
}

const ComponentSlots = HashMap(
    Vector2,
    void,
    Vector2HashMapContext,
    hash_map.default_max_load_percentage,
);
pub fn findComponentSlots(self: Gamestate, for_connector: bool) !ComponentSlots {
    var slots = ComponentSlots.init(self.alloc);
    for (self.entities.items) |e| {
        if (e.dead) continue;
        const component = util.variant(e.kind, .component) orelse continue;
        if (component.kind != .connector and !for_connector) continue;
        const angles = [_]f32{ 0, math.pi / 2.0, math.pi, -math.pi / 2.0 };
        for (angles) |angle| {
            const offset = Vector2{ .x = Component.size.x, .y = 0 };
            const slot_pos = e.pos.add(offset.rotate(angle + e.rot));
            if (!slots.contains(slot_pos)) {
                // Make sure nothing would overlap the new component.
                const rect = RotatedRect{ .pos = slot_pos, .rot = e.rot, .size = Component.size };
                for (self.entities.items) |blocker| {
                    if (!blocker.dead and blocker.rotatedRect().overlaps(rect)) break;
                } else try slots.put(slot_pos, {});
            }
        }
    }
    return slots;
}

A  => client/Meter.zig +27 -0
@@ 1,27 @@
const std = @import("std");
const Meter = @This();

current: f32,
max: f32,

pub fn drain(self: *Meter, amount: f32) f32 {
    const drained = @min(self.current, amount);
    self.current -= drained;
    return amount - drained;
}

pub fn consume(self: *Meter, amount: f32) bool {
    if (self.current >= amount) {
        self.current -= amount;
        return true;
    }
    return false;
}

pub fn fill(self: *Meter, amount: f32) void {
    self.current = @min(self.max, self.current + amount);
}

pub fn fmt(meter: Meter, buf: []u8) []u8 {
    return std.fmt.bufPrintZ(buf, "{d}/{d}", .{ @round(meter.current), meter.max }) catch unreachable;
}

A  => client/Missile.zig +53 -0
@@ 1,53 @@
const std = @import("std");
const math = std.math;
const raylib = @import("raylib");
const Texture = raylib.Texture;
const Vector2 = raylib.Vector2;
const common = @import("common");
const util = @import("util.zig");
const Entity = @import("Entity.zig");
const Gamestate = @import("Gamestate.zig");
const Missile = @This();

creator: Entity.Id,
origin: Vector2,
range: f32,
pos: Vector2,
rot: f32,
damage: f32,
dead: bool = false,
team: Team,

pub const Team = enum { ally, enemy };

pub var texture: Texture = undefined;
const size: f32 = 6;
const speed: f32 = 150 / common.cycles_per_s;

pub fn loadAssets() void {
    const file = @embedFile("images/missile.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(self: Missile) void {
    util.drawTextureRotated(texture, self.pos, .{ .x = size, .y = size }, self.rot);
}

pub fn tick(self: *Missile, gamestate: *Gamestate) !void {
    const vel = (Vector2{ .x = speed, .y = 0 }).rotate(self.rot);
    self.pos.x += vel.x;
    self.pos.y += vel.y;
    if (self.origin.distance(self.pos) > self.range) {
        self.dead = true;
        return;
    }
    for (gamestate.entities.items) |*e| {
        if (e.dead or e.id == self.creator) continue;
        if (e.rotatedRect().containsPoint(self.pos)) {
            self.dead = true;
            if ((self.team == .enemy) != (e.kind == .enemy))
                try e.takeDamage(gamestate, self.damage);
        }
    }
}

A  => client/Notif.zig +18 -0
@@ 1,18 @@
const std = @import("std");
const fmt = std.fmt;
const Allocator = std.mem.Allocator;
const common = @import("common");
const Notif = @This();

time: u32 = 5 * common.cycles_per_s,
message: []const u8,

pub const player_left = "{s} left the game. Their cards have been distributed among remaining players.";

pub fn new(alloc: Allocator, comptime template: []const u8, args: anytype) !Notif {
    return .{ .message = try fmt.allocPrint(alloc, template, args) };
}

pub fn clone(self: Notif, alloc: Allocator) !Notif {
    return .{ .time = self.time, .message = try alloc.dupe(u8, self.message) };
}

A  => client/Player.zig +24 -0
@@ 1,24 @@
const std = @import("std");
const ArrayList = std.ArrayList;
const common = @import("common");
const Name = common.Name;
const PlayerId = common.PlayerId;
const Card = @import("Card.zig");
const Player = @This();

id: PlayerId,
name: Name,
cards: ArrayList(Card),

pub fn findCard(self: Player, id: Card.Id) ?Card {
    for (self.cards.items) |card| {
        if (card.id == id) return card;
    }
    return null;
}
pub fn findCardIndex(self: Player, id: Card.Id) ?usize {
    for (self.cards.items, 0..) |card, i| {
        if (card.id == id) return i;
    }
    return null;
}

A  => client/RotatedRect.zig +130 -0
@@ 1,130 @@
const std = @import("std");
const math = std.math;
const testing = std.testing;
const raylib = @import("raylib");
const Color = raylib.Color;
const Vector2 = raylib.Vector2;
const util = @import("util.zig");
const RotatedRect = @This();

pos: Vector2,
size: Vector2,
rot: f32,

pub fn getCorners(self: RotatedRect) [4]Vector2 {
    const topleft_diff = (Vector2{ .x = -self.size.x / 2, .y = -self.size.y / 2 }).rotate(self.rot);
    const topleft = self.pos.add(topleft_diff);
    const topright_diff = (Vector2{ .x = self.size.x / 2, .y = -self.size.y / 2 }).rotate(self.rot);
    const topright = self.pos.add(topright_diff);
    const bottomleft_diff = (Vector2{ .x = -self.size.x / 2, .y = self.size.y / 2 }).rotate(self.rot);
    const bottomleft = self.pos.add(bottomleft_diff);
    const bottomright_diff = (Vector2{ .x = self.size.x / 2, .y = self.size.y / 2 }).rotate(self.rot);
    const bottomright = self.pos.add(bottomright_diff);
    return .{ topleft, topright, bottomright, bottomleft };
}

pub fn getSides(self: RotatedRect) [4][2]Vector2 {
    const corners = self.getCorners();
    return .{
        .{ corners[0], corners[1] },
        .{ corners[1], corners[2] },
        .{ corners[2], corners[3] },
        .{ corners[3], corners[0] },
    };
}

// This implementation checks if any of either rect's corners is inside the other rect.
// This should work for every case EXCEPT if you have oblong ships being placed on top of
// each other, instead of moving onto each other.
pub fn overlapsCorner(a: RotatedRect, b: RotatedRect) bool {
    const dist = a.pos.distance(b.pos);
    if (dist > @max(a.size.x + a.size.y, b.size.x + b.size.y)) return false;
    // Shrink them by 1 pixel each to prevent things right next to each other,
    // like station components, from colliding.
    var a_shrunk = a;
    a_shrunk.size.x -= 1;
    a_shrunk.size.y -= 1;
    var b_shrunk = b;
    b_shrunk.size.x -= 1;
    b_shrunk.size.y -= 1;
    for (a_shrunk.getCorners()) |a_corner| {
        if (b_shrunk.containsPoint(a_corner)) return true;
    }
    for (b_shrunk.getCorners()) |b_corner| {
        if (a_shrunk.containsPoint(b_corner)) return true;
    }
    return false;
}

// This implementation checks if any of either rect's sides can be used as a separating line.
// This should work for all cases.
pub fn overlaps(a: RotatedRect, b: RotatedRect) bool {
    const dist = a.pos.distance(b.pos);
    if (dist > @max(a.size.x + a.size.y, b.size.x + b.size.y)) return false;
    inline for (.{ a, b }) |rect| {
        for (rect.getSides()) |line| {
            const angle = line[0].angleTo(line[1]);
            const perpendicular = angle + @as(f32, math.pi) / 2;
            var a_min: f32 = math.inf(f32);
            var a_max: f32 = -math.inf(f32);
            inline for (a.getCorners()) |corner| {
                const rotated = corner.rotate(-perpendicular);
                a_min = @min(a_min, rotated.x);
                a_max = @max(a_max, rotated.x);
            }
            var b_min: f32 = math.inf(f32);
            var b_max: f32 = -math.inf(f32);
            inline for (b.getCorners()) |corner| {
                const rotated = corner.rotate(-perpendicular);
                b_min = @min(b_min, rotated.x);
                b_max = @max(b_max, rotated.x);
            }
            // Use 1 as an epsilon cause otherwise we sometimes can't build components
            // because it thinks they overlap the ones they're connecting to.
            if (a_max - 1 <= b_min or a_min + 1 >= b_max) return false;
        }
    }
    return true;
}

pub fn containsPoint(self: RotatedRect, point: Vector2) bool {
    // Strategy: un-rotate the point by the amount the rect is rotated,
    // then compare ignoring the rect's rotation.
    const diff = point.subtract(self.pos);
    const rotated_diff = diff.rotate(-self.rot);
    const rotated_point = point.subtract(diff).add(rotated_diff);
    return raylib.CheckCollisionPointRec(rotated_point, .{
        .x = self.pos.x - self.size.x / 2,
        .y = self.pos.y - self.size.y / 2,
        .width = self.size.x,
        .height = self.size.y,
    });
}

test "RotatedRect.containsPoint" {
    const rect = RotatedRect{ .pos = .{ .x = -72, .y = 0 }, .size = .{ .x = 72, .y = 72 }, .rot = 0 };
    var point = Vector2{ .x = -41, .y = 5 };
    try testing.expect(rect.containsPoint(point));
    point = Vector2{ .x = -31, .y = 5 };
    try testing.expect(!rect.containsPoint(point));
}

test "RotatedRect.overlaps" {
    // Two on the same spot.
    var a = RotatedRect{ .pos = .{ .x = 0, .y = 0 }, .size = .{ .x = 72, .y = 72 }, .rot = 0 };
    var b = RotatedRect{ .pos = .{ .x = 0, .y = 0 }, .size = .{ .x = 72, .y = 72 }, .rot = 0 };
    try testing.expect(a.overlaps(b));
    // One is half to the right.
    b.pos.x = 36;
    try testing.expect(a.overlaps(b));
    // One is off to the right.
    b.pos.x = 73;
    try testing.expect(!a.overlaps(b));
    // It's off to the right, but rotated so it should still overlap.
    b.rot = @as(f32, math.pi) / 8;
    try testing.expect(a.overlaps(b));
}

pub fn draw(self: RotatedRect, thickness: f32, color: Color) void {
    for (self.getSides()) |line| raylib.DrawLineEx(line[0], line[1], thickness, color);
}

A  => client/Scrap.zig +25 -0
@@ 1,25 @@
const raylib = @import("raylib");
const Rectangle = raylib.Rectangle;
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;

pos: Vector2,
amount: f32,

pub var texture: Texture = undefined;
pub const size = Vector2{ .x = 36, .y = 36 };

pub fn loadAssets() void {
    const file = @embedFile("images/scrap.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(self: @This()) void {
    const topleft = Vector2{ .x = self.pos.x - size.x / 2, .y = self.pos.y - size.y / 2 };
    raylib.DrawTextureV(texture, topleft, raylib.WHITE);
}

pub fn rect(self: @This()) Rectangle {
    return .{ .x = self.pos.x - size.x / 2, .y = self.pos.y - size.y / 2, .width = size.x, .height = size.y };
}

A  => client/Ship.zig +208 -0
@@ 1,208 @@
const std = @import("std");
const math = std.math;
const mem = std.mem;
const meta = std.meta;
const raylib = @import("raylib");
const Color = raylib.Color;
const Rectangle = raylib.Rectangle;
const Texture = raylib.Texture;
const Vector2 = raylib.Vector2;
const util = @import("util.zig");
const Card = @import("Card.zig");
const Component = @import("Component.zig");
const Entity = @import("Entity.zig");
const Meter = @import("Meter.zig");
const Gamestate = @import("Gamestate.zig");
const Hangar = @import("components/Hangar.zig");
const Probe = @import("ships/Probe.zig");
const Fighter = @import("ships/Fighter.zig");
const Bomber = @import("ships/Bomber.zig");
const Corvette = @import("ships/Corvette.zig");
const Pylon = @import("ships/Pylon.zig");
const PathfindMode = Entity.PathfindMode;
const Ship = @This();

const KindDetail = union(enum) {
    probe: Probe,
    fighter: Fighter,
    bomber: Bomber,
    corvette: Corvette,
    pylon: Pylon,
};
pub const Kind = meta.Tag(KindDetail);

pub fn DetailOfKind(comptime kind: Kind) type {
    const kind_name = @tagName(kind);
    for (@typeInfo(KindDetail).Union.fields) |field| {
        if (mem.eql(u8, field.name, kind_name)) return field.type;
    }
}

pub const Destination = union(enum) {
    pos: PosDestination,
    land: Entity.Id,
    pub fn toPos(self: Destination, gamestate: Gamestate) ?Vector2 {
        switch (self) {
            .pos => |p| return p.pos,
            .land => |id| {
                const hangar = gamestate.findEntity(id) orelse return null;
                if (hangar.kind == .component and hangar.kind.component.kind == .hangar)
                    return hangar.pos;
                return null;
            },
        }
    }
};
pub const PosDestination = struct {
    pos: Vector2,
    stay: bool,
};

destination: ?Destination = null,
kind: KindDetail,
time: ?u32 = null,
hangar: ?Entity.Id = null,

pub fn init(kind: Kind) Ship {
    return .{ .kind = switch (kind) {
        .probe => .{ .probe = .{} },
        .fighter => .{ .fighter = .{} },
        .bomber => .{ .bomber = .{} },
        .corvette => .{ .corvette = .{} },
        .pylon => .{ .pylon = .{} },
    } };
}

fn FromKindType(comptime p: type) type {
    const p_info = @typeInfo(p).Pointer;
    return if (p_info.is_const) *const Ship else *Ship;
}
pub fn fromKind(p: anytype) FromKindType(@TypeOf(p)) {
    const t = @typeInfo(@TypeOf(p)).Pointer.child;
    const kind = switch (t) {
        Probe => @fieldParentPtr(KindDetail, "probe", p),
        Fighter => @fieldParentPtr(KindDetail, "fighter", p),
        Bomber => @fieldParentPtr(KindDetail, "bomber", p),
        Corvette => @fieldParentPtr(KindDetail, "corvette", p),
        Pylon => @fieldParentPtr(KindDetail, "pylon", p),
        else => @compileError("invalid ship kind"),
    };
    return @fieldParentPtr(Ship, "kind", kind);
}

pub fn tick(self: *Ship, gamestate: *Gamestate) !void {
    if (self.time) |*time| {
        if (time.* == 0) {
            var entity = Entity.fromKind(self);
            entity.dead = true;
            return;
        }
        time.* -= 1;
    }
    switch (self.kind) {
        inline else => |*k| try k.tick(gamestate),
    }
}

pub fn draw(self: *const Ship) void {
    const e = Entity.fromKind(self);
    switch (self.kind) {
        .probe => Probe.draw(e.pos, e.rot),
        .fighter => Fighter.draw(e.pos, e.rot),
        .bomber => Bomber.draw(e.pos, e.rot),
        .corvette => Corvette.draw(e.pos, e.rot),
        .pylon => Pylon.draw(e.pos, e.rot),
    }
}

pub fn size(kind: Kind) Vector2 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).size,
    };
}

pub fn typeName(kind: Kind) []const u8 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).name,
    };
}
pub fn description(kind: Kind) []const u8 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).description,
    };
}
pub fn texture(kind: Kind) Texture {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).texture,
    };
}

pub fn hull(kind: Kind) f32 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).hull,
    };
}

pub fn cost(kind: Kind) f32 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).cost,
    };
}
pub fn target(self: Ship) ?Entity.Id {
    return switch (self.kind) {
        .fighter => |f| f.target,
        .bomber => |b| b.target,
        .corvette => |c| c.target,
        else => null,
    };
}
pub fn setTarget(self: *Ship, t: ?Entity.Id) void {
    self.destination = null;
    switch (self.kind) {
        .fighter => |*f| f.target = t,
        .bomber => |*b| b.target = t,
        .corvette => |*c| c.target = t,
        else => {},
    }
}
pub fn speed(kind: Kind) f32 {
    return switch (kind) {
        inline else => |k| DetailOfKind(k).speed,
    };
}
pub fn range(kind: Kind) ?f32 {
    return switch (kind) {
        .probe => null,
        inline else => |k| DetailOfKind(k).range,
    };
}

pub fn handleReachedDestination(self: *Ship, gamestate: *Gamestate) !void {
    const dest = self.destination orelse return;
    var entity = Entity.fromKind(self);
    switch (dest) {
        .pos => |d| {
            if (!d.stay and meta.eql(d.pos, entity.pos)) self.destination = null;
        },
        .land => |hangar_id| {
            const hangar_e = gamestate.findEntity(hangar_id) orelse return;
            if (hangar_e.pos.distance(entity.pos) >= Component.size.x * 1.5) return;
            const hangar_c = if (hangar_e.kind == .component) &hangar_e.kind.component else return;
            const hangar = if (hangar_c.kind == .hangar) &hangar_c.kind.hangar else return;
            if (hangar.ships.len < Hangar.capacity) {
                // Shove it somewhere it won't collide with anything.
                entity.pos = .{ .x = -1_000_000, .y = -1_000_000 };
                hangar.ships.append(entity.id) catch unreachable;
                self.destination = null;
                self.hangar = hangar_id;
            }
        },
    }
}

pub fn moveToward(self: *Ship, gamestate: *Gamestate, goal: Vector2, mode: PathfindMode) !void {
    var entity = Entity.fromKind(self);
    const step = try entity.pathfind(gamestate.*, goal, mode);
    if (step) |s| entity.move(gamestate.*, s);
    try self.handleReachedDestination(gamestate);
}

A  => client/Ui.zig +716 -0
@@ 1,716 @@
const std = @import("std");
const fmt = std.fmt;
const math = std.math;
const raylib = @import("raylib");
const Color = raylib.Color;
const Rectangle = raylib.Rectangle;
const Texture = raylib.Texture;
const Vector2 = raylib.Vector2;
const common = @import("common");
const PlayerId = common.PlayerId;
const consts = @import("consts.zig");
const game = @import("game.zig");
const globals = @import("globals.zig");
const ui_util = @import("ui.zig");
const util = @import("util.zig");
const Card = @import("Card.zig");
const Component = @import("Component.zig");
const Entity = @import("Entity.zig");
const Gamestate = @import("Gamestate.zig");
const Meter = @import("Meter.zig");
const RotatedRect = @import("RotatedRect.zig");
const Scrap = @import("Scrap.zig");
const Ship = @import("Ship.zig");
const Hangar = @import("components/Hangar.zig");
const ShieldGenerator = @import("components/ShieldGenerator.zig");
const Ui = @This();

camera: @import("raylib").Camera2D,
mode: Mode = .blank,
bookmarks: [10]?Entity.Id = [1]?Entity.Id{null} ** 10,
show_all_meters: bool = false,

pub const Mode = union(enum) {
    blank,
    select: Entity.Id,
    build_ship: Ship.Kind,
    build_component: Component.Kind,
    card: Card.Id,
};

const outline_thick: f32 = 2;
const selection_color = Color{ .r = 0, .g = 255, .b = 0, .a = 255 };
const target_color = Color{ .r = 255, .g = 0, .b = 0, .a = 255 };
const destination_color = Color{ .r = 255, .g = 255, .b = 0, .a = 255 };
const destination_size: f32 = 12;
const panel_color = raylib.GRAY;
const preview_color = destination_color;
const text_color = raylib.WHITE;
const ally_color = Color{ .r = 0, .g = 0, .b = 255, .a = 255 };
const red = Color{ .r = 255, .g = 0, .b = 0, .a = 255 };
const enemy_color = red;
const hull_color = Color{ .r = 0, .g = 255, .b = 0, .a = 255 };
const shield_color = raylib.BLUE;
const power_color = Color{ .r = 255, .g = 255, .b = 0, .a = 255 };
const range_color = Color{ .r = 0, .g = 0, .b = 255, .a = 255 };
const font_size = 16;
const info_font_size = 12;
const spacing = 2;
const meter_height = 20;
const left_panel_width = 200;
const bottom_panel_height = card_height + 2 * margin;
const margin = 6;
const card_width = 72;
const card_height = 101;
const tooltip_bar_size = Vector2{ .x = 36, .y = 12 };

pub fn mouseBoardPos(ui: Ui) ?Vector2 {
    var pos = raylib.GetMousePosition();
    if (pos.x < left_panel_width or pos.y > bottomPanelY()) return null;
    pos = pos.subtract(ui.camera.offset);
    pos = pos.scale(1 / ui.camera.zoom);
    pos = pos.add(ui.camera.target);
    return pos;
}

fn bottomPanelY() f32 {
    return @as(f32, @floatFromInt(raylib.GetScreenHeight())) - bottom_panel_height;
}

pub fn drawFrame(ui: *Ui, gamestate: Gamestate) !void {
    raylib.ClearBackground(.{ .r = 0, .g = 0, .b = 0, .a = 255 });
    ui.camera.Begin();
    for (gamestate.scraps.items) |s| s.draw();
    for (gamestate.artifacts.items) |a| a.draw();
    for (gamestate.missiles.items) |m| m.draw();
    for (gamestate.entities.items) |e| if (!e.dead) e.draw();
    for (gamestate.entities.items) |e| if (!e.dead) drawTimeRings(e);
    const selected_id = util.variant(ui.mode, .select);
    const selected = if (selected_id) |id| gamestate.findEntity(id) else null;
    if (selected) |entity| {
        drawOutline(entity.*, selection_color);
        if (util.variant(entity.kind, .ship)) |ship| {
            if (ship.destination) |d| {
                switch (d) {
                    .pos => |pos_d| drawDestinationMark(pos_d),
                    .land => |id| drawOutlineId(gamestate, id, destination_color),
                }
            }
        }
        if (entity.target()) |target| drawOutlineId(gamestate, target, target_color);
        if (entity.range()) |range| drawRangeOutline(entity.pos, range);
    }
    if (util.variant(ui.mode, .build_component)) |kind| {
        var station_rot: f32 = undefined;
        for (gamestate.entities.items) |e| {
            if (!e.dead and e.kind == .component) {
                station_rot = e.rot;
                break;
            }
        }
        var slots = try gamestate.findComponentSlots(kind == .connector);
        defer slots.deinit();
        var iter = slots.iterator();
        while (iter.next()) |slot| {
            const slot_rect = RotatedRect{
                .pos = slot.key_ptr.*,
                .rot = station_rot,
                .size = Component.size,
            };
            const pos = ui.mouseBoardPos();
            var color = preview_color;
            if (pos != null and slot_rect.containsPoint(pos.?)) color = selection_color;
            slot_rect.draw(outline_thick, color);
        }
    }
    if (ui.mode != .build_ship and ui.mode != .build_component) ui.handleBookmarkKeys();
    try ui.drawTooltips(gamestate);
    ui.camera.End();
    try ui.drawLeftPanel(gamestate);
    ui.drawBottomPanel(gamestate);
    drawGlobalEffects(gamestate);
    try drawNotifs(gamestate);
    try drawDialogue(gamestate);
    if (gamestate.over) {
        const center = util.screenSize().scale(0.5);
        ui_util.drawTextCentered("GAME OVER", center, 72, red);
    }
}

fn handleBookmarkKeys(ui: *Ui) void {
    const keys = [_]raylib.KeyboardKey{
        .KEY_ONE,
        .KEY_TWO,
        .KEY_THREE,
        .KEY_FOUR,
        .KEY_FIVE,
        .KEY_SIX,
        .KEY_SEVEN,
        .KEY_EIGHT,
        .KEY_NINE,
        .KEY_ZERO,
    };
    for (keys, 0..) |key, i| {
        if (!raylib.IsKeyPressed(key)) continue;
        if (raylib.IsKeyDown(.KEY_LEFT_SHIFT) or raylib.IsKeyDown(.KEY_RIGHT_SHIFT)) {
            // Bookmark a unit.
            const selected = util.variant(ui.mode, .select);
            if (selected) |id| {
                // First, remove duplicates.
                for (&ui.bookmarks) |*bookmark| {
                    if (bookmark.* == id) bookmark.* = null;
                }
            }
            ui.bookmarks[i] = selected;
        } else {
            // Select a bookmarked unit.
            ui.mode = if (ui.bookmarks[i]) |id| .{ .select = id } else .blank;
        }
    }
}

fn drawGlobalEffects(gamestate: Gamestate) void {
    var x: i32 = left_panel_width;
    const y = @as(i32, @intFromFloat(bottomPanelY())) - card_height;
    if (gamestate.scavenging_party > 0) {
        // XXX working around zig compiler bug
        if (gamestate.scavenging_party > Card.scavenging_party_duration)
            util.log("{}\n", .{Card.Kind.scavenging_party});
        raylib.DrawTexture(Card.Kind.scavenging_party.texture(), x, y, raylib.WHITE);
        const seconds = gamestate.scavenging_party / common.cycles_per_s;
        drawGlobalEffectTimer(seconds, x, y);
        x += card_width;
    }
    if (gamestate.shield_overcharge > 0) {
        // XXX working around zig compiler bug
        if (gamestate.shield_overcharge > Card.shield_overcharge_duration)
            util.log("{}\n", .{Card.Kind.shield_overcharge});
        raylib.DrawTexture(Card.Kind.shield_overcharge.texture(), x, y, raylib.WHITE);
        const seconds = gamestate.shield_overcharge / common.cycles_per_s;
        drawGlobalEffectTimer(seconds, x, y);
        x += card_width;
    }
}
fn drawGlobalEffectTimer(seconds: u32, x: i32, y: i32) void {
    var buf: [5]u8 = undefined;
    const text = fmt.bufPrintZ(&buf, "{d}", .{seconds}) catch unreachable;
    const dims = raylib.MeasureTextEx(ui_util.font, text.ptr, 24, ui_util.font_spacing);
    const top_left = Vector2{
        .x = @as(f32, @floatFromInt(x)) + card_width / 2 - dims.x / 2,
        .y = @as(f32, @floatFromInt(y)) + card_height - dims.y - 2,
    };
    raylib.DrawRectangleRec(.{
        .x = top_left.x - 2,
        .y = top_left.y - 2,
        .width = dims.x + 4,
        .height = dims.y + 4,
    }, raylib.BLACK);
    ui_util.drawText(text, top_left, 24, raylib.WHITE);
}

fn drawLeftPanel(ui: *Ui, gamestate: Gamestate) !void {
    raylib.DrawRectangle(0, 0, left_panel_width, raylib.GetScreenHeight(), panel_color);
    var pos = Vector2{ .x = margin, .y = margin };
    // Global meters.
    drawLeftPanelMeter(&pos, gamestate.shield, shield_color);
    drawLeftPanelMeter(&pos, gamestate.power, power_color);
    switch (ui.mode) {
        .select => |id| if (gamestate.findEntity(id)) |entity| try drawSelectionInfo(&pos, gamestate, entity),
        .build_component => |kind| ui.drawBuildComponentMenu(&pos, gamestate, kind),
        .build_ship => |kind| ui.drawBuildShipMenu(&pos, gamestate, kind),
        .card => |id| try drawCardInfo(&pos, gamestate, id),
        else => {},
    }
    // Scrap.
    const screen_height: f32 = @floatFromInt(raylib.GetScreenHeight());
    const scrap_y = screen_height - left_panel_width - Scrap.size.y - 2 * margin;
    raylib.DrawTextureV(Scrap.texture, .{ .x = margin, .y = scrap_y }, raylib.WHITE);
    var scrap_buf: [5]u8 = undefined;
    const scrap_str = fmt.bufPrintZ(&scrap_buf, "{d}", .{@round(gamestate.scrap)}) catch unreachable;
    const scrap_pos = Vector2{ .x = margin + Scrap.size.x, .y = scrap_y };
    ui_util.drawText(scrap_str, scrap_pos, font_size, text_color);
    try drawMinimap(ui, gamestate);
    // Unit bookmark indicators.
    if (ui.mode == .build_ship or ui.mode == .build_component) return;
    const bookmark_row_height = 40 + font_size + 4;
    const bookmark_y = scrap_y - 2 * bookmark_row_height;
    for (ui.bookmarks, 0..) |bookmark, i| {
        const id = bookmark orelse continue;
        const unit = gamestate.findEntity(id) orelse {
            ui.bookmarks[i] = null;
            continue;
        };
        const y = @as(f32, @floatFromInt(i / 5 * bookmark_row_height)) + bookmark_y;
        const x = @as(f32, @floatFromInt(i % 5 * 40));
        const rect = Rectangle{ .x = x, .y = y, .width = 36, .height = 36 };
        raylib.DrawRectangleRec(rect, raylib.BLACK);
        // Unit icon.
        const size = unit.size();
        const texture = unit.texture();
        const scale = @min(1.0, 30 / @max(size.x, size.y));
        util.drawTextureRotatedScaled(texture, rect.center(), size, -math.pi / 2.0, scale);
        // Bookmark number.
        const number_pos = Vector2{ .x = x + 18, .y = y - font_size / 2 };
        const digit = if (i == 9) 0 else i + 1;
        const numeral = fmt.digitToChar(@intCast(digit), undefined);
        const str = [_:0]u8{numeral};
        ui_util.drawTextCentered(&str, number_pos, font_size, text_color);
    }
}

fn drawLeftPanelName(pos: *Vector2, name: []const u8) void {
    ui_util.drawText(name, pos.*, font_size, text_color);
    pos.y += font_size + margin;
}

fn drawCardInfo(pos: *Vector2, gamestate: Gamestate, card_id: Card.Id) !void {
    const player = gamestate.findPlayer(globals.id).?;
    const index = player.findCardIndex(card_id) orelse return;
    const card = player.cards.items[index];
    drawLeftPanelName(pos, card.kind.name());
    pos.y += margin;
    const text_width = left_panel_width - 2 * margin;
    _ = try ui_util.drawTextWrapped(
        gamestate.alloc,
        card.kind.description(),
        pos.*,
        text_width,
        info_font_size,
        raylib.WHITE,
    );
}

fn drawSelectionInfo(pos: *Vector2, gamestate: Gamestate, entity: *Entity) !void {
    drawLeftPanelName(pos, entity.typeName());
    drawLeftPanelMeter(pos, entity.hull, hull_color);
    pos.y += margin + try ui_util.drawTextWrapped(
        gamestate.alloc,
        entity.description(),
        pos.*,
        left_panel_width - 2 * margin,
        info_font_size,
        raylib.WHITE,
    );
    if (entity.kind == .component) {
        pos.y += 2 * margin;
        switch (entity.kind.component.kind) {
            .hangar => |*h| try drawSelectionInfoHangar(pos, h, gamestate),
            else => {},
        }
    }
}

fn drawLeftPanelMeter(pos: *Vector2, value: Meter, color: Color) void {
    const bar_rect = .{
        .x = margin,
        .y = pos.y,
        .width = left_panel_width - 2 * margin,
        .height = meter_height,
    };
    drawBar(bar_rect, color, value.current / value.max);
    const text_pos = .{
        .x = bar_rect.x + bar_rect.width / 2,
        .y = bar_rect.y + bar_rect.height / 2 + 2,
    };
    var buf: [10]u8 = undefined;
    const str = value.fmt(&buf);
    ui_util.drawTextCentered(str, text_pos, font_size, raylib.BLACK);
    pos.y += meter_height + margin;
}

fn drawSelectionInfoHangarMeters(pos: *Vector2, ship: *const Entity) void {
    const hull_bar_rect = .{
        .x = margin,
        .y = pos.y,
        .width = left_panel_width / 2 - 2 * margin,
        .height = meter_height,
    };
    drawBar(hull_bar_rect, hull_color, ship.hull.current / ship.hull.max);
    var buf: [10]u8 = undefined;
    const hull_str = ship.hull.fmt(&buf);
    const hull_text_pos = .{
        .x = hull_bar_rect.x + hull_bar_rect.width / 2,
        .y = hull_bar_rect.y + hull_bar_rect.height / 2 + 2,
    };
    ui_util.drawTextCentered(hull_str, hull_text_pos, font_size, raylib.BLACK);
    pos.y += meter_height + margin;
}

fn drawSelectionInfoHangar(pos: *Vector2, hangar: *const Hangar, gamestate: Gamestate) !void {
    const hangar_id = Entity.fromKind(Component.fromKind(hangar)).id;
    for (hangar.ships.slice()) |ship_id| {
        const ship = gamestate.findEntity(ship_id).?;
        drawLeftPanelMeter(pos, ship.hull, hull_color);
        // Ship icon.
        const size = Ship.size(ship.kind.ship.kind);
        const scale = @min(1.0, 36 / @max(size.x, size.y));
        const texture = Ship.texture(ship.kind.ship.kind);
        const icon_rect = Rectangle{
            .x = margin,
            .y = pos.y,
            .width = 36,
            .height = 36,
        };
        raylib.DrawRectangleRec(icon_rect, raylib.BLACK);
        util.drawTextureRotatedScaled(texture, icon_rect.center(), size, -math.pi / 2.0, scale);
        // Launch button.
        var btn_rect = Rectangle{
            .x = left_panel_width / 3 * 2,
            .y = pos.y,
            .width = 36,
            .height = 36,
        };
        const mouse_pos = raylib.GetMousePosition();
        if (raylib.IsMouseButtonPressed(.MOUSE_BUTTON_LEFT) and raylib.CheckCollisionPointRec(mouse_pos, btn_rect)) {
            try game.sendAction(.{ .launch_from_hangar = .{ .hangar_id = hangar_id, .ship_id = ship_id } });
        }
        raylib.DrawTextureV(hangar_launch_btn_texture, btn_rect.topleft(), raylib.WHITE);
        pos.y += 36 + margin;
    }
}

fn drawBar(rect: Rectangle, color: Color, fill: f32) void {
    const outline = 2;
    raylib.DrawRectangleLinesEx(rect, outline, raylib.BLACK);
    const fill_rect = .{
        .x = rect.x + outline,
        .y = rect.y + outline,
        .width = (rect.width - 2 * outline) * fill,
        .height = rect.height - 2 * outline,
    };
    raylib.DrawRectangleRec(fill_rect, color);
}

fn drawBottomPanel(ui: *Ui, gamestate: Gamestate) void {
    const y_int: i32 = @intFromFloat(bottomPanelY());
    raylib.DrawRectangle(left_panel_width, y_int, raylib.GetScreenWidth(), bottom_panel_height, panel_color);
    const y = @as(f32, @floatFromInt(y_int)) + margin;
    // Cards.
    var x: f32 = left_panel_width + margin;
    const player = gamestate.findPlayer(globals.id).?;
    for (player.cards.items) |card| {
        // XXX This print is here to prevent zig from optimizing out the card images. Weird, I know.
        // We want a condition that's always false so it doesn't actually print, but we need Zig to not know that.
        if (ui.camera.zoom == 0) util.log("{}\n", .{card.kind});
        raylib.DrawTextureEx(card.kind.texture(), .{ .x = x, .y = y }, 0, 1, raylib.WHITE);
        if (raylib.IsMouseButtonPressed(.MOUSE_BUTTON_LEFT)) {
            const card_rect = Rectangle{
                .x = x,
                .y = y,
                .width = card_width,
                .height = card_height,
            };
            const pos = raylib.GetMousePosition();
            if (raylib.CheckCollisionPointRec(pos, card_rect)) {
                ui.mode = .{ .card = card.id };
            }
        }
        if (util.variant(ui.mode, .card) == card.id) {
            raylib.DrawRectangleLinesEx(.{
                .x = x - outline_thick,
                .y = y - outline_thick,
                .width = card_width + 2 * outline_thick,
                .height = card_height + 2 * outline_thick,
            }, outline_thick, selection_color);
        }
        x += card_width + margin;
    }
}

fn drawOutline(e: Entity, color: Color) void {
    e.rotatedRect().draw(outline_thick, color);
}

fn drawOutlineId(gamestate: Gamestate, id: Entity.Id, color: Color) void {
    const e = (gamestate.findEntity(id)) orelse return;
    drawOutline(e.*, color);
}

fn drawTimeRings(e: Entity) void {
    if (e.emp > 0) {
        const radius = @max(e.size().x, e.size().y);
        const fraction = @as(f32, @floatFromInt(e.emp)) / @as(f32, @floatFromInt(Card.emp_duration));
        const yellow = Color{ .r = 255, .g = 255, .b = 0, .a = 255 };
        raylib.DrawRing(e.pos, radius, radius + 1, 0, 360 * fraction, 0, yellow);
    }
    const ship = util.variant(e.kind, .ship) orelse return;
    if (ship.time) |time| {
        // Add 2 to radius so this ring doesn't collide with EMP ring.
        const radius = @max(Ship.size(ship.kind).x, Ship.size(ship.kind).y) + 2;
        const fraction = @as(f32, @floatFromInt(time)) / @as(f32, @floatFromInt(Card.distress_call_duration));
        const cyan = Color{ .r = 0, .g = 255, .b = 255, .a = 255 };
        raylib.DrawRing(e.pos, radius, radius + 1, 0, 360 * fraction, 0, cyan);
    }
}

fn drawDestinationMark(d: Ship.PosDestination) void {
    const topleft = Vector2{ .x = d.pos.x - destination_size / 2, .y = d.pos.y - destination_size / 2 };
    const topright = Vector2{ .x = d.pos.x + destination_size / 2, .y = d.pos.y - destination_size / 2 };
    const bottomleft = Vector2{ .x = d.pos.x - destination_size / 2, .y = d.pos.y + destination_size / 2 };
    const bottomright = Vector2{ .x = d.pos.x + destination_size / 2, .y = d.pos.y + destination_size / 2 };
    raylib.DrawLineEx(topleft, bottomright, outline_thick, destination_color);
    raylib.DrawLineEx(topright, bottomleft, outline_thick, destination_color);
    if (d.stay) raylib.DrawRectangleLinesEx(.{
        .x = topleft.x,
        .y = topleft.y,
        .width = destination_size,
        .height = destination_size,
    }, outline_thick, destination_color);
}

fn drawRangeOutline(pos: Vector2, range: f32) void {
    raylib.DrawCircleLines(@intFromFloat(pos.x), @intFromFloat(pos.y), range, range_color);
}

fn drawTooltips(ui: Ui, gamestate: Gamestate) !void {
    var pos = ui.mouseBoardPos() orelse return;
    for (gamestate.scraps.items) |s| {
        if (raylib.CheckCollisionPointRec(pos, s.rect())) {
            var buf: [4]u8 = undefined;
            const str = fmt.bufPrintZ(&buf, "{d}", .{@round(s.amount)}) catch unreachable;
            pos.y -= font_size;
            ui_util.drawText(str, pos, font_size, text_color);
            break;
        }
    }
    for (gamestate.entities.items) |*e| {
        if (e.dead) continue;
        if (ui.show_all_meters or e.rotatedRect().containsPoint(pos)) {
            var rect = .{
                .x = e.pos.x - tooltip_bar_size.x / 2,
                .y = e.pos.y - tooltip_bar_size.y,
                .width = tooltip_bar_size.x,
                .height = tooltip_bar_size.y,
            };
            drawBar(rect, hull_color, e.hull.current / e.hull.max);
        }
    }
    if (util.variant(ui.mode, .card)) |card_id| {
        const player = gamestate.findPlayer(globals.id).?;
        const card = player.findCard(card_id) orelse return;
        switch (card.kind) {
            .emp => drawRangeOutline(pos, Card.emp_range),
            .scrap_magnet => drawRangeOutline(pos, Card.scrap_magnet_range),
            else => {},
        }
    }
}

const BuildMenuItem = struct {
    name: []const u8,
    texture: raylib.Texture,
    size: Vector2,
    cost: f32,
    selected: bool,
    result: Mode,
    fn fromComponent(comptime kind: Component.Kind, selected: Component.Kind) BuildMenuItem {
        return .{
            .name = Component.typeName(kind),
            .cost = Component.cost(kind),
            .texture = Component.DetailOfKind(kind).texture,
            .size = Component.size,
            .selected = kind == selected,
            .result = .{ .build_component = kind },
        };
    }
    fn fromShip(comptime kind: Ship.Kind, selected: Ship.Kind) BuildMenuItem {
        return .{
            .name = Ship.typeName(kind),
            .cost = Ship.cost(kind),
            .texture = Ship.DetailOfKind(kind).texture,
            .size = Ship.size(kind),
            .selected = kind == selected,
            .result = .{ .build_ship = kind },
        };
    }
};

fn drawBuildShipMenu(ui: *Ui, pos: *Vector2, gamestate: Gamestate, selected: Ship.Kind) void {
    ui.drawBuildMenuItem(gamestate, &pos.y, BuildMenuItem.fromShip(.probe, selected), .KEY_ONE);
    ui.drawBuildMenuItem(gamestate, &pos.y, BuildMenuItem.fromShip(.fighter, selected), .KEY_TWO);
    ui.drawBuildMenuItem(gamestate, &pos.y, BuildMenuItem.fromShip(.bomber, selected), .KEY_THREE);
    ui.drawBuildMenuItem(gamestate, &pos.y, BuildMenuItem.fromShip(.corvette, selected), .KEY_FOUR);
    ui.drawBuildMenuItem(gamestate, &pos.y, BuildMenuItem.fromShip(.pylon, selected), .KEY_FIVE);
}
fn drawBuildComponentMenu(ui: *Ui, pos: *Vector2, gamestate: Gamestate, selected: Component.Kind) void {
    ui.drawBuildMenuItem(gamestate, &pos.y, BuildMenuItem.fromComponent(.connector, selected), .KEY_ONE);
    ui.drawBuildMenuItem(gamestate, &pos.y, BuildMenuItem.fromComponent(.power_generator, selected), .KEY_TWO);
    ui.drawBuildMenuItem(gamestate, &pos.y, BuildMenuItem.fromComponent(.shield_generator, selected), .KEY_THREE);
    ui.drawBuildMenuItem(gamestate, &pos.y, BuildMenuItem.fromComponent(.laser_turret, selected), .KEY_FOUR);
    ui.drawBuildMenuItem(gamestate, &pos.y, BuildMenuItem.fromComponent(.missile_turret, selected), .KEY_FIVE);
    ui.drawBuildMenuItem(gamestate, &pos.y, BuildMenuItem.fromComponent(.engine, selected), .KEY_SIX);
    ui.drawBuildMenuItem(gamestate, &pos.y, BuildMenuItem.fromComponent(.hangar, selected), .KEY_SEVEN);
}
fn drawBuildMenuItem(ui: *Ui, gamestate: Gamestate, y: *f32, item: BuildMenuItem, key: raylib.KeyboardKey) void {
    const rect = .{
        .x = margin,
        .y = y.*,
        .width = left_panel_width - margin * 2,
        .height = font_size + 36 + margin * 3,
    };
    raylib.DrawRectangleRec(rect, raylib.BLACK);
    y.* += margin;
    ui_util.drawText(item.name, .{ .x = margin * 2, .y = y.* }, font_size, text_color);
    y.* += font_size + margin;
    raylib.DrawTextureV(Scrap.texture, .{ .x = margin * 2, .y = y.* }, raylib.WHITE);
    var buf: [4]u8 = undefined;
    const str = fmt.bufPrintZ(&buf, "{d}", .{item.cost}) catch unreachable;
    const cost_color = if (item.cost > gamestate.scrap) red else text_color;
    ui_util.drawText(str, .{ .x = 3 * margin + Scrap.size.x, .y = y.* }, font_size, cost_color);
    const item_size = @max(item.size.x, item.size.y);
    const scale = 36.0 / item_size;
    util.drawTextureRotatedScaled(item.texture, .{ .x = 136, .y = y.* + 18 }, item.size, -math.pi / 2.0, scale);
    y.* += 36 + margin;
    if (item.selected) raylib.DrawRectangleLinesEx(rect, outline_thick, selection_color);
    if (raylib.IsKeyDown(key)) ui.mode = item.result;
    if (raylib.IsMouseButtonPressed(.MOUSE_BUTTON_LEFT)) {
        const pos = raylib.GetMousePosition();
        if (raylib.CheckCollisionPointRec(pos, rect)) ui.mode = item.result;
    }
    y.* += margin;
}

pub fn scroll(ui: *Ui, by: Vector2) void {
    const scaled = by.scale(1 / ui.camera.zoom);
    ui.camera.target = ui.camera.target.add(scaled);
}

fn drawNotifs(gamestate: Gamestate) !void {
    const notif_width = 216;
    const screen_width: f32 = @floatFromInt(raylib.GetScreenWidth());
    var pos = Vector2{ .x = screen_width - notif_width, .y = bottomPanelY() };
    var i = gamestate.notifs.items.len;
    while (i > 0) : (i -= 1) {
        const n = gamestate.notifs.items[i - 1];
        const height = try ui_util.drawTextWrapped(
            gamestate.alloc,
            n.message,
            null,
            notif_width,
            font_size,
            text_color,
        ) + margin;
        const bg_color = .{ .r = 0, .g = 0, .b = 0, .a = 127 };
        pos.y -= height;
        raylib.DrawRectangleV(pos, .{ .x = notif_width, .y = height }, bg_color);
        _ = try ui_util.drawTextWrapped(gamestate.alloc, n.message, pos, notif_width, font_size, text_color);
    }
}

fn drawDialogue(gamestate: Gamestate) !void {
    const dialogue = gamestate.dialogue orelse return;
    const msg_width = 432;
    const height = try ui_util.drawTextWrapped(
        gamestate.alloc,
        dialogue,
        null,
        msg_width,
        font_size,
        text_color,
    );
    const bg_color = .{ .r = 0, .g = 0, .b = 0, .a = 127 };
    var pos = Vector2{
        .x = (@as(f32, @floatFromInt(raylib.GetScreenWidth())) - msg_width) / 2,
        .y = 0,
    };
    raylib.DrawRectangleV(pos, .{ .x = msg_width, .y = height + margin }, bg_color);
    pos = .{ .x = pos.x + margin, .y = pos.y + margin };
    _ = try ui_util.drawTextWrapped(gamestate.alloc, dialogue, pos, msg_width - 2 * margin, font_size, text_color);
}

pub fn centerOn(ui: *Ui, pos: Vector2) void {
    ui.camera.target = .{
        .x = pos.x - left_panel_width / 2,
        .y = pos.y + bottom_panel_height / 2,
    };
}

fn drawMinimap(ui: *Ui, gamestate: Gamestate) !void {
    const y = @as(f32, @floatFromInt(raylib.GetScreenHeight())) - left_panel_width;
    // Background.
    const rect = Rectangle{
        .x = 0,
        .y = y,
        .width = left_panel_width,
        .height = left_panel_width,
    };
    raylib.DrawRectangleRec(rect, raylib.BLACK);
    const mouse_pos = raylib.GetMousePosition();
    if (rect.containsPoint(mouse_pos)) {
        const pos = minimapToBoard(mouse_pos);
        if (raylib.IsMouseButtonDown(.MOUSE_BUTTON_LEFT)) ui.camera.target = pos;
        set_destination: {
            if (raylib.IsMouseButtonPressed(.MOUSE_BUTTON_RIGHT)) {
                const selected_id = util.variant(ui.mode, .select) orelse break :set_destination;
                const selected = gamestate.findEntity(selected_id) orelse break :set_destination;
                if (selected.kind != .ship) break :set_destination;
                const stay = raylib.IsKeyDown(.KEY_LEFT_CONTROL) or raylib.IsKeyDown(.KEY_RIGHT_CONTROL);
                try game.sendAction(.{ .move = .{ .id = selected_id, .x = pos.x, .y = pos.y, .stay = stay } });
            }
        }
    }
    // Viewport outline.
    const viewport_center = Vector2{
        .x = ui.camera.target.x + left_panel_width / 2,
        .y = ui.camera.target.y - bottom_panel_height / 2,
    };
    const outline_center = boardToMinimap(viewport_center);
    const viewport_size = Vector2{
        .x = util.screenSize().x - left_panel_width,
        .y = util.screenSize().y - bottom_panel_height,
    };
    const outline_size = viewport_size.scale(minimap_scale / ui.camera.zoom);
    raylib.DrawRectangleLines(
        @intFromFloat(outline_center.x - outline_size.x / 2),
        @intFromFloat(outline_center.y - outline_size.y / 2),
        @intFromFloat(outline_size.x),
        @intFromFloat(outline_size.y),
        raylib.WHITE,
    );
    // Entities.
    for (gamestate.entities.items) |e| {
        if (e.dead) continue;
        switch (e.kind) {
            .component => drawMinimapItem(e.pos, ally_color, 4),
            .ship => drawMinimapItem(e.pos, ally_color, 2),
            .enemy => |enemy| drawMinimapItem(e.pos, enemy_color, switch (enemy.kind) {
                .drone, .fighter, .bomber, .sniper => 2,
                .corvette => 3,
                .frigate, .factory, .destroyer => 4,
                .battleship => 5,
            }),
        }
    }
    for (gamestate.scraps.items) |s| {
        drawMinimapItem(s.pos, raylib.BROWN, 2);
    }
}

const minimap_scale = @as(f32, @floatFromInt(left_panel_width)) / 2592;
fn boardToMinimap(pos: Vector2) Vector2 {
    const screen_height: f32 = @floatFromInt(raylib.GetScreenHeight());
    const minimap_center = .{ .x = left_panel_width / 2, .y = screen_height - left_panel_width / 2 };
    return pos.scale(minimap_scale).add(minimap_center);
}
fn minimapToBoard(pos: Vector2) Vector2 {
    const screen_height: f32 = @floatFromInt(raylib.GetScreenHeight());
    const minimap_center = .{ .x = left_panel_width / 2, .y = screen_height - left_panel_width / 2 };
    return pos.subtract(minimap_center).scale(1 / minimap_scale);
}
fn drawMinimapItem(pos: Vector2, color: Color, size: i32) void {
    const pos_on_map = boardToMinimap(pos);
    const topleft_x = @as(i32, @intFromFloat(pos_on_map.x)) - @divTrunc(size, 2);
    const topleft_y = @as(i32, @intFromFloat(pos_on_map.y)) - @divTrunc(size, 2);
    raylib.DrawRectangle(topleft_x, topleft_y, size, size, color);
}

var hangar_repair_btn_texture: Texture = undefined;
var hangar_launch_btn_texture: Texture = undefined;
pub fn loadAssets() void {
    const repair_btn_file = @embedFile("images/hangar-repair-btn.png");
    var repair_btn_image = raylib.LoadImageFromMemory(".png", repair_btn_file, repair_btn_file.len);
    hangar_repair_btn_texture = raylib.LoadTextureFromImage(repair_btn_image);
    const launch_btn_file = @embedFile("images/hangar-launch-btn.png");
    var launch_btn_image = raylib.LoadImageFromMemory(".png", launch_btn_file, launch_btn_file.len);
    hangar_launch_btn_texture = raylib.LoadTextureFromImage(launch_btn_image);
}

A  => client/Vector2HashMapContext.zig +19 -0
@@ 1,19 @@
const std = @import("std");
const meta = std.meta;
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;

pub fn eql(ctx: @This(), a: Vector2, b: Vector2) bool {
    _ = ctx;
    const a_x: i32 = @intFromFloat(a.x);
    const a_y: i32 = @intFromFloat(a.y);
    const b_x: i32 = @intFromFloat(b.x);
    const b_y: i32 = @intFromFloat(b.y);
    return a_x == b_x and a_y == b_y;
}
pub fn hash(ctx: @This(), v: Vector2) u64 {
    _ = ctx;
    const x: i64 = @intFromFloat(v.x);
    const y: i64 = @intFromFloat(v.y);
    return @bitCast((x << 32) & y);
}

A  => client/action.zig +109 -0
@@ 1,109 @@
const std = @import("std");
const io = std.io;
const Allocator = std.mem.Allocator;
const ArrayList = std.ArrayList;
const Card = @import("Card.zig");
const Component = @import("Component.zig");
const Entity = @import("Entity.zig");
const Ship = @import("Ship.zig");
const Engine = @import("components/Engine.zig");
const ShieldGenerator = @import("components/ShieldGenerator.zig");

pub const Action = union(enum) {
    quit,
    pause,
    move: Move,
    assign_target: AssignTarget,
    set_power: SetPowerOrShield,
    set_shield: SetPowerOrShield,
    engine_control: EngineControl,
    build_ship: BuildShip,
    build_component: BuildComponent,
    play_card: PlayCard,
    land_in_hangar: HangarOp,
    launch_from_hangar: HangarOp,
    pub const Move = packed struct {
        id: Entity.Id,
        x: f32,
        y: f32,
        stay: bool,
    };
    pub const AssignTarget = packed struct {
        id: Entity.Id,
        target: Entity.Id,
    };
    pub const SetPowerOrShield = packed struct {
        id: Entity.Id,
        on: bool,
    };
    pub const EngineControl = packed struct {
        state: Engine.State,
    };
    pub const BuildShip = packed struct {
        kind: Ship.Kind,
        x: f32,
        y: f32,
    };
    pub const BuildComponent = packed struct {
        kind: Component.Kind,
        id: Entity.Id,
        angle: f32,
    };
    pub const PlayCard = packed struct {
        id: Card.Id,
        // We want to have a union of target and x/y here, but unions don't work with writeStruct.
        target: Entity.Id = 0,
        x: f32 = 0,
        y: f32 = 0,
    };
    pub const HangarOp = packed struct {
        hangar_id: Entity.Id,
        ship_id: Entity.Id,
    };
    // BuildComponent is longest, probably cause writeStruct represents Component.Kind as usize.
    pub const max_len: usize = 17;
    pub fn toBytes(self: Action, alloc: Allocator) ![]u8 {
        var bytes = try ArrayList(u8).initCapacity(alloc, max_len);
        defer bytes.deinit();
        var writer = bytes.writer();
        switch (self) {
            .quit => writer.writeByte(0) catch unreachable,
            .pause => writer.writeByte(1) catch unreachable,
            .move => |info| serializeInfo(writer, 2, info),
            .assign_target => |info| serializeInfo(writer, 3, info),
            .set_power => |info| serializeInfo(writer, 4, info),
            .set_shield => |info| serializeInfo(writer, 5, info),
            .engine_control => |info| serializeInfo(writer, 6, info),
            .build_ship => |info| serializeInfo(writer, 7, info),
            .build_component => |info| serializeInfo(writer, 8, info),
            .play_card => |info| serializeInfo(writer, 9, info),
            .land_in_hangar => |info| serializeInfo(writer, 10, info),
            .launch_from_hangar => |info| serializeInfo(writer, 11, info),
        }
        return bytes.toOwnedSlice();
    }
    // TODO writeStruct is endian-sensitive, isn't it?
    fn serializeInfo(w: anytype, type_tag: u8, info: anytype) void {
        w.writeByte(type_tag) catch unreachable;
        w.writeStruct(info) catch unreachable;
    }
    pub fn fromBytes(bytes: []const u8) !Action {
        var fbs = io.fixedBufferStream(bytes);
        var reader = fbs.reader();
        return switch (try reader.readByte()) {
            0 => Action.quit,
            1 => Action.pause,
            2 => Action{ .move = try reader.readStruct(Move) },
            3 => Action{ .assign_target = try reader.readStruct(AssignTarget) },
            4 => Action{ .set_power = try reader.readStruct(SetPowerOrShield) },
            5 => Action{ .set_shield = try reader.readStruct(SetPowerOrShield) },
            6 => Action{ .engine_control = try reader.readStruct(EngineControl) },
            7 => Action{ .build_ship = try reader.readStruct(BuildShip) },
            8 => Action{ .build_component = try reader.readStruct(BuildComponent) },
            9 => Action{ .play_card = try reader.readStruct(PlayCard) },
            10 => Action{ .land_in_hangar = try reader.readStruct(HangarOp) },
            11 => Action{ .launch_from_hangar = try reader.readStruct(HangarOp) },
            else => error.InvalidActionType,
        };
    }
};

A  => client/ally_helpers.zig +33 -0
@@ 1,33 @@
const util = @import("util.zig");
const Entity = @import("Entity.zig");
const Gamestate = @import("Gamestate.zig");

pub fn laserTargetDesirability(target: Entity) ?f32 {
    const enemy = util.variant(target.kind, .enemy) orelse return null;
    return switch (enemy.kind) {
        .drone => return 9,
        .bomber => return 8,
        .fighter => return 7,
        .sniper => return 6,
        .corvette => return 5,
        .destroyer => return 4,
        .frigate => return 3,
        .factory => return 2,
        .battleship => return 1,
    };
}

pub fn missileTargetDesirability(target: Entity) ?f32 {
    const enemy = util.variant(target.kind, .enemy) orelse return null;
    return switch (enemy.kind) {
        .destroyer => return 9,
        .battleship => return 8,
        .frigate => return 7,
        .factory => return 6,
        .corvette => return 5,
        .sniper => return 4,
        .bomber => return 3,
        .fighter => return 2,
        .drone => return 1,
    };
}

A  => client/artifact.zig +25 -0
@@ 1,25 @@
const Laser = @import("artifacts/Laser.zig");
const Explosion = @import("artifacts/Explosion.zig");

pub const Artifact = union(enum) {
    laser: Laser,
    explosion: Explosion,
    pub fn draw(self: Artifact) void {
        return switch (self) {
            .laser => |l| l.draw(),
            .explosion => |e| e.draw(),
        };
    }
    pub fn tick(self: *Artifact) void {
        return switch (self.*) {
            .laser => |*l| l.tick(),
            .explosion => |*e| e.tick(),
        };
    }
    pub fn isDone(self: Artifact) bool {
        return switch (self) {
            .laser => |l| l.isDone(),
            .explosion => |e| e.isDone(),
        };
    }
};

A  => client/artifacts/Explosion.zig +41 -0
@@ 1,41 @@
const raylib = @import("raylib");
const Color = raylib.Color;
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;
const common = @import("common");
const Explosion = @This();

pub const Size = enum { small, medium, big };

size: Size,
elapsed: u32 = 0,
pos: Vector2,

var small_texture: Texture = undefined;
var medium_texture: Texture = undefined;

pub fn loadAssets() void {
    const small_file = @embedFile("../images/explosions/small-1.png");
    var small_image = raylib.LoadImageFromMemory(".png", small_file, small_file.len);
    small_texture = raylib.LoadTextureFromImage(small_image);
    const medium_file = @embedFile("../images/explosions/medium-1.png");
    var medium_image = raylib.LoadImageFromMemory(".png", medium_file, medium_file.len);
    medium_texture = raylib.LoadTextureFromImage(medium_image);
}

pub fn draw(self: Explosion) void {
    const pos = .{ .x = self.pos.x - 24, .y = self.pos.y - 24 };
    raylib.DrawTextureV(switch (self.size) {
        .small => small_texture,
        .medium => medium_texture,
        .big => medium_texture,
    }, pos, raylib.WHITE);
}

pub fn tick(self: *Explosion) void {
    self.elapsed += 1;
}

pub fn isDone(self: Explosion) bool {
    return self.elapsed == common.cycles_per_s / 2;
}

A  => client/artifacts/Laser.zig +25 -0
@@ 1,25 @@
const raylib = @import("raylib");
const Color = raylib.Color;
const Vector2 = raylib.Vector2;
const Laser = @This();

time: u32,
elapsed: u32 = 0,
thickness: f32,
color: Color,
origin: Vector2,
target: Vector2,

pub fn draw(self: Laser) void {
    var color = self.color;
    color.a = @truncate(255 * (self.time - self.elapsed) / self.time);
    raylib.DrawLineEx(self.origin, self.target, self.thickness, color);
}

pub fn tick(self: *Laser) void {
    self.elapsed += 1;
}

pub fn isDone(self: Laser) bool {
    return self.elapsed == self.time;
}

A  => client/binding.js +9 -0
@@ 1,9 @@
mergeInto(LibraryManager.library, {
    jsTimestamp: function jsTimestamp() {
        return (new Date()).getTime();
    },
    websocketSend: function websocketSend(ptr, len) {
        const bytes = new Uint8Array(Module.asm.memory.buffer, ptr, len);
        websocket.send(bytes);
    },
});

A  => client/components/Connector.zig +19 -0
@@ 1,19 @@
const raylib = @import("raylib");
const Texture = raylib.Texture;
const Vector2 = raylib.Vector2;
const util = @import("../util.zig");
const Component = @import("../Component.zig");

pub const name = "Connector";
pub const description = "All components must be connected to a Connector.";
pub var texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/components/connector.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, Component.size, rot);
}

A  => client/components/Engine.zig +95 -0
@@ 1,95 @@
const std = @import("std");
const math = std.math;
const mem = std.mem;
const meta = std.meta;
const AutoHashMap = std.AutoHashMap;
const raylib = @import("raylib");
const Texture = raylib.Texture;
const Vector2 = raylib.Vector2;
const common = @import("common");
const util = @import("../util.zig");
const Component = @import("../Component.zig");
const Entity = @import("../Entity.zig");
const Gamestate = @import("../Gamestate.zig");
const RotatedRect = @import("../RotatedRect.zig");
const Engine = @This();

pub const name = "Engine";
pub const description = "Rotates the station. Controls are Z/X/C. Consumes 1 power per second while active. More effective if farther from the station's center of mass.";
pub const State = enum(u8) { ccw, off, cw };
const force = @as(f32, 1) / common.cycles_per_s;

pub var texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/components/engine.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, Component.size, rot);
}

pub fn tick(self: *Engine, gamestate: *Gamestate) !void {
    if (gamestate.engine_state == .off) return;
    const c = Component.fromKind(self);
    const cost = @as(f32, 1) / common.cycles_per_s;
    if (!gamestate.power.consume(cost)) return;
    try rotateStation(c, gamestate.*, if (gamestate.engine_state == .ccw) -1 else 1);
}

const MassInfo = struct {
    center: Vector2,
    mass: f32,
};

fn stationMassInfo(gamestate: Gamestate) !MassInfo {
    var total = Vector2{ .x = 0, .y = 0 };
    var count: f32 = 0;
    for (gamestate.entities.items) |e| {
        if (!e.dead and e.kind == .component) {
            total = total.add(e.pos);
            count += 1;
        }
    }
    const center = total.scale(1 / count);
    return .{ .center = center, .mass = count };
}

fn rotateStation(self: *Component, gamestate: Gamestate, direction: f32) !void {
    const mass_info = try stationMassInfo(gamestate);
    const self_e = Entity.fromKind(self);
    const applied_force = force * direction * self_e.pos.distance(mass_info.center) / Component.size.x;
    const rotation_amount = applied_force / mass_info.mass;
    var moves = AutoHashMap(Entity.Id, Vector2).init(gamestate.alloc);
    defer moves.deinit();
    for (gamestate.entities.items) |e| {
        if (!e.dead and e.kind == .component) {
            const diff = e.pos.subtract(mass_info.center);
            const new_diff = diff.rotate(rotation_amount);
            try moves.put(e.id, mass_info.center.add(new_diff));
        }
    }
    var value_iter = moves.valueIterator();
    // All connected components must have the same rotation.
    const new_rot = self_e.rot + rotation_amount;
    // Check if any of the components' new positions would be blocked.
    while (value_iter.next()) |new_pos| {
        const new_rect = RotatedRect{ .pos = new_pos.*, .rot = new_rot, .size = Component.size };
        // Check for things blocking its new position.
        for (gamestate.entities.items) |blocker| {
            // Skip them if they're part of the station.
            if (blocker.dead or moves.contains(blocker.id)) continue;
            // Rotation blocked.
            if (blocker.rotatedRect().overlapsCorner(new_rect)) return;
        }
    }
    // Rotation not blocked. Do it.
    var iter = moves.iterator();
    while (iter.next()) |entry| {
        var entity = gamestate.findEntity(entry.key_ptr.*).?;
        entity.pos = entry.value_ptr.*;
        entity.rot += rotation_amount;
    }
}

A  => client/components/Hangar.zig +44 -0
@@ 1,44 @@
const std = @import("std");
const mem = std.mem;
const meta = std.meta;
const BoundedArray = std.BoundedArray;
const raylib = @import("raylib");
const Texture = raylib.Texture;
const Vector2 = raylib.Vector2;
const common = @import("common");
const util = @import("../util.zig");
const Component = @import("../Component.zig");
const Entity = @import("../Entity.zig");
const Gamestate = @import("../Gamestate.zig");
const Hangar = @This();

pub const name = "Hangar";
pub const description = "Can store up to 4 ships. Repairs them 1/3 hull per second, at the cost of 1 power per second each.";
pub const capacity = 4;
pub const repair_rate = @as(f32, 1) / 3;

on: bool = true,
ships: BoundedArray(Entity.Id, capacity) = .{ .buffer = undefined, .len = 0 },

pub var texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/components/hangar.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, Component.size, rot);
}

pub fn tick(self: *const Hangar, gamestate: *Gamestate) void {
    if (!self.on) return;
    for (self.ships.slice()) |id| {
        const ship = gamestate.findEntity(id).?;
        if (ship.hull.current == ship.hull.max) continue;
        const cost = 1 / @as(f32, common.cycles_per_s);
        if (gamestate.power.consume(cost))
            ship.hull.fill(repair_rate / common.cycles_per_s);
    }
}

A  => client/components/LaserTurret.zig +97 -0
@@ 1,97 @@
const std = @import("std");
const math = std.math;
const meta = std.meta;
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;
const common = @import("common");
const ally_helpers = @import("../ally_helpers.zig");
const util = @import("../util.zig");
const Component = @import("../Component.zig");
const Enemy = @import("../Enemy.zig");
const Entity = @import("../Entity.zig");
const Gamestate = @import("../Gamestate.zig");
const Target = Entity.Target;
const LaserTurret = @This();

pub const name = "Laser Turret";
pub const description = "Fires 1-damage laser every second. Consumes 1 power per shot.";
const cooldown = common.cycles_per_s;
const damage = 1;
pub const range = 324;

cooldown: u32 = 0,
target: ?Entity.Id = null,
on: bool = true,
barrel_angle: f32 = 0,

pub var texture: Texture = undefined;
var barrel_on_texture: Texture = undefined;
var barrel_off_texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/components/laser-turret.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
    const barrel_on_file = @embedFile("../images/components/laser-turret-barrel-on.png");
    var barrel_on_image = raylib.LoadImageFromMemory(".png", barrel_on_file, barrel_on_file.len);
    barrel_on_texture = raylib.LoadTextureFromImage(barrel_on_image);
    const barrel_off_file = @embedFile("../images/components/laser-turret-barrel-off.png");
    var barrel_off_image = raylib.LoadImageFromMemory(".png", barrel_off_file, barrel_off_file.len);
    barrel_off_texture = raylib.LoadTextureFromImage(barrel_off_image);
}

pub fn draw(self: *const LaserTurret) void {
    const entity = Entity.fromKind(Component.fromKind(self));
    util.drawTextureRotated(Component.base_texture, entity.pos, Component.size, entity.rot);
    const barrel_texture = if (self.on) barrel_on_texture else barrel_off_texture;
    util.drawTextureRotated(barrel_texture, entity.pos, Component.size, entity.rot + self.barrel_angle);
}

pub fn tick(self: *LaserTurret, gamestate: *Gamestate) !void {
    if (self.cooldown > 0) {
        self.cooldown -= 1;
        return;
    }
    if (!self.on) return;
    if (self.target) |id| {
        if (gamestate.findEntity(id)) |enemy| {
            const entity = Entity.fromKind(Component.fromKind(self));
            if (try entity.canSee(gamestate.*, enemy.*, range)) |hit_point| {
                try self.shootAt(gamestate, .{ .entity = enemy, .hit_point = hit_point });
            } else {
                // If the target still exists but is out of sight,
                // shoot something else for now but remember the target.
                try self.autoTarget(gamestate);
            }
        } else {
            // If the target no longer exists, clear it and auto-target.
            self.target = null;
            try self.autoTarget(gamestate);
        }
    } else {
        try self.autoTarget(gamestate);
    }
}

fn autoTarget(self: *LaserTurret, gamestate: *Gamestate) !void {
    const entity = Entity.fromKind(Component.fromKind(self));
    const target = try entity.findTarget(gamestate.*, range, ally_helpers.laserTargetDesirability) orelse return;
    try self.shootAt(gamestate, target);
}

fn shootAt(self: *LaserTurret, gamestate: *Gamestate, target: Target) !void {
    var component = Component.fromKind(self);
    const entity = Entity.fromKind(component);
    if (!gamestate.power.consume(1)) return;
    try target.entity.takeDamage(gamestate, damage);
    self.cooldown = cooldown;
    self.barrel_angle = entity.pos.angleTo(target.hit_point) - entity.rot;
    try gamestate.artifacts.append(.{ .laser = .{
        .time = common.cycles_per_s / 2,
        .color = raylib.BLUE,
        .thickness = 3,
        .origin = entity.pos,
        .target = target.hit_point,
    } });
}

A  => client/components/MissileTurret.zig +97 -0
@@ 1,97 @@
const std = @import("std");
const meta = std.meta;
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;
const common = @import("common");
const ally_helpers = @import("../ally_helpers.zig");
const util = @import("../util.zig");
const Component = @import("../Component.zig");
const Enemy = @import("../Enemy.zig");
const Entity = @import("../Entity.zig");
const Gamestate = @import("../Gamestate.zig");
const MissileTurret = @This();

pub const name = "Missile Turret";
pub const description = "Fires 3-damage missile every second. Consumes 1 power per shot.";
const cooldown = common.cycles_per_s;
const damage = 3;
pub const range = 324;

cooldown: u32 = 0,
target: ?Entity.Id = null,
on: bool = true,
barrel_angle: f32 = 0,

pub var texture: Texture = undefined;
var barrel_on_texture: Texture = undefined;
var barrel_off_texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/components/missile-turret.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
    const barrel_on_file = @embedFile("../images/components/missile-turret-barrel-on.png");
    var barrel_on_image = raylib.LoadImageFromMemory(".png", barrel_on_file, barrel_on_file.len);
    barrel_on_texture = raylib.LoadTextureFromImage(barrel_on_image);
    const barrel_off_file = @embedFile("../images/components/missile-turret-barrel-off.png");
    var barrel_off_image = raylib.LoadImageFromMemory(".png", barrel_off_file, barrel_off_file.len);
    barrel_off_texture = raylib.LoadTextureFromImage(barrel_off_image);
}

pub fn draw(self: *const MissileTurret) void {
    const entity = Entity.fromKind(Component.fromKind(self));
    util.drawTextureRotated(Component.base_texture, entity.pos, Component.size, entity.rot);
    const barrel_texture = if (self.on) barrel_on_texture else barrel_off_texture;
    util.drawTextureRotated(barrel_texture, entity.pos, Component.size, entity.rot + self.barrel_angle);
}

pub fn tick(self: *@This(), gamestate: *Gamestate) !void {
    if (self.cooldown > 0) {
        self.cooldown -= 1;
        return;
    }
    if (!self.on) return;
    if (self.target) |id| {
        if (gamestate.findEntity(id)) |enemy| {
            const entity = Entity.fromKind(Component.fromKind(self));
            if (try entity.canSee(gamestate.*, enemy.*, range)) |hit_point| {
                try self.shootAt(gamestate, hit_point);
            } else {
                // If the target still exists but is out of sight,
                // shoot something else for now but remember the target.
                try self.autoTarget(gamestate);
            }
        } else {
            // If the target no longer exists, clear it and auto-target.
            self.target = null;
            try self.autoTarget(gamestate);
        }
    } else {
        try self.autoTarget(gamestate);
    }
}

fn autoTarget(self: *MissileTurret, gamestate: *Gamestate) !void {
    const entity = Entity.fromKind(Component.fromKind(self));
    const target = try entity.findTarget(gamestate.*, range, ally_helpers.missileTargetDesirability) orelse return;
    try self.shootAt(gamestate, target.hit_point);
}

fn shootAt(self: *@This(), gamestate: *Gamestate, hit_point: Vector2) !void {
    var component = Component.fromKind(self);
    const entity = Entity.fromKind(component);
    if (!gamestate.power.consume(1)) return;
    self.cooldown = cooldown;
    var angle = entity.pos.angleTo(hit_point);
    self.barrel_angle = entity.pos.angleTo(hit_point) - entity.rot;
    try gamestate.missiles.append(.{
        .origin = entity.pos,
        .pos = entity.pos,
        .range = range,
        .rot = angle,
        .creator = entity.id,
        .damage = damage,
        .team = .ally,
    });
}

A  => client/components/PowerGenerator.zig +28 -0
@@ 1,28 @@
const raylib = @import("raylib");
const Texture = raylib.Texture;
const Vector2 = raylib.Vector2;
const common = @import("common");
const util = @import("../util.zig");
const Component = @import("../Component.zig");
const Gamestate = @import("../Gamestate.zig");

pub const name = "Power Generator";
pub const description = "Generates 2 power per second.";
pub const capacity = 30.0;
const charge_speed = 2 / @as(f32, common.cycles_per_s);

pub var texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/components/power-generator.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, Component.size, rot);
}

pub fn tick(gamestate: *Gamestate) void {
    gamestate.power.fill(charge_speed);
}

A  => client/components/ShieldGenerator.zig +42 -0
@@ 1,42 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;
const common = @import("common");
const util = @import("../util.zig");
const Component = @import("../Component.zig");
const Entity = @import("../Entity.zig");
const Gamestate = @import("../Gamestate.zig");
const ShieldGenerator = @This();

pub const name = "Shield Generator";
pub const description = "Contributes 50 max shield. Regenerates 1 shield per second, consuming 1 power per second.";
pub const capacity = 50.0;
const recharge_speed = 1 / @as(f32, common.cycles_per_s);

on: bool = true,

pub var texture: Texture = undefined;
pub var on_texture: Texture = undefined;
pub var hiding_texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/components/shield-generator.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, Component.size, rot);
}

pub fn tick(self: *ShieldGenerator, gamestate: *Gamestate) !void {
    if (!self.on) return;
    if (gamestate.shield.current == gamestate.shield.max) return;
    const charge_cost = 1 / @as(f32, common.cycles_per_s);
    if (gamestate.power.consume(charge_cost)) {
        const amount = if (gamestate.shield_overcharge > 0) recharge_speed * 5 else recharge_speed;
        gamestate.shield.fill(amount);
    }
}

A  => client/consts.zig +3 -0
@@ 1,3 @@
pub const screen_width = 1200;
pub const screen_height = 900;
pub const fps = 60;

A  => client/emscripten_entry.c +19 -0
@@ 1,19 @@
#include <stdint.h>
#include <stdlib.h>

#include "emscripten/emscripten.h"
#include "raylib.h"

// Zig compiles C code with -fstack-protector-strong which requires the following two symbols
// which don't seem to be provided by the emscripten toolchain(?)
void *__stack_chk_guard = (void *)0xdeadbeef;
void __stack_chk_fail(void) {
    exit(1);
}

extern void zigMain(void);

int main() {
    zigMain();
    return 0;
}

A  => client/enemies/Battleship.zig +38 -0
@@ 1,38 @@
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;
const common = @import("common");
const helpers = @import("helpers.zig");
const util = @import("../util.zig");
const Gamestate = @import("../Gamestate.zig");
const Battleship = @This();

cooldown: u32 = 0,

pub const name = "Battleship";
pub const description = "Fires 3-damage laser and 12-damage missile every second.";
pub const range = 216;
pub const laser_damage = 3;
pub const missile_damage = 12;
pub const cooldown = common.cycles_per_s;
pub const hull = 120;
pub const size = Vector2{ .x = 144, .y = 108 };
pub const scrap = 60;
pub const speed = 25 / @as(f32, common.cycles_per_s);
pub const dodges_missiles = false;

pub var texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/enemies/battleship.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, size, rot);
}

pub fn tick(self: *Battleship, gamestate: *Gamestate) !void {
    try helpers.frigateTick(self, gamestate);
}

A  => client/enemies/Bomber.zig +37 -0
@@ 1,37 @@
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;
const common = @import("common");
const helpers = @import("./helpers.zig");
const util = @import("../util.zig");
const Gamestate = @import("../Gamestate.zig");
const Bomber = @This();

cooldown: u32 = 0,

pub const name = "Bomber";
pub const description = "Fires 3-damage missile every second.";
pub const range = 216;
pub const missile_damage = 3;
pub const cooldown = common.cycles_per_s;
pub const hull = 4;
pub const size = Vector2{ .x = 36, .y = 32 };
pub const scrap = 4;
pub const speed = 50 / @as(f32, common.cycles_per_s);
pub const dodges_missiles = true;

pub var texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/enemies/bomber.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, size, rot);
}

pub fn tick(self: *Bomber, gamestate: *Gamestate) !void {
    try helpers.bomberTick(self, gamestate);
}

A  => client/enemies/Corvette.zig +38 -0
@@ 1,38 @@
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;
const common = @import("common");
const helpers = @import("helpers.zig");
const util = @import("../util.zig");
const Gamestate = @import("../Gamestate.zig");
const Corvette = @This();

cooldown: u32 = 0,

pub const name = "Corvette";
pub const description = "Fires 1-damage laser and 3-damage missile every second.";
pub const range = 216;
pub const laser_damage = 1;
pub const missile_damage = 3;
pub const cooldown = common.cycles_per_s;
pub const hull = 10;
pub const size = Vector2{ .x = 54, .y = 42 };
pub const scrap = 8;
pub const speed = 40 / @as(f32, common.cycles_per_s);
pub const dodges_missiles = true;

pub var texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/enemies/corvette.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, size, rot);
}

pub fn tick(self: *Corvette, gamestate: *Gamestate) !void {
    try helpers.frigateTick(self, gamestate);
}

A  => client/enemies/Destroyer.zig +37 -0
@@ 1,37 @@
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;
const common = @import("common");
const helpers = @import("helpers.zig");
const util = @import("../util.zig");
const Gamestate = @import("../Gamestate.zig");
const Destroyer = @This();

cooldown: u32 = 0,

pub const name = "Destroyer";
pub const description = "Fires 9-damage missile every 0.5 seconds.";
pub const missile_damage = 9;
pub const range = 180;
pub const cooldown = 0.5 * common.cycles_per_s;
pub const hull = 30;
pub const size = Vector2{ .x = 45, .y = 90 };
pub const scrap = 24;
pub const speed = 25 / @as(f32, common.cycles_per_s);
pub const dodges_missiles = false;

pub var texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/enemies/destroyer.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, size, rot);
}

pub fn tick(self: *Destroyer, gamestate: *Gamestate) !void {
    try helpers.bomberTick(self, gamestate);
}

A  => client/enemies/Drone.zig +37 -0
@@ 1,37 @@
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;
const common = @import("common");
const helpers = @import("./helpers.zig");
const util = @import("../util.zig");
const Gamestate = @import("../Gamestate.zig");
const Fighter = @This();

cooldown: u32 = 0,

pub const name = "Drone";
pub const description = "Fires 1-damage laser every second.";
pub const laser_damage = 1;
pub const range = 180;
pub const cooldown = common.cycles_per_s;
pub const hull = 2;
pub const size = Vector2{ .x = 24, .y = 24 };
pub const scrap = 2;
pub const speed = 65 / @as(f32, common.cycles_per_s);
pub const dodges_missiles = true;

pub var texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/enemies/drone.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, size, rot);
}

pub fn tick(self: *Fighter, gamestate: *Gamestate) !void {
    return helpers.fighterTick(self, gamestate);
}

A  => client/enemies/Factory.zig +75 -0
@@ 1,75 @@
const std = @import("std");
const math = std.math;
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;
const common = @import("common");
const util = @import("../util.zig");
const Enemy = @import("../Enemy.zig");
const Entity = @import("../Entity.zig");
const Gamestate = @import("../Gamestate.zig");
const Factory = @This();

cooldown: u32 = 0,

pub const name = "Factory";
pub const description = "Launches Drone every 5s.";
pub const range = 648;
const cooldown = 5 * common.cycles_per_s;
pub const hull = 60;
pub const size = Vector2{ .x = 78, .y = 78 };
pub const scrap = 18;
pub const speed = 20 / @as(f32, common.cycles_per_s);
const launch_distance = 72;

pub var texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/enemies/factory.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, size, rot);
}

pub fn tick(self: *Factory, gamestate: *Gamestate) !void {
    var entity = Entity.fromKind(Enemy.fromKind(self));
    const target = entity.closest(gamestate.*, .component) orelse return;
    if (target.distance(entity.pos) > range) try self.move(gamestate, target);
    if (self.cooldown == 0) {
        if (target.distance(entity.pos) <= range)
            try self.launchDrone(gamestate, target);
    } else self.cooldown -= 1;
}

fn move(self: *Factory, gamestate: *Gamestate, target: Vector2) !void {
    var entity = Entity.fromKind(Enemy.fromKind(self));
    const step = try entity.pathfind(gamestate.*, target, .enemy);
    if (step) |s| entity.move(gamestate.*, s);
}

fn launchDrone(self: *Factory, gamestate: *Gamestate, target: Vector2) !void {
    const entity = Entity.fromKind(Enemy.fromKind(self));
    const base_angle = entity.pos.angleTo(target);
    var drone = Entity.init(
        gamestate.rng.int(Entity.Id),
        undefined,
        .{ .enemy = Enemy.init(.drone) },
    );
    // Try all 8 cardinal directions.
    different_direction: for (0..8) |tries| {
        var angle = base_angle + @as(f32, math.pi) / 4 * @as(f32, @floatFromInt(tries));
        var offset = (Vector2{ .x = launch_distance, .y = 0 }).rotate(angle);
        drone.pos = entity.pos.add(offset);
        var drone_rect = drone.rotatedRect();
        for (gamestate.entities.items) |e| {
            if (e.dead) continue;
            if (e.rotatedRect().overlaps(drone_rect)) continue :different_direction;
        }
        self.cooldown = cooldown;
        try gamestate.addEntity(drone);
        return;
    }
}

A  => client/enemies/Fighter.zig +37 -0
@@ 1,37 @@
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;
const common = @import("common");
const util = @import("../util.zig");
const helpers = @import("./helpers.zig");
const Gamestate = @import("../Gamestate.zig");
const Fighter = @This();

cooldown: u32 = 0,

pub const name = "Fighter";
pub const description = "Fires 1-damage laser every second.";
pub const laser_damage = 1;
pub const range = 216;
pub const cooldown = common.cycles_per_s;
pub const hull = 4;
pub const size = Vector2{ .x = 36, .y = 32 };
pub const scrap = 4;
pub const speed = 50 / @as(f32, common.cycles_per_s);
pub const dodges_missiles = true;

pub var texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/enemies/fighter.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, size, rot);
}

pub fn tick(self: *Fighter, gamestate: *Gamestate) !void {
    return helpers.fighterTick(self, gamestate);
}

A  => client/enemies/Frigate.zig +38 -0
@@ 1,38 @@
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;
const common = @import("common");
const helpers = @import("helpers.zig");
const util = @import("../util.zig");
const Gamestate = @import("../Gamestate.zig");
const Frigate = @This();

cooldown: u32 = 0,

pub const name = "Frigate";
pub const description = "Fires 2-damage laser and 6-damage missile every second.";
pub const range = 216;
pub const laser_damage = 2;
pub const missile_damage = 6;
pub const cooldown = common.cycles_per_s;
pub const hull = 30;
pub const size = Vector2{ .x = 96, .y = 42 };
pub const scrap = 24;
pub const speed = 30 / @as(f32, common.cycles_per_s);
pub const dodges_missiles = false;

pub var texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/enemies/frigate.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, size, rot);
}

pub fn tick(self: *Frigate, gamestate: *Gamestate) !void {
    return helpers.frigateTick(self, gamestate);
}

A  => client/enemies/Sniper.zig +37 -0
@@ 1,37 @@
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const Texture = raylib.Texture;
const common = @import("common");
const helpers = @import("./helpers.zig");
const util = @import("../util.zig");
const Gamestate = @import("../Gamestate.zig");
const Sniper = @This();

cooldown: u32 = 0,

pub const name = "Sniper";
pub const description = "Fires 2-damage missile every 1.5 seconds.";
pub const missile_damage = 2;
pub const range = 468;
pub const cooldown = 1.5 * common.cycles_per_s;
pub const hull = 3;
pub const size = Vector2{ .x = 42, .y = 32 };
pub const scrap = 6;
pub const speed = 25 / @as(f32, common.cycles_per_s);
pub const dodges_missiles = true;

pub var texture: Texture = undefined;

pub fn loadAssets() void {
    const file = @embedFile("../images/enemies/sniper.png");
    var image = raylib.LoadImageFromMemory(".png", file, file.len);
    texture = raylib.LoadTextureFromImage(image);
}

pub fn draw(pos: Vector2, rot: f32) void {
    util.drawTextureRotated(texture, pos, size, rot);
}

pub fn tick(self: *Sniper, gamestate: *Gamestate) !void {
    try helpers.bomberTick(self, gamestate);
}

A  => client/enemies/helpers.zig +119 -0
@@ 1,119 @@
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const common = @import("common");
const Enemy = @import("../Enemy.zig");
const Entity = @import("../Entity.zig");
const Gamestate = @import("../Gamestate.zig");
const Target = Entity.Target;

pub fn fighterTick(self: anytype, gamestate: *Gamestate) !void {
    const Self = @typeInfo(@TypeOf(self)).Pointer.child;
    var entity = Entity.fromKind(Enemy.fromKind(self));
    // Movement logic.
    const dodging = Self.dodges_missiles and entity.avoidMissiles(gamestate.*);
    const target = try entity.findTarget(gamestate.*, Self.range, laserTargetDesirability);
    if (!dodging and target == null) {
        const goal = entity.closest(gamestate.*, .component) orelse return;
        const step = try entity.pathfind(gamestate.*, goal, .enemy);
        if (step) |s| entity.move(gamestate.*, s);
    }
    // Attacking logic.
    if (self.cooldown == 0) {
        if (target) |t| {
            try shootLaser(self, gamestate, t);
            self.cooldown = Self.cooldown;
        }
    } else self.cooldown -= 1;
}

pub fn bomberTick(self: anytype, gamestate: *Gamestate) !void {
    const Self = @typeInfo(@TypeOf(self)).Pointer.child;
    var entity = Entity.fromKind(Enemy.fromKind(self));
    // Movement logic.
    const dodging = Self.dodges_missiles and entity.avoidMissiles(gamestate.*);
    const target = try entity.findTarget(gamestate.*, Self.range, missileTargetDesirability);
    const needs_target = target == null or target.?.entity.kind != .component;
    if (!dodging and needs_target) {
        const goal = entity.closest(gamestate.*, .component) orelse return;
        const step = try entity.pathfind(gamestate.*, goal, .enemy);
        if (step) |s| entity.move(gamestate.*, s);
    }
    // Attacking logic.
    if (self.cooldown == 0) {
        if (target) |t| {
            try shootMissile(self, gamestate, t.hit_point);
            self.cooldown = Self.cooldown;
        }
    } else self.cooldown -= 1;
}

pub fn frigateTick(self: anytype, gamestate: *Gamestate) !void {
    const Self = @typeInfo(@TypeOf(self)).Pointer.child;
    var entity = Entity.fromKind(Enemy.fromKind(self));
    // This is needed either for attacking or for moving.
    const missile_target = try entity.findTarget(gamestate.*, Self.range, missileTargetDesirability);
    // Attacking logic.
    if (self.cooldown == 0) {
        const laser_target = try entity.findTarget(gamestate.*, Self.range, laserTargetDesirability);
        if (laser_target) |target| {
            try shootLaser(self, gamestate, target);
        }
        if (missile_target) |target| {
            try shootMissile(self, gamestate, target.hit_point);
            // It should never be able to find a target for only one weapon since they have the same range.
            self.cooldown = Self.cooldown;
        }
    } else self.cooldown -= 1;
    // Movement logic.
    const dodging = Self.dodges_missiles and entity.avoidMissiles(gamestate.*);
    const needs_target = missile_target == null or missile_target.?.entity.kind != .component;
    if (!dodging and needs_target) {
        const goal = entity.closest(gamestate.*, .component) orelse return;
        const step = try entity.pathfind(gamestate.*, goal, .enemy);
        if (step) |s| entity.move(gamestate.*, s);
    }
}

pub fn laserTargetDesirability(target: Entity) ?f32 {
    return switch (target.kind) {
        .component => return 1,
        .ship => return 2,
        else => return null,
    };
}

pub fn missileTargetDesirability(target: Entity) ?f32 {
    return switch (target.kind) {
        .component => return 2,
        .ship => return 1,
        else => return null,
    };
}

fn shootLaser(self: anytype, gamestate: *Gamestate, target: Target) !void {
    const Self = @typeInfo(@TypeOf(self)).Pointer.child;
    const entity = Entity.fromKind(Enemy.fromKind(self));
    try target.entity.takeDamage(gamestate, Self.laser_damage);
    try gamestate.artifacts.append(.{ .laser = .{
        .time = common.cycles_per_s / 4,
        .color = raylib.RED,
        .thickness = 1,
        .origin = entity.pos,
        .target = target.hit_point,
    } });
}

fn shootMissile(self: anytype, gamestate: *Gamestate, hit_point: Vector2) !void {
    const Self = @typeInfo(@TypeOf(self)).Pointer.child;
    const entity = Entity.fromKind(Enemy.fromKind(self));
    const angle = entity.pos.angleTo(hit_point);
    try gamestate.missiles.append(.{
        .origin = entity.pos,
        .pos = entity.pos,
        .range = Self.range * 1.5,
        .creator = entity.id,
        .rot = angle,
        .damage = Self.missile_damage,
        .team = .enemy,
    });
}

A  => client/game.zig +279 -0
@@ 1,279 @@
const std = @import("std");
const box = std.crypto.nacl.Box;
const math = std.math;
const meta = std.meta;
const os = std.os;
const tcp = std.x.net.tcp;
const time = std.time;
const Allocator = std.mem.Allocator;
const ArrayList = std.ArrayList;
const DefaultPrng = std.rand.DefaultPrng;
const Mutex = Thread.Mutex;
const Thread = std.Thread;
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const common = @import("common");
const EncryptedSocket = common.EncryptedSocket;
const LobbyExternal = common.LobbyExternal;
const PlayerExternal = common.PlayerExternal;
const PlayerId = common.PlayerId;
const globals = @import("globals.zig");
const network = @import("network.zig");
const util = @import("util.zig");
const Action = @import("action.zig").Action;
const Card = @import("Card.zig");
const Component = @import("Component.zig");
const Entity = @import("Entity.zig");
const Gamestate = @import("Gamestate.zig");
const RotatedRect = @import("RotatedRect.zig");
const Ship = @import("Ship.zig");
const Ui = @import("Ui.zig");

var exit_game = false;
var rng: DefaultPrng = undefined;
var gamestate: Gamestate = undefined;
var ui: Ui = undefined;
// Fields only used for tutorial.
var game_time: i128 = undefined;
var action_queue: ArrayList(Action) = undefined;

const camera_speed = 6;

pub fn init(lobby: LobbyExternal, seed: u64) !void {
    util.log("rng seed: {}\n", .{seed});
    rng = DefaultPrng.init(seed);
    gamestate = try Gamestate.fromLobby(globals.alloc, rng.random(), lobby);
    ui = Ui{ .camera = .{
        .offset = util.screenSize().scale(0.5),
        .target = .{ .x = 0, .y = 0 },
        .zoom = 1,
        .rotation = 0,
    } };
}

pub fn initTutorial(player: PlayerExternal) !void {
    const seed = 7824091084447677329;
    rng = DefaultPrng.init(seed);
    gamestate = try Gamestate.initTutorial(globals.alloc, rng.random(), player);
    ui = Ui{ .camera = .{
        .offset = util.screenSize().scale(0.5),
        .target = .{ .x = 0, .y = 0 },
        .zoom = 1,
        .rotation = 0,
    } };
    game_time = util.timestamp();
    action_queue = ArrayList(Action).init(globals.alloc);
}

pub fn frame() !void {
    ui.camera.offset = util.screenSize().scale(0.5);
    try ui.drawFrame(gamestate);
    try handleInput();
    if (gamestate.script == .tutorial) {
        for (action_queue.items) |action| {
            try gamestate.applyAction(globals.id, action);
        }
        action_queue.clearRetainingCapacity();
        var behind = util.timestamp() - game_time;
        const time_per_frame = time.ns_per_s / common.cycles_per_s;
        while (behind > 0) : (behind -= time_per_frame) {
            game_time += time_per_frame;
            try gamestate.tick();
        }
        try gamestate.script.tutorial.tickTutorial(&gamestate, ui);
    }
}

pub fn recvEvent(bytes: []const u8) !void {
    var player_id: PlayerId = bytes[0..4].*;
    // Player ID zero means "gamestate tick".
    if (meta.eql(player_id, [4]u8{ 0, 0, 0, 0 })) {
        try gamestate.tick();
    } else {
        const action = try Action.fromBytes(bytes[4..]);
        try gamestate.applyAction(player_id, action);
    }
}

fn handleInput() !void {
    if (raylib.IsKeyDown(.KEY_ESCAPE) and raylib.IsKeyDown(.KEY_BACKSPACE)) {
        try sendAction(.quit);
        if (gamestate.script == .tutorial) action_queue.deinit();
        gamestate.deinit();
        globals.state = .menu;
        return;
    }
    if (raylib.IsKeyPressed(.KEY_ESCAPE)) try sendAction(.pause);
    if (raylib.IsMouseButtonPressed(.MOUSE_BUTTON_LEFT)) try handleLeftClick();
    if (raylib.IsMouseButtonPressed(.MOUSE_BUTTON_RIGHT)) try handleRightClick();
    if (raylib.IsKeyPressed(.KEY_SPACE)) try handlePowerKey();
    if (raylib.IsKeyPressed(.KEY_TAB)) try handleShieldKey();
    if (raylib.IsKeyPressed(.KEY_GRAVE)) {
        if (raylib.IsKeyDown(.KEY_LEFT_SHIFT) or raylib.IsKeyDown(.KEY_RIGHT_SHIFT))
            ui.mode = .{ .build_component = .connector }
        else
            ui.mode = .{ .build_ship = .probe };
    }
    if (raylib.IsKeyDown(.KEY_LEFT) or raylib.IsKeyDown(.KEY_A))
        ui.scroll(.{ .x = -camera_speed, .y = 0 });
    if (raylib.IsKeyDown(.KEY_RIGHT) or raylib.IsKeyDown(.KEY_D))
        ui.scroll(.{ .x = camera_speed, .y = 0 });
    if (raylib.IsKeyDown(.KEY_UP) or raylib.IsKeyDown(.KEY_W))
        ui.scroll(.{ .x = 0, .y = -camera_speed });
    if (raylib.IsKeyDown(.KEY_DOWN) or raylib.IsKeyDown(.KEY_S))
        ui.scroll(.{ .x = 0, .y = camera_speed });
    if (raylib.IsKeyDown(.KEY_MINUS)) ui.camera.zoom /= 1.01;
    if (raylib.IsKeyDown(.KEY_EQUAL)) ui.camera.zoom *= 1.01;
    center_key: {
        if (raylib.IsKeyDown(.KEY_G)) {
            const selected_id = util.variant(ui.mode, .select) orelse break :center_key;
            const selected = gamestate.findEntity(selected_id) orelse break :center_key;
            ui.centerOn(selected.pos);
            if (util.variant(selected.kind, .ship)) |ship| {
                if (ship.hangar) |hangar_id| {
                    const hangar = gamestate.findEntity(hangar_id) orelse break :center_key;
                    ui.centerOn(hangar.pos);
                }
            }
        }
    }
    if (raylib.IsKeyPressed(.KEY_H)) try handleGotoHangarKey();
    if (raylib.IsKeyPressed(.KEY_F)) ui.show_all_meters = !ui.show_all_meters;
    try handleEngineControls();
}

fn handleLeftClick() !void {
    if (ui.mouseBoardPos()) |pos| {
        for (gamestate.entities.items) |*e| {
            if (e.dead) continue;
            if (e.rotatedRect().containsPoint(pos)) {
                ui.mode = .{ .select = e.id };
                return;
            }
        }
        ui.mode = .blank;
    }
}

fn handleRightClick() !void {
    const pos = ui.mouseBoardPos() orelse return;
    switch (ui.mode) {
        .select => |selected_id| try handleRightClickSelected(selected_id, pos),
        .build_ship => |kind| try sendAction(.{ .build_ship = .{ .x = pos.x, .y = pos.y, .kind = kind } }),
        .build_component => |kind| {
            for (gamestate.entities.items) |e| {
                if (e.dead) continue;
                const component = util.variant(e.kind, .component) orelse continue;
                if (component.kind != .connector and kind != .connector) continue;
                const angles = [_]f32{ 0, math.pi / 2.0, math.pi, -math.pi / 2.0 };
                for (angles) |angle| {
                    const offset = Vector2{ .x = Component.size.x, .y = 0 };
                    const new_pos = e.pos.add(offset.rotate(angle + e.rot));
                    const new_rect = RotatedRect{ .pos = new_pos, .rot = e.rot, .size = Component.size };
                    if (new_rect.containsPoint(pos)) {
                        const build_info = .{ .kind = kind, .id = e.id, .angle = angle };
                        return try sendAction(.{ .build_component = build_info });
                    }
                }
            }
        },
        .card => |id| {
            const player = gamestate.findPlayer(globals.id).?;
            const index = player.findCardIndex(id) orelse return;
            const card = player.cards.items[index];
            if (card.kind.targetMode() == .pos) {
                return try sendAction(.{ .play_card = .{ .id = id, .x = pos.x, .y = pos.y } });
            }
            for (gamestate.entities.items) |e| {
                if (e.dead) continue;
                if (e.rotatedRect().containsPoint(pos)) {
                    return try sendAction(.{ .play_card = .{ .id = id, .target = e.id } });
                }
            }
        },
        else => {},
    }
}

fn handleRightClickSelected(selected_id: Entity.Id, pos: Vector2) !void {
    const selected = gamestate.findEntity(selected_id) orelse return;
    // Clearing target.
    if (raylib.IsKeyDown(.KEY_LEFT_SHIFT) or raylib.IsKeyDown(.KEY_RIGHT_SHIFT)) {
        // 0 means null. TODO change when we move away from writeStruct.
        return try sendAction(.{ .assign_target = .{ .id = selected_id, .target = 0 } });
    }
    // Assigning target.
    for (gamestate.entities.items) |e| {
        if (e.dead or e.kind != .enemy) continue;
        if (e.rotatedRect().containsPoint(pos)) {
            return try sendAction(.{ .assign_target = .{ .id = selected_id, .target = e.id } });
        }
    }
    // Assigning destination.
    if (selected.kind == .ship) {
        const stay = raylib.IsKeyDown(.KEY_LEFT_CONTROL) or raylib.IsKeyDown(.KEY_RIGHT_CONTROL);
        // Check if you clicked on a hangar.
        for (gamestate.entities.items) |e| {
            if (e.dead) continue;
            const c = util.variant(e.kind, .component) orelse continue;
            if (c.kind != .hangar) continue;
            if (e.rotatedRect().containsPoint(pos)) {
                return try sendAction(.{ .land_in_hangar = .{ .ship_id = selected_id, .hangar_id = e.id } });
            }
        }
        // No hangar, just going somewhere.
        try sendAction(.{ .move = .{ .id = selected_id, .x = pos.x, .y = pos.y, .stay = stay } });
    }
}

fn handlePowerKey() !void {
    const selected = util.variant(ui.mode, .select) orelse return;
    const entity = gamestate.findEntity(selected) orelse return;
    const component = util.variant(entity.kind, .component) orelse return;
    const power = component.power() orelse return;
    try sendAction(.{ .set_power = .{ .id = entity.id, .on = !power } });
}

fn handleShieldKey() !void {
    const selected = util.variant(ui.mode, .select) orelse return;
    const entity = gamestate.findEntity(selected) orelse return;
    const component = util.variant(entity.kind, .component) orelse return;
    try sendAction(.{ .set_shield = .{ .id = entity.id, .on = !component.shielded } });
}

fn handleEngineControls() !void {
    if (raylib.IsKeyPressed(.KEY_Z)) {
        try sendAction(.{ .engine_control = .{ .state = .ccw } });
    } else if (raylib.IsKeyPressed(.KEY_X)) {
        try sendAction(.{ .engine_control = .{ .state = .off } });
    } else if (raylib.IsKeyPressed(.KEY_C)) {
        try sendAction(.{ .engine_control = .{ .state = .cw } });
    }
}

fn handleGotoHangarKey() !void {
    const selected = util.variant(ui.mode, .select) orelse return;
    const entity = gamestate.findEntity(selected) orelse return;
    if (entity.kind != .ship) return;
    var closest: ?Entity.Id = null;
    var closest_dist: f32 = math.floatMax(f32);
    for (gamestate.entities.items) |e| {
        if (e.dead) continue;
        const c = util.variant(e.kind, .component) orelse continue;
        if (c.kind != .hangar) continue;
        const dist = entity.pos.distance(e.pos);
        if (dist < closest_dist) {
            closest_dist = dist;
            closest = e.id;
        }
    }
    if (closest) |hangar_id|
        try sendAction(.{ .land_in_hangar = .{ .ship_id = entity.id, .hangar_id = hangar_id } });
}

pub fn sendAction(action: Action) !void {
    if (gamestate.script == .tutorial)
        try action_queue.append(action)
    else
        try network.sendGameAction(action);
}

A  => client/globals.zig +63 -0
@@ 1,63 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const Mutex = std.Thread.Mutex;
const raylib = @import("raylib");
const Rectangle = raylib.Rectangle;
const RenderTexture2D = raylib.RenderTexture2D;
const Vector2 = raylib.Vector2;
const common = @import("common");
const PlayerId = common.PlayerId;
const menu = @import("menu.zig");
const game = @import("game.zig");

pub var alloc: Allocator = undefined;
pub var lock: Mutex = .{};
pub var id: PlayerId = undefined;
pub var state: State = .menu;
pub var tooltip: ?Tooltip = null;

pub const State = enum {
    menu,
    game,
};

pub const Tooltip = struct {
    pos: Vector2,
    texture: RenderTexture2D,
};

pub fn frame() !void {
    defer if (tooltip) |tt| {
        raylib.UnloadRenderTexture(tt.texture);
        tooltip = null;
    };
    raylib.BeginDrawing();
    defer raylib.EndDrawing();
    raylib.ClearBackground(raylib.BLACK);
    lock.lock();
    defer lock.unlock();
    try switch (state) {
        .menu => menu.frame(),
        .game => game.frame(),
    };
    if (tooltip) |tt| {
        const txt = tt.texture.texture;
        // Must reverse height because OpenGL coordinates work opposite way as Raylib's.
        const rect = Rectangle{
            .x = 0,
            .y = 0,
            .width = @floatFromInt(txt.width),
            .height = @floatFromInt(-txt.height),
        };
        raylib.DrawTextureRec(txt, rect, tt.pos, raylib.WHITE);
    }
}

pub fn recvEvent(bytes: []const u8) !void {
    lock.lock();
    defer lock.unlock();
    try switch (state) {
        .menu => menu.recvEvent(bytes),
        .game => game.recvEvent(bytes),
    };
}

A  => client/images/cards/distress-call-bolt.png +0 -0
A  => client/images/cards/distress-call.png +0 -0
A  => client/images/cards/emp-bolt.png +0 -0
A  => client/images/cards/emp.png +0 -0
A  => client/images/cards/planetary-cannon-bolt.png +0 -0
A  => client/images/cards/planetary-cannon.png +0 -0
A  => client/images/cards/repair-bolt.png +0 -0
A  => client/images/cards/repair.png +0 -0
A  => client/images/cards/salvage.png +0 -0
A  => client/images/cards/salvage.xcf +0 -0
A  => client/images/cards/scavenging-party.png +0 -0
A  => client/images/cards/scavenging-party.xcf +0 -0
A  => client/images/cards/scrap-magnet-bolt.png +0 -0
A  => client/images/cards/scrap-magnet.png +0 -0
A  => client/images/cards/shield-overcharge-bolt.png +0 -0
A  => client/images/cards/shield-overcharge.png +0 -0
A  => client/images/components/component-base.png +0 -0
A  => client/images/components/component-base.xcf +0 -0
A  => client/images/components/connector.png +0 -0
A  => client/images/components/connector.xcf +0 -0
A  => client/images/components/engine.png +0 -0
A  => client/images/components/engine.xcf +0 -0
A  => client/images/components/hangar.png +0 -0
A  => client/images/components/hangar.xcf +0 -0
A  => client/images/components/laser-turret-barrel-off.png +0 -0
A  => client/images/components/laser-turret-barrel-on.png +0 -0
A  => client/images/components/laser-turret.png +0 -0
A  => client/images/components/laser-turret.xcf +0 -0
A  => client/images/components/missile-turret-barrel-off.png +0 -0
A  => client/images/components/missile-turret-barrel-on.png +0 -0
A  => client/images/components/missile-turret-off.png +0 -0
A  => client/images/components/missile-turret.png +0 -0
A  => client/images/components/missile-turret.xcf +0 -0
A  => client/images/components/no-power.png +0 -0
A  => client/images/components/no-shield.png +0 -0
A  => client/images/components/power-generator.png +0 -0
A  => client/images/components/power-generator.xcf +0 -0
A  => client/images/components/power-shield-symbols.xcf +0 -0
A  => client/images/components/shield-generator-hiding.png +0 -0
A  => client/images/components/shield-generator-on.png +0 -0
A  => client/images/components/shield-generator.png +0 -0
A  => client/images/components/shield-generator.xcf +0 -0
A  => client/images/enemies/battleship.png +0 -0
A  => client/images/enemies/battleship.xcf +0 -0
A  => client/images/enemies/bomber.png +0 -0
A  => client/images/enemies/bomber.xcf +0 -0
A  => client/images/enemies/corvette.png +0 -0
A  => client/images/enemies/corvette.xcf +0 -0
A  => client/images/enemies/destroyer.png +0 -0
A  => client/images/enemies/destroyer.xcf +0 -0
A  => client/images/enemies/drone.png +0 -0
A  => client/images/enemies/drone.xcf +0 -0
A  => client/images/enemies/factory.png +0 -0
A  => client/images/enemies/factory.xcf +0 -0
A  => client/images/enemies/fighter.png +0 -0
A  => client/images/enemies/fighter.xcf +0 -0
A  => client/images/enemies/frigate.png +0 -0
A  => client/images/enemies/frigate.xcf +0 -0
A  => client/images/enemies/sniper.png +0 -0
A  => client/images/enemies/sniper.xcf +0 -0
A  => client/images/explosions/medium-1.png +0 -0
A  => client/images/explosions/medium-1.xcf +0 -0
A  => client/images/explosions/small-1.png +0 -0
A  => client/images/explosions/small-1.xcf +0 -0
A  => client/images/hangar-launch-btn.png +0 -0
A  => client/images/hangar-repair-btn.png +0 -0
A  => client/images/missile.png +0 -0
A  => client/images/missile.xcf +0 -0
A  => client/images/scrap.png +0 -0
A  => client/images/scrap.xcf +0 -0
A  => client/images/ships/bomber.png +0 -0
A  => client/images/ships/bomber.xcf +0 -0
A  => client/images/ships/corvette.png +0 -0
A  => client/images/ships/corvette.xcf +0 -0
A  => client/images/ships/fighter.png +0 -0
A  => client/images/ships/fighter.xcf +0 -0
A  => client/images/ships/probe.png +0 -0
A  => client/images/ships/probe.xcf +0 -0
A  => client/images/ships/pylon.png +0 -0
A  => client/images/ships/pylon.xcf +0 -0
A  => client/index.html +30 -0
@@ 1,30 @@
<!DOCTYPE html>
<html>
	<head>
		<title>Spacestation Defense</title>
	</head>
	<body>
		<canvas oncontextmenu="return false;"></canvas>
	</body>
	<script>
		websocket = new WebSocket("wss://spacestation-defense.com:9002");
		recvPtr = null;
		websocket.onmessage = async e => {
			if (!recvPtr) {
				const array = new Uint8Array(await e.data.arrayBuffer());
				setTimeout(() => {
					recvPtr = Module.asm.init(array[0], array[1], array[2], array[3]);
				}, 2000);
				return;
			}
			const jsArray = new Uint8Array(await e.data.arrayBuffer());
			const wasmArray = new Uint8Array(Module.asm.memory.buffer, recvPtr, jsArray.length);
			wasmArray.set(jsArray);
			Module.asm.websocketRecv(jsArray.length);
		};
		Module = {
			canvas: document.querySelector('canvas'),
		};
	</script>
	{{{ SCRIPT }}}
</html>

A  => client/main_common.zig +41 -0
@@ 1,41 @@
const raylib = @import("raylib");
const consts = @import("consts.zig");

pub fn initRaylib() void {
    raylib.InitWindow(consts.screen_width, consts.screen_height, "Spacestation Defense");
    raylib.SetWindowState(.FLAG_WINDOW_RESIZABLE);
    raylib.SetTargetFPS(consts.fps);
    raylib.SetExitKey(.KEY_NULL);
    loadAllAssets();
}

fn loadAllAssets() void {
    @import("Component.zig").loadAssets();
    @import("components/Connector.zig").loadAssets();
    @import("components/PowerGenerator.zig").loadAssets();
    @import("components/ShieldGenerator.zig").loadAssets();
    @import("components/LaserTurret.zig").loadAssets();
    @import("components/MissileTurret.zig").loadAssets();
    @import("components/Engine.zig").loadAssets();
    @import("components/Hangar.zig").loadAssets();
    @import("enemies/Drone.zig").loadAssets();
    @import("enemies/Fighter.zig").loadAssets();
    @import("enemies/Bomber.zig").loadAssets();
    @import("enemies/Corvette.zig").loadAssets();
    @import("enemies/Sniper.zig").loadAssets();
    @import("enemies/Frigate.zig").loadAssets();
    @import("enemies/Factory.zig").loadAssets();
    @import("enemies/Destroyer.zig").loadAssets();
    @import("enemies/Battleship.zig").loadAssets();
    @import("ships/Probe.zig").loadAssets();
    @import("ships/Fighter.zig").loadAssets();