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();