commit b199c7d52ee91c173371656d110df5876cb41d75 Author: geemili Date: Sun Apr 14 14:31:52 2024 -0600 initial version of seizer-solitaire diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee7098f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +zig-out/ +zig-cache/ diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..67c206f --- /dev/null +++ b/build.zig @@ -0,0 +1,56 @@ +const std = @import("std"); + +// Although this function looks imperative, note that its job is to +// declaratively construct a build graph that will be executed by an external +// runner. +pub fn build(b: *std.Build) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard optimization options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not + // set a preferred release mode, allowing the user to decide how to optimize. + const optimize = b.standardOptimizeOption(.{}); + + const seizer = b.dependency("seizer", .{ + .target = target, + .optimize = optimize, + }); + + const exe = b.addExecutable(.{ + .name = "seizer-solitaire", + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + exe.root_module.addImport("seizer", seizer.module("seizer")); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + exe_unit_tests.root_module.addImport("seizer", seizer.module("seizer")); + + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + + // Similar to creating the run step earlier, this exposes a `test` step to + // the `zig build --help` menu, providing a way for the user to request + // running the unit tests. + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..562d684 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,36 @@ +.{ + .name = "seizer-solitaire", + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + + // This field is optional. + // This is currently advisory only; Zig does not yet do anything + // with this value. + //.minimum_zig_version = "0.11.0", + + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{ + .seizer = .{ + .url = "https://github.com/leroycep/seizer/archive/ec651ce18e423a8fdda3b1a3369d9121c45d96f0.tar.gz", + .hash = "122028487862f2462ba02191adbc6676b621b37fa05e0962f0a5990d4079ed34140e", + }, + }, + .paths = .{ + // This makes *all* files, recursively, included in this package. It is generally + // better to explicitly list the files and directories instead, to insure that + // fetching from tarballs, file system paths, and version control all result + // in the same contents hash. + "", + // For example... + //"build.zig", + //"build.zig.zon", + //"src", + //"LICENSE", + //"README.md", + }, +} diff --git a/src/assets.zig b/src/assets.zig new file mode 100644 index 0000000..5ebbcf5 --- /dev/null +++ b/src/assets.zig @@ -0,0 +1,394 @@ +pub const Suit = enum(u2) { + clubs = 0b00, + spades = 0b01, + hearts = 0b10, + diamonds = 0b11, + + pub fn color(this: @This()) u1 { + return @intCast((@intFromEnum(this) & 0b10) >> 1); + } +}; + +pub const Card = packed struct(u6) { + suit: Suit, + rank: Rank, + + pub const Rank = u4; +}; + +/// A texture with a regular grid of sprites +pub const TileSheet = struct { + texture: seizer.Texture, + tile_offset: [2]u32, + tile_size: [2]u32, + tile_stride: [2]u32, + + pub const InitOptions = struct { + allocator: std.mem.Allocator, + image_file_contents: []const u8, + tile_offset: [2]u32, + tile_size: [2]u32, + tile_stride: [2]u32, + texture_options: seizer.Texture.InitFromFileOptions = .{}, + }; + + pub fn init(options: InitOptions) !@This() { + const texture = try seizer.Texture.initFromFileContents( + options.allocator, + options.image_file_contents, + options.texture_options, + ); + return @This(){ + .texture = texture, + .tile_offset = options.tile_offset, + .tile_size = options.tile_size, + .tile_stride = options.tile_stride, + }; + } + + pub fn deinit(this: *@This()) void { + this.texture.deinit(); + } + + pub fn uvCoordinatesFromTileId(this: @This(), tile_id: u32) seizer.geometry.AABB(f32) { + const texture_sizef = [2]f32{ + @floatFromInt(this.texture.size[0]), + @floatFromInt(this.texture.size[1]), + }; + const tile_offsetf = [2]f32{ + @floatFromInt(this.tile_offset[0]), + @floatFromInt(this.tile_offset[1]), + }; + const tile_stridef = [2]f32{ + @floatFromInt(this.tile_stride[0]), + @floatFromInt(this.tile_stride[1]), + }; + const tile_sizef = [2]f32{ + @floatFromInt(this.tile_size[0]), + @floatFromInt(this.tile_size[1]), + }; + // add a half to round up + const size_in_tiles = [2]u32{ + (@as(u32, @intCast(this.texture.size[0])) + (this.tile_stride[0] / 2)) / this.tile_stride[0], + (@as(u32, @intCast(this.texture.size[1])) + (this.tile_stride[1] / 2)) / this.tile_stride[1], + }; + const pos_in_tiles = [2]u32{ + tile_id % size_in_tiles[0], + tile_id / size_in_tiles[0], + }; + const pos_in_tilesf = [2]f32{ + @floatFromInt(pos_in_tiles[0]), + @floatFromInt(pos_in_tiles[1]), + }; + + const pixel_pos = [2]f32{ + pos_in_tilesf[0] * tile_stridef[0] + tile_offsetf[0], + pos_in_tilesf[1] * tile_stridef[1] + tile_offsetf[1], + }; + return seizer.geometry.AABB(f32){ + .min = .{ + pixel_pos[0] / texture_sizef[0], + pixel_pos[1] / texture_sizef[1], + }, + .max = .{ + (pixel_pos[0] + tile_sizef[0]) / texture_sizef[0], + (pixel_pos[1] + tile_sizef[1]) / texture_sizef[1], + }, + }; + } + + pub fn renderTile(this: @This(), canvas: *seizer.Canvas, tile_id: u32, pos: [2]f32, options: struct { + size: ?[2]f32 = null, + color: [4]u8 = [4]u8{ 0xFF, 0xFF, 0xFF, 0xFF }, + }) void { + const uv = this.uvCoordinatesFromTileId(tile_id); + + canvas.rect(pos, options.size orelse [2]f32{ @floatFromInt(this.tile_size[0]), @floatFromInt(this.tile_size[1]) }, .{ + .texture = this.texture.glTexture, + .uv = uv, + .color = options.color, + }); + } +}; + +test "tilesheet math is exact" { + const tilesheet = TileSheet{ + .texture = .{ + .glTexture = undefined, + .size = .{ 494, 329 }, + }, + .tile_offset = .{ 6, 2 }, + .tile_size = .{ 20, 29 }, + .tile_stride = .{ 33, 33 }, + }; + + try std.testing.expectEqualDeep(seizer.geometry.AABB(f32){ + .min = .{ + 468.0 / 494.0, + 35.0 / 329.0, + }, + .max = .{ + (468.0 + 20.0) / 494.0, + (35.0 + 29.0) / 329.0, + }, + }, tilesheet.uvCoordinatesFromTileId(29)); +} + +/// A texture with a regular grid of sprites +pub const DeckSprites = struct { + tilesheet: TileSheet, + /// Return the tile index for a given card, + mapping: std.AutoHashMapUnmanaged(Card, u32), + blank: u32, + back: u32, + margin: u32, + hand_offset: [2]u32, + + pub fn deinit(this: *@This(), gpa: std.mem.Allocator) void { + this.tilesheet.deinit(); + this.mapping.deinit(gpa); + } + + pub fn getTileForCard(this: @This(), card: Card) u32 { + return this.mapping.get(card) orelse 0; + } +}; + +pub fn loadSmallCards(gpa: std.mem.Allocator) !DeckSprites { + var tilesheet = try TileSheet.init(.{ + .allocator = gpa, + .image_file_contents = @embedFile("./cardsSmall_tilemap.png"), + .tile_offset = .{ 0, 0 }, + .tile_size = .{ 16, 16 }, + .tile_stride = .{ 17, 17 }, + .texture_options = .{ + .min_filter = .nearest, + .mag_filter = .nearest, + }, + }); + errdefer tilesheet.deinit(); + + var mapping = std.AutoHashMap(Card, u32).init(gpa); + errdefer mapping.deinit(); + try mapping.ensureTotalCapacity(52); + + const hearts_start_index: u32 = 0; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .hearts, .rank = @intCast(rank + 1) }, + hearts_start_index + @as(u32, @intCast(rank)), + ); + } + + const diamonds_start_index: u32 = 14; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .diamonds, .rank = @intCast(rank + 1) }, + diamonds_start_index + @as(u32, @intCast(rank)), + ); + } + + const clubs_start_index: u32 = 28; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .clubs, .rank = @intCast(rank + 1) }, + clubs_start_index + @as(u32, @intCast(rank)), + ); + } + + const spades_start_index: u32 = 42; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .spades, .rank = @intCast(rank + 1) }, + spades_start_index + @as(u32, @intCast(rank)), + ); + } + + return DeckSprites{ + .tilesheet = tilesheet, + .mapping = mapping.unmanaged, + // TODO: add better graphic for blank card + .blank = 59, + .back = 59, + }; +} + +pub fn loadMediumCards(gpa: std.mem.Allocator) !DeckSprites { + var tilesheet = try TileSheet.init(.{ + .allocator = gpa, + .image_file_contents = @embedFile("./cardsMedium_tilemap.png"), + .tile_offset = .{ 6, 2 }, + .tile_size = .{ 20, 29 }, + .tile_stride = .{ 33, 33 }, + .texture_options = .{ + .min_filter = .nearest, + .mag_filter = .nearest, + }, + }); + errdefer tilesheet.deinit(); + + var mapping = std.AutoHashMap(Card, u32).init(gpa); + errdefer mapping.deinit(); + try mapping.ensureTotalCapacity(52); + + const hearts_start_index: u32 = 0; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .hearts, .rank = @intCast(rank + 1) }, + hearts_start_index + @as(u32, @intCast(rank)), + ); + } + + const diamonds_start_index: u32 = 15; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .diamonds, .rank = @intCast(rank + 1) }, + diamonds_start_index + @as(u32, @intCast(rank)), + ); + } + + const clubs_start_index: u32 = 30; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .clubs, .rank = @intCast(rank + 1) }, + clubs_start_index + @as(u32, @intCast(rank)), + ); + } + + const spades_start_index: u32 = 45; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .spades, .rank = @intCast(rank + 1) }, + spades_start_index + @as(u32, @intCast(rank)), + ); + } + + return DeckSprites{ + .tilesheet = tilesheet, + .mapping = mapping.unmanaged, + .blank = 14, + .back = 29, + }; +} + +pub fn loadLargeCards(gpa: std.mem.Allocator) !DeckSprites { + var tilesheet = try TileSheet.init(.{ + .allocator = gpa, + .image_file_contents = @embedFile("./cardsLarge_tilemap.png"), + .tile_offset = .{ 11, 2 }, + .tile_size = .{ 41, 60 }, + .tile_stride = .{ 65, 65 }, + .texture_options = .{ + .min_filter = .nearest, + .mag_filter = .nearest, + }, + }); + errdefer tilesheet.deinit(); + + var mapping = std.AutoHashMap(Card, u32).init(gpa); + errdefer mapping.deinit(); + try mapping.ensureTotalCapacity(52); + + const hearts_start_index: u32 = 0; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .hearts, .rank = @intCast(rank + 1) }, + hearts_start_index + @as(u32, @intCast(rank)), + ); + } + + const diamonds_start_index: u32 = 14; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .diamonds, .rank = @intCast(rank + 1) }, + diamonds_start_index + @as(u32, @intCast(rank)), + ); + } + + const clubs_start_index: u32 = 28; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .clubs, .rank = @intCast(rank + 1) }, + clubs_start_index + @as(u32, @intCast(rank)), + ); + } + + const spades_start_index: u32 = 42; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .spades, .rank = @intCast(rank + 1) }, + spades_start_index + @as(u32, @intCast(rank)), + ); + } + + return DeckSprites{ + .tilesheet = tilesheet, + .mapping = mapping.unmanaged, + .blank = 13, + .back = 27, + }; +} + +pub fn loadSolitaireCards(gpa: std.mem.Allocator) !DeckSprites { + var tilesheet = try TileSheet.init(.{ + .allocator = gpa, + .image_file_contents = @embedFile("./solitaire-cards.png"), + .tile_offset = .{ 0, 0 }, + .tile_size = .{ 32, 40 }, + .tile_stride = .{ 32, 40 }, + .texture_options = .{ + .min_filter = .nearest, + .mag_filter = .nearest, + }, + }); + errdefer tilesheet.deinit(); + + var mapping = std.AutoHashMap(Card, u32).init(gpa); + errdefer mapping.deinit(); + try mapping.ensureTotalCapacity(52); + + const hearts_start_index: u32 = 0; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .hearts, .rank = @intCast(rank + 1) }, + hearts_start_index + @as(u32, @intCast(rank)), + ); + } + + const diamonds_start_index: u32 = 14; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .diamonds, .rank = @intCast(rank + 1) }, + diamonds_start_index + @as(u32, @intCast(rank)), + ); + } + + const clubs_start_index: u32 = 28; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .clubs, .rank = @intCast(rank + 1) }, + clubs_start_index + @as(u32, @intCast(rank)), + ); + } + + const spades_start_index: u32 = 42; + for (0..13) |rank| { + mapping.putAssumeCapacityNoClobber( + Card{ .suit = .spades, .rank = @intCast(rank + 1) }, + spades_start_index + @as(u32, @intCast(rank)), + ); + } + + return DeckSprites{ + .tilesheet = tilesheet, + .mapping = mapping.unmanaged, + .blank = 13, + .back = 27, + .margin = 4, + .hand_offset = .{ 13, 12 }, + }; +} + +const seizer = @import("seizer"); +const gl = seizer.gl; +const std = @import("std"); diff --git a/src/cardsLarge_tilemap.png b/src/cardsLarge_tilemap.png new file mode 100644 index 0000000..d8e0ad8 Binary files /dev/null and b/src/cardsLarge_tilemap.png differ diff --git a/src/cardsMedium_tilemap.png b/src/cardsMedium_tilemap.png new file mode 100644 index 0000000..cde704a Binary files /dev/null and b/src/cardsMedium_tilemap.png differ diff --git a/src/cardsSmall_tilemap.png b/src/cardsSmall_tilemap.png new file mode 100644 index 0000000..a7c7c4d Binary files /dev/null and b/src/cardsSmall_tilemap.png differ diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..8a56c4c --- /dev/null +++ b/src/main.zig @@ -0,0 +1,549 @@ +pub const main = seizer.main; + +var gpa: std.mem.Allocator = undefined; +var prng: std.rand.DefaultPrng = undefined; +var canvas: seizer.Canvas = undefined; +var card_tilemap: ?DeckSprites = null; + +var draw_pile: std.ArrayListUnmanaged(Card) = .{}; +var draw_pile_exhausted = false; +var drawn_cards: std.ArrayListUnmanaged(Card) = .{}; +var stacks: [7]std.ArrayListUnmanaged(Card) = [_]std.ArrayListUnmanaged(Card){.{}} ** 7; +var foundations: [4]std.ArrayListUnmanaged(Card) = [_]std.ArrayListUnmanaged(Card){.{}} ** 4; + +var last_hovered_stack: usize = 0; +var last_hovered_foundation: usize = 0; +var hovered_deck: ?*std.ArrayListUnmanaged(Card) = null; +var hovered_card: usize = 0; + +var selected_deck: ?*std.ArrayListUnmanaged(Card) = null; +var selected_card: usize = 0; + +pub fn init(context: *seizer.Context) !void { + gpa = context.gpa; + prng = std.rand.DefaultPrng.init(@bitCast(std.time.timestamp())); + + const window = try context.createWindow(.{ + .title = "Seizer Solitaire", + .on_render = render, + .on_destroy = destroy, + }); + + canvas = try seizer.Canvas.init(context.gpa, .{}); + errdefer canvas.deinit(); + + card_tilemap = try assets.loadSolitaireCards(context.gpa); + + draw_pile = std.ArrayListUnmanaged(Card).fromOwnedSlice(try makeStandardDeck(gpa)); + prng.random().shuffle(Card, draw_pile.items); + + for (&stacks, 0..) |*stack, i| { + for (0..i + 1) |_| { + const drawn_card = draw_pile.pop(); + try stack.append(gpa, drawn_card); + } + } + + // update joystick to gamepad mappings + update_gamepad_mappings_file: { + const sdl_controller_config_filepath = std.process.getEnvVarOwned(gpa, "SDL_GAMECONTROLLERCONFIG_FILE") catch break :update_gamepad_mappings_file; + defer gpa.free(sdl_controller_config_filepath); + + std.log.debug("Loading gamepad mappings from file: \"{}\"", .{std.zig.fmtEscapes(sdl_controller_config_filepath)}); + + const controller_config_data = std.fs.cwd().readFileAllocOptions( + gpa, + sdl_controller_config_filepath, + 512 * 1024 * 1024, + null, + @alignOf(u8), + 0, + ) catch break :update_gamepad_mappings_file; + defer gpa.free(controller_config_data); + + if (!seizer.backend.glfw.Joystick.updateGamepadMappings(controller_config_data)) { + std.log.warn("Failed to update gamepad mappings from file", .{}); + } + } + update_gamepad_mappings: { + const sdl_controller_config = std.process.getEnvVarOwned(gpa, "SDL_GAMECONTROLLERCONFIG") catch break :update_gamepad_mappings; + defer gpa.free(sdl_controller_config); + + std.log.debug("Loading gamepad mappings from environment variable", .{}); + + const sdl_controller_configz = gpa.dupeZ(u8, sdl_controller_config) catch break :update_gamepad_mappings; + + if (!seizer.backend.glfw.Joystick.updateGamepadMappings(sdl_controller_configz)) { + std.log.warn("Failed to update gamepad mappings from environment variable", .{}); + } + } + + window.glfw_window.setKeyCallback(key_input_callback); +} + +fn destroy(window: *seizer.Window) void { + _ = window; + draw_pile.deinit(gpa); + drawn_cards.deinit(gpa); + for (&stacks) |*stack| { + stack.deinit(gpa); + } + for (&foundations) |*foundation| { + foundation.deinit(gpa); + } + + if (card_tilemap) |*ct| { + ct.deinit(gpa); + card_tilemap = null; + } + canvas.deinit(); +} + +fn render(window: *seizer.Window) !void { + for (1..@intFromEnum(seizer.backend.glfw.Joystick.Id.last)) |i| { + const joystick = seizer.backend.glfw.Joystick{ .jid = @enumFromInt(i) }; + if (joystick.getGamepadState()) |gamepad| { + if (gamepad.getButton(.a) == .press) doSelectOrPlace(); + if (gamepad.getButton(.dpad_left) == .press) moveLeft(); + if (gamepad.getButton(.dpad_right) == .press) moveRight(); + if (gamepad.getButton(.dpad_up) == .press) moveUp(); + if (gamepad.getButton(.dpad_down) == .press) moveDown(); + } + } + + gl.clearColor(0.2, 0.4, 0.2, 1.0); + gl.clear(gl.COLOR_BUFFER_BIT); + + canvas.begin(.{ + .window_size = window.getSize(), + .framebuffer_size = window.getFramebufferSize(), + }); + + const scale: u32 = if (canvas.window_size[1] <= 400) 1 else 2; + const scalef: f32 = @floatFromInt(scale); + const tile_size = [2]u32{ + card_tilemap.?.tilesheet.tile_size[0] * scale, + card_tilemap.?.tilesheet.tile_size[1] * scale, + }; + const margin = card_tilemap.?.margin * scale; + const hand_offset = [2]u32{ + card_tilemap.?.hand_offset[0] * scale, + card_tilemap.?.hand_offset[1] * scale, + }; + const tile_sizef = [2]f32{ @floatFromInt(tile_size[0]), @floatFromInt(tile_size[1]) }; + + for (&stacks, 0..) |*stack, i| { + const deck_is_selected = selected_deck != null and selected_deck.? == stack; + const deck_is_hovered = hovered_deck != null and hovered_deck.? == stack; + const deck_color = if (deck_is_selected) + [4]u8{ 0xFF, 0xA0, 0xA0, 0xFF } + else if (deck_is_hovered) + [4]u8{ 0xA0, 0xFF, 0xA0, 0xFF } + else + [4]u8{ 0xFF, 0xFF, 0xFF, 0xFF }; + + var pos = [2]f32{ + @floatFromInt(margin + ((tile_size[0] + margin) * (i + 1))), + @floatFromInt(margin + margin + tile_size[1]), + }; + canvas.rect(pos, tile_sizef, .{ .color = deck_color }); + + for (stack.items, 0..) |card, j| { + const is_selected = deck_is_selected and j >= selected_card; + const is_hovered = deck_is_hovered and j >= hovered_card; + const color = if (is_selected) + [4]u8{ 0xFF, 0xA0, 0xA0, 0xFF } + else if (is_hovered) + [4]u8{ 0xA0, 0xFF, 0xA0, 0xFF } + else + [4]u8{ 0xFF, 0xFF, 0xFF, 0xFF }; + + const tile_id = card_tilemap.?.getTileForCard(card); + card_tilemap.?.tilesheet.renderTile(&canvas, tile_id, pos, .{ + .size = tile_sizef, + .color = color, + }); + pos[1] += @floatFromInt(hand_offset[1]); + } + } + + for (&foundations, 0..) |*foundation, i| { + const is_hovered = hovered_deck != null and hovered_deck.? == foundation; + const color = if (is_hovered) [4]u8{ 0xA0, 0xFF, 0xA0, 0xFF } else [4]u8{ 0xFF, 0xFF, 0xFF, 0xFF }; + + const pos = [2]f32{ + @floatFromInt(margin), + @floatFromInt(margin + ((margin + tile_size[1]) * (i + 1))), + }; + canvas.rect(pos, tile_sizef, .{ .color = color }); + + for (foundation.items) |card| { + const tile_id = card_tilemap.?.getTileForCard(card); + card_tilemap.?.tilesheet.renderTile(&canvas, tile_id, pos, .{ + .size = tile_sizef, + .color = color, + }); + } + } + + { + const deck_is_selected = selected_deck != null and selected_deck.? == &drawn_cards; + const deck_is_hovered = hovered_deck != null and hovered_deck.? == &drawn_cards; + + var pos = [2]f32{ + @floatFromInt(margin + margin + tile_size[0]), + @floatFromInt(margin), + }; + for (drawn_cards.items, 0..) |card, j| { + const is_selected = deck_is_selected and j >= selected_card; + const is_hovered = deck_is_hovered and j >= hovered_card; + const color = if (is_selected) + [4]u8{ 0xFF, 0xA0, 0xA0, 0xFF } + else if (is_hovered) + [4]u8{ 0xA0, 0xFF, 0xA0, 0xFF } + else + [4]u8{ 0xFF, 0xFF, 0xFF, 0xFF }; + + const tile_id = card_tilemap.?.getTileForCard(card); + card_tilemap.?.tilesheet.renderTile(&canvas, tile_id, pos, .{ + .size = tile_sizef, + .color = color, + }); + pos[0] += @floatFromInt(hand_offset[0]); + } + } + + { + const is_hovered = hovered_deck != null and hovered_deck.? == &draw_pile; + const color = if (is_hovered) [4]u8{ 0xA0, 0xFF, 0xA0, 0xFF } else [4]u8{ 0xFF, 0xFF, 0xFF, 0xFF }; + + var pos = [2]f32{ @floatFromInt(margin), @floatFromInt(margin) }; + for (draw_pile.items) |card| { + const tile_id = if (!draw_pile_exhausted) card_tilemap.?.back else card_tilemap.?.getTileForCard(card); + card_tilemap.?.tilesheet.renderTile(&canvas, tile_id, pos, .{ + .size = tile_sizef, + .color = color, + }); + pos[1] -= 0.25 * scalef; + } + } + + var text_writer = canvas.textWriter(.{}); + for (1..@intFromEnum(seizer.backend.glfw.Joystick.Id.last)) |i| { + const joystick = seizer.backend.glfw.Joystick{ .jid = @enumFromInt(i) }; + if (joystick.getName()) |name| { + text_writer.writer().print("connected_controller[{}] = {s}\n", .{ i, name }) catch {}; + } + } + + canvas.end(); +} + +pub fn makeStandardDeck(allocator: std.mem.Allocator) ![]Card { + var deck = try allocator.alloc(Card, 52); + errdefer allocator.free(deck); + + var next_index: usize = 0; + for (0..4) |suit| { + for (1..14) |rank| { + deck[next_index] = Card{ + .suit = @enumFromInt(suit), + .rank = @intCast(rank), + }; + next_index += 1; + } + } + + return deck; +} + +pub fn key_input_callback( + window: seizer.backend.glfw.Window, + key: seizer.backend.glfw.Key, + scancode: i32, + action: seizer.backend.glfw.Action, + mods: seizer.backend.glfw.Mods, +) void { + _ = window; + _ = scancode; + _ = mods; + switch (action) { + .press => switch (key) { + .z => doSelectOrPlace(), + .left => moveLeft(), + .right => moveRight(), + .down => moveDown(), + .up => moveUp(), + else => {}, + }, + else => {}, + } +} + +pub fn doSelectOrPlace() void { + if (selected_deck == null) { + if (hovered_deck == &draw_pile and !draw_pile_exhausted) { + drawn_cards.ensureUnusedCapacity(gpa, 3) catch return; + for (0..3) |_| { + if (draw_pile.popOrNull()) |card| drawn_cards.appendAssumeCapacity(card); + } + if (draw_pile.items.len == 0) { + draw_pile_exhausted = true; + } + } else if (hovered_deck == &draw_pile and draw_pile_exhausted) { + selected_deck = hovered_deck; + selected_card = hovered_card; + } else if (hovered_deck == &drawn_cards) { + selected_deck = hovered_deck; + selected_card = hovered_card; + } else { + for (stacks[0..]) |*stack| { + if (hovered_deck != stack) continue; + selected_deck = hovered_deck; + selected_card = hovered_card; + break; + } + } + } else if (hovered_deck == selected_deck) { + const selected_substack = selected_deck.?.items[selected_card..]; + + // try to move all selected cards into the foundations + move_cards_to_foundations: for (0..selected_substack.len) |_| { + for (foundations[0..]) |*foundation| { + if (hovered_deck == foundation) break :move_cards_to_foundations; + } + + for (foundations[0..]) |*foundation| { + const empty = foundation.items.len == 0; + const ace = selected_deck.?.items[selected_card].rank == 1; + const suit_matches = !empty and foundation.items[foundation.items.len - 1].suit == selected_deck.?.items[selected_deck.?.items.len - 1].suit; + const is_next_rank = !empty and foundation.items[foundation.items.len - 1].rank + 1 == selected_deck.?.items[selected_deck.?.items.len - 1].rank; + if ((empty and ace) or (suit_matches and is_next_rank)) { + foundation.ensureUnusedCapacity(gpa, 1) catch continue; + foundation.appendAssumeCapacity(selected_deck.?.pop()); + hovered_card = indexOfTopOfStack(hovered_deck.?.items); + break; + } + } else { + break :move_cards_to_foundations; + } + } + selected_deck = null; + } else { + if (selected_deck) |selected| move_from_selected_to_hovered: { + const selected_substack = selected.items[selected_card..]; + if (selected_substack.len == 0) break :move_from_selected_to_hovered; + + const hovered = hovered_deck orelse break :move_from_selected_to_hovered; + + if (hovered.items.len == 0) { + for (stacks[0..]) |*stack| { + if (hovered != stack) continue; + hovered.ensureUnusedCapacity(gpa, selected_substack.len) catch break :move_from_selected_to_hovered; + hovered.appendSliceAssumeCapacity(selected_substack); + selected.shrinkRetainingCapacity(selected.items.len - selected_substack.len); + hovered_card = indexOfTopOfStack(hovered_deck.?.items); + break :move_from_selected_to_hovered; + } + + if (selected_substack.len == 1) { + if (draw_pile_exhausted and hovered == &draw_pile and draw_pile.items.len == 0) { + hovered.ensureUnusedCapacity(gpa, 1) catch break :move_from_selected_to_hovered; + hovered.appendAssumeCapacity(selected_deck.?.pop()); + hovered_card = indexOfTopOfStack(hovered_deck.?.items); + break :move_from_selected_to_hovered; + } else for (foundations[0..]) |*foundation| { + if (foundation != selected) continue; + const empty = foundation.items.len == 0; + const ace = selected_deck.?.items[selected_card].rank == 1; + const suit_matches = !empty and foundation.items[foundation.items.len - 1].suit == selected_deck.?.items[selected_deck.?.items.len - 1].suit; + const is_next_rank = !empty and foundation.items[foundation.items.len - 1].rank + 1 == selected_deck.?.items[selected_deck.?.items.len - 1].rank; + if ((empty and ace) or (suit_matches and is_next_rank)) { + foundation.ensureUnusedCapacity(gpa, 1) catch continue; + foundation.appendAssumeCapacity(selected_deck.?.pop()); + hovered_card = indexOfTopOfStack(hovered_deck.?.items); + break :move_from_selected_to_hovered; + } + } + } + + break :move_from_selected_to_hovered; + } + + if (hovered.items[hovered.items.len - 1].rank - 1 != selected_substack[0].rank or + hovered.items[hovered.items.len - 1].suit == selected_substack[0].suit) + { + break :move_from_selected_to_hovered; + } + hovered.ensureUnusedCapacity(gpa, selected_substack.len) catch break :move_from_selected_to_hovered; + hovered.appendSliceAssumeCapacity(selected_substack); + selected.shrinkRetainingCapacity(selected.items.len - selected_substack.len); + hovered_card = indexOfTopOfStack(hovered_deck.?.items); + } + selected_deck = null; + } +} + +pub fn moveLeft() void { + if (hovered_deck == null) { + hovered_deck = &draw_pile; + hovered_card = 0; + } else if (hovered_deck == &draw_pile) { + hovered_deck = &drawn_cards; + hovered_card = drawn_cards.items.len -| 1; + } else if (hovered_deck == &drawn_cards) { + hovered_deck = &draw_pile; + hovered_card = 0; + } else if (hovered_deck == &stacks[0]) { + hovered_deck = &foundations[last_hovered_foundation]; + } else { + for (stacks[1..], 1..) |*stack, i| { + if (hovered_deck == stack) { + hovered_deck = &stacks[i - 1]; + hovered_card = indexOfTopOfStack(hovered_deck.?.items); + last_hovered_stack = i - 1; + return; + } + } + for (&foundations) |*foundation| { + if (hovered_deck == foundation) { + hovered_deck = &stacks[stacks.len - 1]; + hovered_card = indexOfTopOfStack(hovered_deck.?.items); + return; + } + } + } +} + +pub fn moveRight() void { + if (hovered_deck == null) { + hovered_deck = &draw_pile; + hovered_card = 0; + } else if (hovered_deck == &draw_pile) { + hovered_deck = &drawn_cards; + hovered_card = drawn_cards.items.len -| 1; + } else if (hovered_deck == &drawn_cards) { + hovered_deck = &draw_pile; + hovered_card = 0; + } else if (hovered_deck == &stacks[stacks.len - 1]) { + hovered_deck = &foundations[last_hovered_foundation]; + } else { + for (stacks[0 .. stacks.len - 1], 0..) |*stack, i| { + if (hovered_deck == stack) { + hovered_deck = &stacks[i + 1]; + hovered_card = indexOfTopOfStack(hovered_deck.?.items); + last_hovered_stack = i + 1; + return; + } + } + for (&foundations) |*foundation| { + if (hovered_deck == foundation) { + hovered_deck = &stacks[0]; + hovered_card = indexOfTopOfStack(hovered_deck.?.items); + return; + } + } + } +} + +pub fn moveUp() void { + if (hovered_deck == null) { + hovered_deck = &draw_pile; + hovered_card = 0; + } else if (hovered_deck == &draw_pile) { + hovered_deck = &foundations[foundations.len - 1]; + last_hovered_foundation = foundations.len - 1; + } else if (hovered_deck == &drawn_cards) { + hovered_deck = &stacks[last_hovered_stack]; + hovered_card = indexOfTopOfStack(hovered_deck.?.items); + } else if (hovered_deck == &foundations[0]) { + hovered_deck = &draw_pile; + hovered_card = 0; + } else { + for (stacks[0..]) |*stack| { + if (hovered_deck != stack) continue; + const top_of_stack = indexOfTopOfStack(stack.items); + if (hovered_card > top_of_stack) { + hovered_card -= 1; + return; + } + if (drawn_cards.items.len > 0) { + hovered_deck = &drawn_cards; + hovered_card = drawn_cards.items.len -| 1; + } else { + hovered_deck = &draw_pile; + hovered_card = 0; + } + return; + } + for (foundations[1..], 1..) |*foundation, i| { + if (hovered_deck == foundation) { + hovered_deck = &foundations[i - 1]; + last_hovered_foundation = i - 1; + return; + } + } + } +} + +pub fn moveDown() void { + if (hovered_deck == null) { + hovered_deck = &draw_pile; + hovered_card = 0; + } else if (hovered_deck == &draw_pile) { + hovered_deck = &foundations[0]; + last_hovered_foundation = 0; + } else if (hovered_deck == &drawn_cards) { + hovered_deck = &stacks[last_hovered_stack]; + hovered_card = indexOfTopOfStack(hovered_deck.?.items); + } else if (hovered_deck == &foundations[foundations.len - 1]) { + hovered_deck = &draw_pile; + hovered_card = 0; + } else { + for (stacks[0..]) |*stack| { + if (hovered_deck != stack) continue; + if (hovered_card < stack.items.len -| 1) { + hovered_card += 1; + return; + } + + if (drawn_cards.items.len > 0) { + hovered_deck = &drawn_cards; + hovered_card = drawn_cards.items.len -| 1; + } else { + hovered_deck = &draw_pile; + hovered_card = 0; + } + return; + } + for (foundations[0 .. foundations.len - 1], 0..) |*foundation, i| { + if (hovered_deck == foundation) { + hovered_deck = &foundations[i + 1]; + last_hovered_foundation = i + 1; + return; + } + } + } +} + +pub fn indexOfTopOfStack(cards: []Card) usize { + if (cards.len < 2) return 0; + + var index = cards.len - 1; + while (index > 0) : (index -= 1) { + const prev_card = cards[index]; + const card = cards[index - 1]; + if (card.suit.color() == prev_card.suit.color()) break; + if (card.rank != prev_card.rank + 1) break; + } + return index; +} + +const DeckSprites = assets.DeckSprites; +const Card = assets.Card; + +const assets = @import("./assets.zig"); +const seizer = @import("seizer"); +const gl = seizer.gl; +const ecs = seizer.flecs; +const std = @import("std"); diff --git a/src/solitaire-cards.aseprite b/src/solitaire-cards.aseprite new file mode 100644 index 0000000..8f85317 Binary files /dev/null and b/src/solitaire-cards.aseprite differ diff --git a/src/solitaire-cards.png b/src/solitaire-cards.png new file mode 100644 index 0000000..7f77ca8 Binary files /dev/null and b/src/solitaire-cards.png differ