├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── build.zig ├── build.zig.zon ├── res └── tile.bmp └── src ├── main.zig ├── minesweeper ├── event.zig ├── game.zig └── test.zig └── sdl_backend.zig /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push, pull_request] 3 | env: 4 | zig_version: 0.14.0 5 | 6 | jobs: 7 | build: 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, macos-latest, windows-latest] 11 | runs-on: ${{matrix.os}} 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: goto-bus-stop/setup-zig@v2 15 | with: 16 | version: ${{ env.zig_version }} 17 | - run: zig build 18 | - run: zig build test 19 | - uses: actions/upload-artifact@v4 20 | with: 21 | name: binary-${{matrix.os}} 22 | path: zig-out/bin/* 23 | 24 | lint: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: goto-bus-stop/setup-zig@v2 29 | with: 30 | version: ${{ env.zig_version }} 31 | - run: zig fmt --check src/*.zig 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .zig-cache/ 2 | zig-out/ 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Thibault Schueller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minesweeper 2 | 3 | ## Building 4 | 5 | This should get you going after cloning the repo: 6 | ```sh 7 | $ zig build run -- 8 | ``` 9 | 10 | ## Controls 11 | 12 | | Action | Key | 13 | |--------------|--------------------| 14 | | Uncover cell | Left mouse button | 15 | | Flag cell | Right mouse button | 16 | | Quit game | Escape | 17 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub fn build(b: *std.Build) void { 4 | // Standard target options allows the person running `zig build` to choose 5 | // what target to build for. Here we do not override the defaults, which 6 | // means any target is allowed, and the default is native. Other options 7 | // for restricting supported target set are available. 8 | const target = b.standardTargetOptions(.{}); 9 | 10 | // Standard release options allow the person running `zig build` to select 11 | // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. 12 | const optimize_mode = b.standardOptimizeOption(.{}); 13 | 14 | const exe = b.addExecutable(.{ 15 | .name = "minesweeper", 16 | .root_source_file = b.path("src/main.zig"), 17 | .target = target, 18 | .optimize = optimize_mode, 19 | }); 20 | 21 | const sdl_dep = b.dependency("sdl", .{ 22 | .target = target, 23 | .optimize = optimize_mode, 24 | }); 25 | const sdl_lib = sdl_dep.artifact("SDL3"); 26 | 27 | exe.root_module.linkLibrary(sdl_lib); 28 | 29 | exe.root_module.addAnonymousImport("sprite_sheet", .{ .root_source_file = b.path("res/tile.bmp") }); 30 | 31 | b.installArtifact(exe); 32 | 33 | const run_cmd = b.addRunArtifact(exe); 34 | run_cmd.step.dependOn(b.getInstallStep()); 35 | if (b.args) |args| { 36 | run_cmd.addArgs(args); 37 | } 38 | 39 | const run_step = b.step("run", "Run the program"); 40 | run_step.dependOn(&run_cmd.step); 41 | 42 | const lib_unit_tests = b.addTest(.{ 43 | .root_source_file = b.path("src/minesweeper/test.zig"), 44 | .target = target, 45 | .optimize = optimize_mode, 46 | }); 47 | 48 | const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); 49 | 50 | const test_step = b.step("test", "Run tests"); 51 | test_step.dependOn(&run_lib_unit_tests.step); 52 | } 53 | -------------------------------------------------------------------------------- /build.zig.zon: -------------------------------------------------------------------------------- 1 | .{ 2 | // This is the default name used by packages depending on this one. For 3 | // example, when a user runs `zig fetch --save `, this field is used 4 | // as the key in the `dependencies` table. Although the user can choose a 5 | // different name, most users will stick with this provided value. 6 | // 7 | // It is redundant to include "zig" in this name because it is already 8 | // within the Zig package namespace. 9 | .name = .minesweeper, 10 | 11 | // This is a [Semantic Version](https://semver.org/). 12 | // In a future version of Zig it will be used for package deduplication. 13 | .version = "1.0.0", 14 | 15 | // Together with name, this represents a globally unique package 16 | // identifier. This field is generated by the Zig toolchain when the 17 | // package is first created, and then *never changes*. This allows 18 | // unambiguous detection of one package being an updated version of 19 | // another. 20 | // 21 | // When forking a Zig project, this id should be regenerated (delete the 22 | // field and run `zig build`) if the upstream project is still maintained. 23 | // Otherwise, the fork is *hostile*, attempting to take control over the 24 | // original project's identity. Thus it is recommended to leave the comment 25 | // on the following line intact, so that it shows up in code reviews that 26 | // modify the field. 27 | .fingerprint = 0xb5625ee1c5d11946, // Changing this has security and trust implications. 28 | 29 | // Tracks the earliest Zig version that the package considers to be a 30 | // supported use case. 31 | .minimum_zig_version = "0.14.0", 32 | 33 | // This field is optional. 34 | // Each dependency must either provide a `url` and `hash`, or a `path`. 35 | // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. 36 | // Once all dependencies are fetched, `zig build` no longer requires 37 | // internet connectivity. 38 | .dependencies = .{ 39 | .sdl = .{ 40 | .url = "git+https://github.com/castholm/SDL.git#2bb5f57ea8b8c43eabe514f7bbd3361365ba2ff3", 41 | .hash = "1220f653f5b656888b522bf5be06fc3062278767cfa7764e5d00eb559056d65b616f", 42 | }, 43 | }, 44 | 45 | // Specifies the set of files and directories that are included in this package. 46 | // Only files and directories listed here are included in the `hash` that 47 | // is computed for this package. Only files listed here will remain on disk 48 | // when using the zig package manager. As a rule of thumb, one should list 49 | // files required for compilation plus any license(s). 50 | // Paths are relative to the build root. Use the empty string (`""`) to refer to 51 | // the build root itself. 52 | // A directory listed here means that all files within, recursively, are included. 53 | .paths = .{ 54 | "build.zig", 55 | "build.zig.zon", 56 | "src", 57 | "LICENSE.md", 58 | }, 59 | } 60 | -------------------------------------------------------------------------------- /res/tile.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ryp/minesweeper-zig/297f6bbdad993d8a93166895ef5fd649d000c365/res/tile.bmp -------------------------------------------------------------------------------- /src/main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const builtin = @import("builtin"); 3 | const assert = std.debug.assert; 4 | const native_endian = builtin.cpu.arch.endian(); 5 | 6 | const game = @import("minesweeper/game.zig"); 7 | const backend = @import("sdl_backend.zig"); 8 | 9 | pub fn main() !void { 10 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 11 | defer _ = gpa.deinit(); 12 | 13 | // Parse arguments 14 | const args = try std.process.argsAlloc(gpa.allocator()); 15 | defer std.process.argsFree(gpa.allocator(), args); 16 | 17 | const extent_x = if (args.len > 1) try std.fmt.parseUnsigned(u32, args[1], 0) else game.DefaultExtentX; 18 | const extent_y = if (args.len > 2) try std.fmt.parseUnsigned(u32, args[2], 0) else game.DefaultExtentY; 19 | const mine_count = if (args.len > 3) try std.fmt.parseUnsigned(u32, args[3], 0) else game.DefaultMineCount; 20 | 21 | // Using the method from the docs to get a reasonably random seed 22 | var buf: [8]u8 = undefined; 23 | std.crypto.random.bytes(buf[0..]); 24 | const seed = std.mem.readInt(u64, buf[0..8], native_endian); 25 | 26 | // Create game state 27 | var game_state = try game.create_game_state(gpa.allocator(), .{ extent_x, extent_y }, mine_count, seed); 28 | defer game.destroy_game_state(gpa.allocator(), &game_state); 29 | 30 | try backend.execute_main_loop(gpa.allocator(), &game_state); 31 | } 32 | -------------------------------------------------------------------------------- /src/minesweeper/event.zig: -------------------------------------------------------------------------------- 1 | const GameState = @import("game.zig").GameState; 2 | 3 | pub const DiscoverSingleEvent = struct { 4 | location: u32, 5 | }; 6 | 7 | pub const DiscoverManyEvent = struct { 8 | location: u32, 9 | children: []u32, 10 | }; 11 | 12 | pub const DiscoverNumberEvent = struct { 13 | location: u32, 14 | children: []u32, 15 | }; 16 | 17 | pub const GameResult = enum { 18 | Win, 19 | Lose, 20 | }; 21 | 22 | pub const GameEndEvent = struct { 23 | result: GameResult, 24 | exploded_mines: []u32, 25 | }; 26 | 27 | pub const GameEvent = union(enum) { 28 | discover_single: DiscoverSingleEvent, 29 | discover_many: DiscoverManyEvent, 30 | discover_number: DiscoverNumberEvent, 31 | game_end: GameEndEvent, 32 | }; 33 | 34 | pub fn allocate_new_event(game: *GameState) *GameEvent { 35 | const new_event = &game.event_history[game.event_history_index]; 36 | game.event_history_index += 1; 37 | 38 | return new_event; 39 | } 40 | -------------------------------------------------------------------------------- /src/minesweeper/game.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const assert = std.debug.assert; 3 | 4 | const event = @import("event.zig"); 5 | 6 | pub const DefaultExtentX = 25; 7 | pub const DefaultExtentY = 20; 8 | pub const DefaultMineCount = 60; 9 | 10 | const BoardExtentMin = u32_2{ 5, 5 }; 11 | const BoardExtentMax = u32_2{ 1024, 1024 }; 12 | const UncoverAllMinesAfterLosing = true; 13 | const EnableGuessFlag = true; 14 | 15 | const NeighborhoodOffsetTableWithCenter = [9]i32_2{ 16 | .{ -1, -1 }, 17 | .{ -1, 0 }, 18 | .{ -1, 1 }, 19 | .{ 0, -1 }, 20 | .{ 0, 1 }, 21 | .{ 1, -1 }, 22 | .{ 1, 0 }, 23 | .{ 1, 1 }, 24 | .{ 0, 0 }, // Center position at the end so we can easily ignore it 25 | }; 26 | 27 | const NeighborhoodOffsetTable = NeighborhoodOffsetTableWithCenter[0..8]; 28 | 29 | pub const GameState = struct { 30 | extent: u32_2, 31 | mine_count: u32, 32 | board: []CellState, 33 | rng: std.Random.Xoroshiro128, // Hardcode PRNG type for forward compatibility 34 | is_first_move: bool = true, 35 | is_ended: bool = false, 36 | flag_count: u32 = 0, 37 | 38 | // Storage for game events 39 | event_history: []event.GameEvent, 40 | event_history_index: usize = 0, 41 | children_array: []u32, 42 | children_array_index: usize = 0, 43 | }; 44 | 45 | pub const u32_2 = @Vector(2, u32); 46 | const i32_2 = @Vector(2, i32); 47 | 48 | pub const Marking = enum { 49 | None, 50 | Flag, 51 | Guess, 52 | }; 53 | 54 | pub const CellState = struct { 55 | is_mine: bool = false, 56 | is_covered: bool = true, 57 | marking: Marking = .None, 58 | mine_neighbors: u4 = 0, 59 | }; 60 | 61 | pub fn cell_coords_to_flat_index(extent: u32_2, cell_coords: u32_2) u32 { 62 | return cell_coords[0] + extent[0] * cell_coords[1]; 63 | } 64 | 65 | pub fn cell_flat_index_to_coords(extent: u32_2, flat_index: u32) u32_2 { 66 | return .{ 67 | @intCast(flat_index % extent[0]), 68 | @intCast(flat_index / extent[0]), 69 | }; 70 | } 71 | 72 | fn is_coords_valid(extent: u32_2, coords: i32_2) bool { 73 | return all(coords >= i32_2{ 0, 0 }) and all(@as(u32_2, @intCast(coords)) < extent); 74 | } 75 | 76 | // I borrowed this name from HLSL 77 | fn all(vector: anytype) bool { 78 | const type_info = @typeInfo(@TypeOf(vector)); 79 | assert(type_info.vector.child == bool); 80 | assert(type_info.vector.len > 1); 81 | 82 | return @reduce(.And, vector); 83 | } 84 | 85 | // Creates blank board without mines. 86 | // Placement of mines is done on the first player input. 87 | pub fn create_game_state(allocator: std.mem.Allocator, extent: u32_2, mine_count: u32, seed: u64) !GameState { 88 | assert(all(extent >= BoardExtentMin)); 89 | assert(all(extent <= BoardExtentMax)); 90 | 91 | const cell_count = extent[0] * extent[1]; 92 | assert(mine_count > 0); 93 | assert(mine_count <= (cell_count - 9) / 2); // 9 is to take into account the starting position that has no mines in the neighborhood 94 | 95 | // Allocate board 96 | const board = try allocator.alloc(CellState, extent[0] * extent[1]); 97 | errdefer allocator.free(board); 98 | 99 | for (board) |*cell| { 100 | cell.* = .{}; 101 | } 102 | 103 | // Allocate array to hold events 104 | const max_events = cell_count + 2000; 105 | 106 | const event_history = try allocator.alloc(event.GameEvent, max_events); 107 | errdefer allocator.free(event_history); 108 | 109 | // Allocate array to hold cells discovered in events 110 | const children_array = try allocator.alloc(u32, cell_count); 111 | errdefer allocator.free(children_array); 112 | 113 | return GameState{ 114 | .extent = extent, 115 | .mine_count = mine_count, 116 | .rng = std.Random.Xoroshiro128.init(seed), 117 | .board = board, 118 | .event_history = event_history, 119 | .children_array = children_array, 120 | }; 121 | } 122 | 123 | pub fn destroy_game_state(allocator: std.mem.Allocator, game: *GameState) void { 124 | allocator.free(game.children_array); 125 | allocator.free(game.event_history); 126 | allocator.free(game.board); 127 | } 128 | 129 | // Process an oncover events and propagates the state on the board. 130 | pub fn uncover(game: *GameState, uncover_index: u32) void { 131 | assert(uncover_index < game.board.len); 132 | 133 | if (game.is_first_move) { 134 | fill_mines(game, uncover_index); 135 | game.is_first_move = false; 136 | } 137 | 138 | if (game.is_ended) 139 | return; 140 | 141 | const uncovered_cell = &game.board[uncover_index]; 142 | 143 | if (uncovered_cell.marking == .Flag) { 144 | return; // Nothing happens! 145 | } 146 | 147 | if (!uncovered_cell.is_covered) { 148 | if (!uncovered_cell.is_mine and uncovered_cell.mine_neighbors > 0) { 149 | const start_children = game.children_array_index; 150 | 151 | uncover_from_number(game, uncover_index, uncovered_cell); 152 | 153 | const end_children = game.children_array_index; 154 | 155 | event.allocate_new_event(game).* = .{ 156 | .discover_number = .{ 157 | .location = uncover_index, 158 | .children = game.children_array[start_children..end_children], 159 | }, 160 | }; 161 | } else { 162 | return; // Nothing happens! 163 | } 164 | } else if (uncovered_cell.mine_neighbors == 0) { 165 | // Create new event 166 | const start_children = game.children_array_index; 167 | 168 | uncover_zero_neighbors(game, uncover_index); 169 | 170 | const end_children = game.children_array_index; 171 | 172 | event.allocate_new_event(game).* = .{ 173 | .discover_many = .{ 174 | .location = uncover_index, 175 | .children = game.children_array[start_children..end_children], 176 | }, 177 | }; 178 | } else { 179 | uncovered_cell.is_covered = false; 180 | event.allocate_new_event(game).* = .{ 181 | .discover_single = .{ 182 | .location = uncover_index, 183 | }, 184 | }; 185 | } 186 | 187 | check_win_conditions(game); 188 | } 189 | 190 | fn check_win_conditions(game: *GameState) void { 191 | assert(!game.is_ended); 192 | 193 | { 194 | // Did we lose? 195 | // It's possible to lose by doing a wrong number discover. 196 | // That means we potentially lose on another cell, or multiple 197 | // other cells - so we check the full board here. 198 | // Also we count the flags here since we're at it. 199 | const start_children = game.children_array_index; 200 | 201 | game.flag_count = 0; 202 | for (game.board, 0..) |*cell, flat_index| { 203 | // Oops! 204 | if (cell.is_mine and !cell.is_covered) { 205 | game.is_ended = true; 206 | game.children_array[game.children_array_index] = @intCast(flat_index); 207 | game.children_array_index += 1; 208 | } 209 | 210 | if (cell.marking == .Flag) 211 | game.flag_count += 1; 212 | } 213 | 214 | const end_children = game.children_array_index; 215 | 216 | if (game.is_ended) { 217 | assert(end_children > start_children); 218 | 219 | if (UncoverAllMinesAfterLosing) { 220 | for (game.board) |*cell| { 221 | if (cell.is_mine) 222 | cell.is_covered = false; 223 | } 224 | } 225 | 226 | event.allocate_new_event(game).* = .{ 227 | .game_end = .{ 228 | .result = .Lose, 229 | .exploded_mines = game.children_array[start_children..end_children], 230 | }, 231 | }; 232 | } 233 | } 234 | 235 | // Did we win? 236 | if (is_board_won(game.board)) { 237 | // Uncover the board and flag all mines 238 | for (game.board) |*cell| { 239 | if (cell.is_mine) { 240 | // Here we should update the flag count but since we won there's no need 241 | cell.marking = .Flag; 242 | } else { 243 | cell.is_covered = false; 244 | } 245 | } 246 | 247 | game.is_ended = true; 248 | 249 | event.allocate_new_event(game).* = .{ 250 | .game_end = .{ 251 | .result = .Win, 252 | .exploded_mines = game.children_array[0..0], 253 | }, 254 | }; 255 | } 256 | } 257 | 258 | fn is_neighbor(a: u32_2, b: u32_2) !bool { 259 | const dx = @abs(@as(i32, @intCast(a[0])) - @as(i32, @intCast(b[0]))); 260 | const dy = @abs(@as(i32, @intCast(a[1])) - @as(i32, @intCast(b[1]))); 261 | return dx <= 1 and dy <= 1; 262 | } 263 | 264 | // Feed a blank but initialized board and it will dart throw mines at it until it has the right 265 | // number of mines. 266 | // We make sure that no mines is placed in the startup location, including its immediate neighborhood. 267 | // Often players restart the game until they land on this type of spots anyway, that removes the 268 | // frustrating guessing part. 269 | fn fill_mines(game: *GameState, start_index: u32) void { 270 | const start = cell_flat_index_to_coords(game.extent, start_index); 271 | 272 | var remaining_mines = game.mine_count; 273 | 274 | // Randomly place the mines on the board 275 | while (remaining_mines > 0) { 276 | const random_pos_index = game.rng.random().uintLessThan(u32, @intCast(game.board.len)); 277 | const random_pos = cell_flat_index_to_coords(game.extent, random_pos_index); 278 | 279 | // Do not generate mines where the player starts 280 | if (is_neighbor(random_pos, start) catch false) 281 | continue; 282 | 283 | const random_cell = &game.board[random_pos_index]; 284 | 285 | if (random_cell.is_mine) 286 | continue; 287 | 288 | random_cell.is_mine = true; 289 | 290 | for (NeighborhoodOffsetTableWithCenter) |neighbor_offset| { 291 | const neighbor_coords = @as(i32_2, @intCast(random_pos)) + neighbor_offset; 292 | 293 | if (is_coords_valid(game.extent, neighbor_coords)) { 294 | const index_flat = cell_coords_to_flat_index(game.extent, @intCast(neighbor_coords)); 295 | 296 | game.board[index_flat].mine_neighbors += 1; 297 | } 298 | } 299 | 300 | remaining_mines -= 1; 301 | } 302 | } 303 | 304 | // Discovers all cells adjacents to a zero-neighbor cell. 305 | // Assumes that the play is valid. 306 | // Careful, this function is recursive! It WILL smash the stack on large boards 307 | fn uncover_zero_neighbors(game: *GameState, uncover_cell_index: u32) void { 308 | const cell = &game.board[uncover_cell_index]; 309 | 310 | assert(cell.mine_neighbors == 0); 311 | 312 | // If the user put an invalid flag there by mistake, we clear it for him 313 | // That can only happens in recursive calls. 314 | cell.marking = .None; 315 | cell.is_covered = false; 316 | 317 | game.children_array[game.children_array_index] = uncover_cell_index; 318 | game.children_array_index += 1; 319 | 320 | const uncover_coords: i32_2 = @intCast(cell_flat_index_to_coords(game.extent, uncover_cell_index)); 321 | 322 | for (NeighborhoodOffsetTable) |neighbor_offset| { 323 | const neighbor_coords = uncover_coords + neighbor_offset; 324 | 325 | if (is_coords_valid(game.extent, neighbor_coords)) { 326 | const target_index = cell_coords_to_flat_index(game.extent, @intCast(neighbor_coords)); 327 | const target_cell = &game.board[target_index]; 328 | 329 | if (!target_cell.is_covered) 330 | continue; 331 | 332 | if (target_cell.mine_neighbors > 0) { 333 | target_cell.is_covered = false; 334 | 335 | game.children_array[game.children_array_index] = target_index; 336 | game.children_array_index += 1; 337 | } else { 338 | uncover_zero_neighbors(game, target_index); 339 | } 340 | } 341 | } 342 | } 343 | 344 | // Discovers all adjacent cells around a numbered cell, 345 | // if the number of flags around it equals that number. 346 | // This can make you lose if your flags aren't set properly. 347 | fn uncover_from_number(game: *GameState, uncover_cell_index: u32, number_cell: *CellState) void { 348 | assert(number_cell.mine_neighbors > 0); 349 | assert(!number_cell.is_covered); 350 | assert(!number_cell.is_mine); 351 | 352 | var candidates: [8]u32 = undefined; 353 | var candidate_count: u32 = 0; 354 | var flag_count: u32 = 0; 355 | 356 | const uncover_coords: i32_2 = @intCast(cell_flat_index_to_coords(game.extent, uncover_cell_index)); 357 | 358 | // Count covered cells 359 | for (NeighborhoodOffsetTable) |neighbor_offset| { 360 | const neighbor_coords = uncover_coords + neighbor_offset; 361 | 362 | if (is_coords_valid(game.extent, neighbor_coords)) { 363 | const target_index = cell_coords_to_flat_index(game.extent, @intCast(neighbor_coords)); 364 | const target_cell = game.board[target_index]; 365 | 366 | // Only count covered cells 367 | if (!target_cell.is_covered) { 368 | continue; 369 | } 370 | 371 | if (target_cell.marking == .Flag) { 372 | flag_count += 1; 373 | } else if (target_cell.is_covered) { 374 | candidates[candidate_count] = target_index; 375 | candidate_count += 1; 376 | } 377 | } 378 | } 379 | 380 | if (number_cell.mine_neighbors == flag_count) { 381 | for (candidates[0..candidate_count]) |candidate_index| { 382 | const cell = &game.board[candidate_index]; 383 | 384 | assert(cell.marking != .Flag); 385 | 386 | // We might trigger second-hand big uncovers! 387 | if (cell.mine_neighbors == 0) { 388 | uncover_zero_neighbors(game, candidate_index); 389 | } else { 390 | cell.is_covered = false; 391 | } 392 | 393 | game.children_array[game.children_array_index] = candidate_index; 394 | game.children_array_index += 1; 395 | } 396 | } 397 | } 398 | 399 | pub fn toggle_flag(game: *GameState, cell_index: u32) void { 400 | assert(cell_index < game.board.len); 401 | 402 | if (game.is_first_move or game.is_ended) 403 | return; 404 | 405 | const cell = &game.board[cell_index]; 406 | 407 | if (!cell.is_covered) 408 | return; 409 | 410 | switch (cell.marking) { 411 | .None => { 412 | cell.marking = .Flag; 413 | game.flag_count += 1; 414 | }, 415 | .Flag => { 416 | if (EnableGuessFlag) { 417 | cell.marking = .Guess; 418 | } else cell.marking = .None; 419 | game.flag_count -= 1; 420 | }, 421 | .Guess => { 422 | cell.marking = .None; 423 | }, 424 | } 425 | } 426 | 427 | fn is_board_won(board: []CellState) bool { 428 | for (board) |cell| { 429 | if (cell.is_covered and !cell.is_mine) 430 | return false; 431 | } 432 | 433 | return true; 434 | } 435 | -------------------------------------------------------------------------------- /src/minesweeper/test.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const expect = std.testing.expect; 3 | const expectEqual = std.testing.expectEqual; 4 | 5 | const game = @import("game.zig"); 6 | const u32_2 = game.u32_2; 7 | const event = @import("event.zig"); 8 | 9 | const test_seed: u64 = 0xC0FFEE42DEADBEEF; 10 | 11 | test "Critical path" { 12 | const extent = u32_2{ 5, 5 }; 13 | const mine_count: u32 = 8; 14 | const uncover_pos_0 = game.cell_coords_to_flat_index(extent, .{ 2, 2 }); 15 | const uncover_pos_1 = game.cell_coords_to_flat_index(extent, .{ 0, 1 }); 16 | 17 | const allocator: std.mem.Allocator = std.heap.page_allocator; 18 | 19 | var game_state = try game.create_game_state(allocator, extent, mine_count, test_seed); 20 | defer game.destroy_game_state(allocator, &game_state); 21 | 22 | game.uncover(&game_state, uncover_pos_0); 23 | 24 | try expectEqual(false, game_state.is_ended); 25 | try expectEqual(false, game_state.board[uncover_pos_0].is_covered); 26 | 27 | game.uncover(&game_state, uncover_pos_1); 28 | 29 | try expectEqual(true, game_state.is_ended); 30 | try expectEqual(false, game_state.board[uncover_pos_1].is_covered); 31 | } 32 | 33 | test "Toggle flag" { 34 | const extent = u32_2{ 5, 5 }; 35 | const mine_count: u32 = 8; 36 | const uncover_pos_0 = game.cell_coords_to_flat_index(extent, .{ 2, 2 }); 37 | const uncover_pos_1 = game.cell_coords_to_flat_index(extent, .{ 0, 1 }); 38 | 39 | const allocator: std.mem.Allocator = std.heap.page_allocator; 40 | 41 | var game_state = try game.create_game_state(allocator, extent, mine_count, test_seed); 42 | defer game.destroy_game_state(allocator, &game_state); 43 | 44 | game.uncover(&game_state, uncover_pos_0); 45 | try expectEqual(false, game_state.board[uncover_pos_0].is_covered); 46 | 47 | game.toggle_flag(&game_state, uncover_pos_1); 48 | game.uncover(&game_state, uncover_pos_1); 49 | try expectEqual(true, game_state.board[uncover_pos_1].is_covered); 50 | 51 | game.toggle_flag(&game_state, uncover_pos_1); 52 | game.uncover(&game_state, uncover_pos_1); 53 | try expectEqual(false, game_state.board[uncover_pos_1].is_covered); 54 | } 55 | 56 | test "Big uncover" { 57 | const extent = u32_2{ 100, 100 }; 58 | const mine_count: u32 = 1; 59 | const start_pos = game.cell_coords_to_flat_index(extent, .{ 25, 25 }); 60 | 61 | const allocator: std.mem.Allocator = std.heap.page_allocator; 62 | 63 | var game_state = try game.create_game_state(allocator, extent, mine_count, test_seed); 64 | defer game.destroy_game_state(allocator, &game_state); 65 | 66 | game.uncover(&game_state, start_pos); 67 | 68 | try expect(game_state.event_history[0] == .discover_many); 69 | } 70 | 71 | test "Number uncover" { 72 | const extent = u32_2{ 5, 5 }; 73 | const mine_count: u32 = 3; 74 | const uncover_pos_0 = game.cell_coords_to_flat_index(extent, .{ 2, 2 }); 75 | const toggle_pos = game.cell_coords_to_flat_index(extent, .{ 0, 2 }); 76 | const uncover_pos_1 = game.cell_coords_to_flat_index(extent, .{ 1, 2 }); 77 | 78 | const allocator: std.mem.Allocator = std.heap.page_allocator; 79 | 80 | var game_state = try game.create_game_state(allocator, extent, mine_count, test_seed); 81 | defer game.destroy_game_state(allocator, &game_state); 82 | 83 | game.uncover(&game_state, uncover_pos_0); 84 | game.toggle_flag(&game_state, toggle_pos); 85 | game.uncover(&game_state, uncover_pos_1); 86 | 87 | const test_pos = game.cell_coords_to_flat_index(extent, .{ 0, 1 }); 88 | 89 | try expectEqual(false, game_state.board[test_pos].is_covered); 90 | 91 | try expect(game_state.event_history[0] == .discover_many); 92 | try expect(game_state.event_history[1] == .discover_number); 93 | 94 | try expectEqual(false, game_state.is_ended); 95 | } 96 | -------------------------------------------------------------------------------- /src/sdl_backend.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const assert = std.debug.assert; 3 | 4 | const c = @cImport({ 5 | @cInclude("SDL3/SDL.h"); 6 | }); 7 | 8 | const game = @import("minesweeper/game.zig"); 9 | const event = @import("minesweeper/event.zig"); 10 | 11 | const SpriteSheetTileExtent = 19; 12 | const SpriteScreenExtent = 38; 13 | const InvalidMoveTimeSecs: f32 = 0.6; 14 | 15 | const GfxState = struct { 16 | invalid_move_time_secs: f32 = 0.0, 17 | is_hovered: bool = false, 18 | is_exploded: bool = false, 19 | }; 20 | 21 | fn get_tile_index(cell: game.CellState, gfx_cell: GfxState, is_game_ended: bool) [2]u8 { 22 | if (cell.is_covered) { 23 | var index_x: u8 = 0; 24 | if (cell.marking == .Flag) { 25 | if (is_game_ended and !cell.is_mine) { 26 | index_x = 8; 27 | } else { 28 | index_x = 2; 29 | } 30 | } else if (cell.marking == .Guess) { 31 | index_x = 4; 32 | } 33 | 34 | if (gfx_cell.is_hovered and !is_game_ended) 35 | index_x += 1; 36 | return .{ index_x, 1 }; 37 | } else { 38 | if (cell.is_mine) { 39 | if (gfx_cell.is_exploded) { 40 | return .{ 7, 1 }; 41 | } else return .{ 6, 1 }; 42 | } 43 | 44 | return .{ cell.mine_neighbors, 0 }; 45 | } 46 | } 47 | 48 | fn get_sprite_sheet_rect(position: [2]u8) c.SDL_FRect { 49 | return c.SDL_FRect{ 50 | .x = @floatFromInt(position[0] * SpriteSheetTileExtent), 51 | .y = @floatFromInt(position[1] * SpriteSheetTileExtent), 52 | .w = @floatFromInt(SpriteSheetTileExtent), 53 | .h = @floatFromInt(SpriteSheetTileExtent), 54 | }; 55 | } 56 | 57 | pub fn execute_main_loop(allocator: std.mem.Allocator, game_state: *game.GameState) !void { 58 | const width = game_state.extent[0] * SpriteScreenExtent; 59 | const height = game_state.extent[1] * SpriteScreenExtent; 60 | 61 | if (!c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_EVENTS)) { 62 | c.SDL_Log("Unable to initialize SDL: %s", c.SDL_GetError()); 63 | return error.SDLInitializationFailed; 64 | } 65 | defer c.SDL_Quit(); 66 | 67 | const window = c.SDL_CreateWindow("Minesweeper", @as(c_int, @intCast(width)), @as(c_int, @intCast(height)), 0) orelse { 68 | c.SDL_Log("Unable to create window: %s", c.SDL_GetError()); 69 | return error.SDLInitializationFailed; 70 | }; 71 | defer c.SDL_DestroyWindow(window); 72 | 73 | if (!c.SDL_SetHint(c.SDL_HINT_RENDER_VSYNC, "1")) { 74 | c.SDL_Log("Unable to set hint: %s", c.SDL_GetError()); 75 | return error.SDLInitializationFailed; 76 | } 77 | 78 | const ren = c.SDL_CreateRenderer(window, null) orelse { 79 | c.SDL_Log("Unable to create renderer: %s", c.SDL_GetError()); 80 | return error.SDLInitializationFailed; 81 | }; 82 | defer c.SDL_DestroyRenderer(ren); 83 | 84 | // Create sprite sheet texture 85 | const sprite_sheet_buffer = @embedFile("sprite_sheet"); 86 | const sprite_sheet_io = c.SDL_IOFromConstMem(sprite_sheet_buffer, sprite_sheet_buffer.len); 87 | const sprite_sheet_surface = c.SDL_LoadBMP_IO(sprite_sheet_io, true) orelse { 88 | c.SDL_Log("Unable to create BMP surface from file: %s", c.SDL_GetError()); 89 | return error.SDLInitializationFailed; 90 | }; 91 | defer c.SDL_DestroySurface(sprite_sheet_surface); 92 | 93 | const sprite_sheet_texture = c.SDL_CreateTextureFromSurface(ren, sprite_sheet_surface) orelse { 94 | c.SDL_Log("Unable to create texture from surface: %s", c.SDL_GetError()); 95 | return error.SDLInitializationFailed; 96 | }; 97 | defer c.SDL_DestroyTexture(sprite_sheet_texture); 98 | 99 | // FIXME Match SDL2 behavior 100 | _ = c.SDL_SetTextureScaleMode(sprite_sheet_texture, c.SDL_SCALEMODE_NEAREST); 101 | 102 | var shouldExit = false; 103 | 104 | const gfx_board = try allocator.alloc(GfxState, game_state.extent[0] * game_state.extent[1]); 105 | errdefer allocator.free(gfx_board); 106 | 107 | for (gfx_board) |*cell| { 108 | cell.* = .{}; 109 | } 110 | 111 | var gfx_event_index: usize = 0; 112 | var last_frame_time_ms: u64 = c.SDL_GetTicks(); 113 | 114 | while (!shouldExit) { 115 | const current_frame_time_ms: u64 = c.SDL_GetTicks(); 116 | const frame_delta_secs = @as(f32, @floatFromInt(current_frame_time_ms - last_frame_time_ms)) * 0.001; 117 | 118 | // Poll events 119 | var sdlEvent: c.SDL_Event = undefined; 120 | while (c.SDL_PollEvent(&sdlEvent)) { 121 | switch (sdlEvent.type) { 122 | c.SDL_EVENT_QUIT => { 123 | shouldExit = true; 124 | }, 125 | c.SDL_EVENT_KEY_DOWN => { 126 | if (sdlEvent.key.key == c.SDLK_ESCAPE) 127 | shouldExit = true; 128 | }, 129 | c.SDL_EVENT_MOUSE_BUTTON_UP => { 130 | const x = @divTrunc(@as(u32, @intFromFloat(sdlEvent.button.x)), SpriteScreenExtent); 131 | const y = @divTrunc(@as(u32, @intFromFloat(sdlEvent.button.y)), SpriteScreenExtent); 132 | const mouse_cell_index = game.cell_coords_to_flat_index(game_state.extent, .{ x, y }); 133 | 134 | if (sdlEvent.button.button == c.SDL_BUTTON_LEFT) { 135 | game.uncover(game_state, mouse_cell_index); 136 | } else if (sdlEvent.button.button == c.SDL_BUTTON_RIGHT) { 137 | game.toggle_flag(game_state, mouse_cell_index); 138 | } 139 | }, 140 | else => {}, 141 | } 142 | } 143 | 144 | // Get current state of Control key 145 | const is_ctrl_pressed = c.SDL_GetModState() & c.SDL_KMOD_CTRL != 0; 146 | 147 | const string = try std.fmt.allocPrintZ(allocator, "Minesweeper {d}x{d} with {d}/{d} mines", .{ game_state.extent[0], game_state.extent[1], game_state.flag_count, game_state.mine_count }); 148 | defer allocator.free(string); 149 | 150 | _ = c.SDL_SetWindowTitle(window, string.ptr); 151 | 152 | var mouse_x: f32 = undefined; 153 | var mouse_y: f32 = undefined; 154 | _ = c.SDL_GetMouseState(&mouse_x, &mouse_y); 155 | 156 | const hovered_cell_x = @max(0, @min(game_state.extent[0], @divTrunc(@as(u32, @intFromFloat(mouse_x)), SpriteScreenExtent))); 157 | const hovered_cell_y = @max(0, @min(game_state.extent[1], @divTrunc(@as(u32, @intFromFloat(mouse_y)), SpriteScreenExtent))); 158 | const hovered_cell_index = game.cell_coords_to_flat_index(game_state.extent, .{ hovered_cell_x, hovered_cell_y }); 159 | 160 | for (gfx_board) |*cell| { 161 | cell.is_hovered = false; 162 | cell.invalid_move_time_secs = @max(0.0, cell.invalid_move_time_secs - frame_delta_secs); 163 | } 164 | 165 | gfx_board[hovered_cell_index].is_hovered = true; 166 | 167 | // Process game events for the gfx side 168 | for (game_state.event_history[gfx_event_index..game_state.event_history_index]) |game_event| { 169 | switch (game_event) { 170 | .discover_number => |e| { 171 | if (e.children.len == 0) { 172 | gfx_board[e.location].invalid_move_time_secs = InvalidMoveTimeSecs; 173 | } 174 | }, 175 | .game_end => |e| { 176 | for (e.exploded_mines) |mine_location| { 177 | gfx_board[mine_location].is_exploded = true; 178 | } 179 | }, 180 | else => {}, 181 | } 182 | } 183 | 184 | // Advance event index since we processed the rest 185 | gfx_event_index = game_state.event_history_index; 186 | 187 | _ = c.SDL_RenderClear(ren); 188 | 189 | for (game_state.board, 0..) |cell, flat_index| { 190 | const gfx_cell = gfx_board[flat_index]; 191 | const cell_coords = game.cell_flat_index_to_coords(game_state.extent, @intCast(flat_index)); 192 | 193 | const sprite_output_pos_rect = c.SDL_FRect{ 194 | .x = @floatFromInt(cell_coords[0] * SpriteScreenExtent), 195 | .y = @floatFromInt(cell_coords[1] * SpriteScreenExtent), 196 | .w = @floatFromInt(SpriteScreenExtent), 197 | .h = @floatFromInt(SpriteScreenExtent), 198 | }; 199 | 200 | const is_cheating = is_ctrl_pressed; 201 | 202 | // Draw base cell sprite 203 | { 204 | var modified_cell = cell; 205 | modified_cell.is_covered = if (is_cheating) false else cell.is_covered; 206 | 207 | const sprite_sheet_pos = get_tile_index(modified_cell, gfx_cell, game_state.is_ended); 208 | const sprite_sheet_rect = get_sprite_sheet_rect(sprite_sheet_pos); 209 | 210 | _ = c.SDL_RenderTexture(ren, sprite_sheet_texture, &sprite_sheet_rect, &sprite_output_pos_rect); 211 | } 212 | 213 | if (is_cheating and cell.is_covered) { 214 | const sprite_sheet_rect = get_sprite_sheet_rect(.{ 0, 1 }); 215 | 216 | _ = c.SDL_SetTextureAlphaMod(sprite_sheet_texture, @intFromFloat(128.0)); 217 | _ = c.SDL_RenderTexture(ren, sprite_sheet_texture, &sprite_sheet_rect, &sprite_output_pos_rect); 218 | _ = c.SDL_SetTextureAlphaMod(sprite_sheet_texture, 255); 219 | } 220 | 221 | // Draw overlay on invalid move 222 | if (gfx_cell.invalid_move_time_secs > 0.0) { 223 | const alpha = gfx_cell.invalid_move_time_secs / InvalidMoveTimeSecs; 224 | const sprite_sheet_rect = get_sprite_sheet_rect(.{ 8, 1 }); 225 | 226 | _ = c.SDL_SetTextureAlphaMod(sprite_sheet_texture, @intFromFloat(alpha * 255.0)); 227 | _ = c.SDL_RenderTexture(ren, sprite_sheet_texture, &sprite_sheet_rect, &sprite_output_pos_rect); 228 | _ = c.SDL_SetTextureAlphaMod(sprite_sheet_texture, 255); 229 | } 230 | } 231 | 232 | _ = c.SDL_RenderPresent(ren); 233 | 234 | last_frame_time_ms = current_frame_time_ms; 235 | } 236 | 237 | allocator.free(gfx_board); 238 | } 239 | --------------------------------------------------------------------------------