diff --git a/build.zig b/build.zig index fcf6260..99152ba 100644 --- a/build.zig +++ b/build.zig @@ -1,6 +1,8 @@ const std = @import("std"); pub fn build(b: *std.Build) void { + // Both of these read flags from the command line, e.g.: + // zig build -Dtarget=x86_64-windows -Doptimize=ReleaseSafe const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); @@ -17,19 +19,25 @@ pub fn build(b: *std.Build) void { }), }); + // Plain `zig build` runs this: copies the binary into zig-out/bin. b.installArtifact(exe); + // `zig build run`: execute the freshly built program. const run_step = b.step("run", "Run the app"); const run_cmd = b.addRunArtifact(exe); run_step.dependOn(&run_cmd.step); + // Chaining run onto install guarantees the exe is rebuilt first. run_cmd.step.dependOn(b.getInstallStep()); if (b.args) |args| { run_cmd.addArgs(args); } + // `zig build test`: builds a special binary whose "main" collects every + // `test` block reachable from the root source file (main.zig) — which is + // exactly why the `_ = Board;` bridge block lives there and not in board.zig. const exe_tests = b.addTest(.{ .root_module = exe.root_module, }); diff --git a/src/board.zig b/src/board.zig index 3c8227c..8854846 100644 --- a/src/board.zig +++ b/src/board.zig @@ -1,3 +1,6 @@ +//! board.zig — pure game rules: packing, merging, sliding, spawning. +//! No I/O, no colors, no player input: this file knows NOTHING about the +//! terminal. That separation is what makes it easy to unit-test. const std = @import("std"); const Self = @This(); @@ -8,12 +11,13 @@ const Self = @This(); cells: [4][4]u16 = .{.{0} ** 4} ** 4, /// ---------------------------------------------------- +/// Packs one row LEFT: non-zero tiles slide toward index 0, +/// gaps close up. NO merging here — that's mergeRow's job. +/// (This is the old slideRowLeft, renamed for honesty.) /// ---------------------------------------------------- -fn slideRowLeft(row: *[4]u16) void { +fn packRow(row: *[4]u16) void { var write: usize = 0; // where the next tile should land - // Pass 1: copy every non-zero tile to the next free slot. - // `cell` = WHAT we're carrying, `write` = WHERE it lands. for (row) |cell| { if (cell != 0) { row[write] = cell; @@ -21,12 +25,45 @@ fn slideRowLeft(row: *[4]u16) void { } } - // Pass 2: everything after the last packed tile becomes 0. for (write..4) |i| { row[i] = 0; } } +/// ---------------------------------------------------- +/// Merges equal adjacent tiles, ONCE per move. +/// Scan left→right: if row[i] == row[i+1] (both non-zero), +/// double row[i] and zero row[i+1]. +/// +/// Why no "already merged" flag is needed: zeroing the right +/// partner puts a 0 BETWEEN the new tile and anything after it, +/// so the same tile can never be merged twice in one scan. +/// The rule enforces itself. +/// +/// Loop stops at 3 because we read row[i+1] — at i=3 that +/// would be out of bounds and Zig would panic (in Debug/ReleaseSafe). +/// ---------------------------------------------------- +fn mergeRow(row: *[4]u16) void { + for (0..3) |i| { + if (row[i] != 0 and row[i] == row[i + 1]) { + row[i] *= 2; + row[i + 1] = 0; + } + } +} + +/// ---------------------------------------------------- +/// Full 2048 slide LEFT = pack → merge → pack. +/// Pass 1 makes equal tiles adjacent (so merges are visible), +/// pass 2 merges them, pass 3 closes the gap a merge opened. +/// Example: [2,2,4,0] → [2,2,4,0] → [4,0,4,0] → [4,4,0,0] +/// ---------------------------------------------------- +fn slideRowLeft(row: *[4]u16) void { + packRow(row); + mergeRow(row); + packRow(row); +} + /// ---------------------------------------------------- /// Reverses one row in place: [2,0,4,0] becomes [0,4,0,2]. /// Only visits the first half (i < 2) — otherwise every swap @@ -95,6 +132,8 @@ pub fn slideDown(self: *Self) void { /// ---------------------------------------------------- /// Spawns a new tile (2 or 4) in a random empty cell. +/// Silent no-op if the board is full — callers who need +/// to know must check for space themselves (Stage 5 will). /// ---------------------------------------------------- pub fn spawn(self: *Self, rand: std.Random) void { // --- Phase 1: find every empty cell ------------------- @@ -133,3 +172,56 @@ pub fn spawn(self: *Self, rand: std.Random) void { self.cells[row][col] = value; } + +// +// TESTS +// Run with: zig build test --summary all +// Silence = success. Failures print file, line, and both values. +// + +const testing = std.testing; + +test "packRow slides tiles left and closes gaps" { + var row = [4]u16{ 2, 0, 0, 2 }; + packRow(&row); + try testing.expectEqual([4]u16{ 2, 2, 0, 0 }, row); +} + +test "mergeRow doubles one pair" { + var row = [4]u16{ 2, 2, 0, 0 }; + mergeRow(&row); + try testing.expectEqual([4]u16{ 4, 0, 0, 0 }, row); +} + +test "mergeRow never chains into an 8" { + var row = [4]u16{ 2, 2, 2, 2 }; + mergeRow(&row); + // After merging at i=0 the zero at index 1 acts as a barrier, + // so the second pair merges separately: [4,0,4,0], never [8,...]. + try testing.expectEqual([4]u16{ 4, 0, 4, 0 }, row); +} + +test "mergeRow leaves unequal neighbors alone" { + var row = [4]u16{ 2, 4, 0, 0 }; + mergeRow(&row); + try testing.expectEqual([4]u16{ 2, 4, 0, 0 }, row); +} + +test "slideRowLeft packs before AND after merging" { + // Needs pack #1 so the 2s become adjacent, + // and pack #2 to close the gap the merge opened. + var row = [4]u16{ 2, 2, 4, 0 }; + slideRowLeft(&row); + try testing.expectEqual([4]u16{ 4, 4, 0, 0 }, row); +} + +test "slideLeft merges every row on the board" { + var b = Self{}; // cells defaults to all zeros via its field default + b.cells[0] = .{ 2, 2, 2, 2 }; + b.cells[1] = .{ 4, 2, 2, 0 }; + b.cells[2] = .{ 2, 0, 0, 2 }; + b.slideLeft(); + try testing.expectEqual([4]u16{ 4, 4, 0, 0 }, b.cells[0]); + try testing.expectEqual([4]u16{ 4, 4, 0, 0 }, b.cells[1]); + try testing.expectEqual([4]u16{ 4, 0, 0, 0 }, b.cells[2]); +} diff --git a/src/main.zig b/src/main.zig index ae9279b..df7153a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,27 +1,125 @@ -//! main.zig — program entry point: sets up a board and shows it. +//! main.zig — entry point: rendering, input, and the game loop. const Board = @import("board.zig"); const std = @import("std"); -pub fn main() !void { - var board: Board = .{}; +/// The four directions a player can swipe. +const Move = enum { up, down, left, right }; - // Random number generator. The seed (42) makes every run - // identical for now — handy for debugging, we'll fix it later. - var prng = std.Random.DefaultPrng.init(42); - const rand = prng.random(); - - board.spawn(rand); // drop one starting tile onto the board - - board.cells[0] = .{ 2, 0, 2, 0 }; - board.cells[1] = .{ 0, 4, 4, 0 }; - board.cells[2] = .{ 2, 2, 2, 2 }; - board.cells[3] = .{ 0, 2, 0, 4 }; - board.slideUp(); // ← swap in any direction to test it +/// Maps a tile value to an ANSI foreground-color escape sequence. +/// "\x1b[" opens every ANSI escape, a number picks the color, 'm' closes it; +/// render() resets back to normal with "\x1b[0m" after each tile. +/// Values 128+ (128, 256, ... 2048) all land in the bold-yellow `else`. +/// Note: the `0 =>` arm is currently unreachable — render() special-cases +/// empty cells before ever calling this. Kept for safety if that changes. +fn tileColor(value: u16) []const u8 { + return switch (value) { + 0 => "\x1b[90m", + 2 => "\x1b[37m", + 4 => "\x1b[33m", + 8 => "\x1b[31m", + 16 => "\x1b[35m", + 32 => "\x1b[36m", + 64 => "\x1b[34m", + else => "\x1b[1;33m", + }; +} +/// Draws the whole board to `out`. `*const Board` is a read-only view: +/// rendering physically cannot mutate game state. +/// `{d:4}` prints each number right-aligned in a 4-column field so +/// columns stay lined up once tiles grow to 3–4 digits. +fn render(board: *const Board, out: *std.Io.Writer) !void { + try out.print("\x1b[2J\x1b[H", .{}); // clear screen + cursor home for (board.cells) |row| { for (row) |cell| { - std.debug.print("\x1b[31m{d:4}", .{cell}); + if (cell == 0) { + try out.print("\x1b[90m .\x1b[0m", .{}); + } else { + try out.print("{s}{d:4}\x1b[0m", .{ tileColor(cell), cell }); + } } - std.debug.print("\n", .{}); + try out.print("\n", .{}); } + try out.flush(); +} + +/// Maps one typed character to a Move. +/// Returns null for anything unrecognized — an OPTIONAL: +/// nothing failed, there just wasn't a match. +fn parseMove(text: []const u8) ?Move { + if (text.len != 1) return null; + return switch (text[0]) { + 'w' => .up, + 'a' => .left, + 's' => .down, + 'd' => .right, + else => null, + }; +} + +pub fn main(init: std.process.Init) !void { + const io = init.io; + + // The writer/reader need somewhere to stage bytes, so we hand each one + // a stack buffer. CRITICAL: the buffer must outlive its interface — + // declare it first, and keep both alive for main's whole body. + var out_buffer: [4096]u8 = undefined; + var file_writer = std.Io.File.stdout().writer(io, &out_buffer); + const out = &file_writer.interface; // buffered stdout: flush() pushes bytes out + + var in_buffer: [256]u8 = undefined; + var file_reader = std.Io.File.stdin().readerStreaming(io, &in_buffer); + const in = &file_reader.interface; // buffered stdin + + var board: Board = .{}; + + // Seed a PRNG once from OS entropy, then reuse it every turn. + // io.random is the slow, secure source; DefaultPrng is a fast + // deterministic stream — exactly what game randomness needs. + // readInt just squashes the 8 random bytes into one u64 seed. + var seed_bytes: [8]u8 = undefined; + io.random(&seed_bytes); + var prng = std.Random.DefaultPrng.init(std.mem.readInt(u64, &seed_bytes, .little)); + const rand = prng.random(); // thin handle over prng; can't be misused alone + + board.spawn(rand); // real 2048 opens with two tiles already on the board + board.spawn(rand); + + while (true) { + try render(&board, out); + try out.print("move (w/a/s/d, q=quit): ", .{}); + try out.flush(); + + // Two different flavors of "nothing came back": + // error = something broke → surface it + // null = clean EOF (Ctrl+D / stdin closed) → just quit + const maybe_line = in.takeDelimiter('\n') catch |err| { + try out.print("\nread error: {any}\n", .{err}); + try out.flush(); + return err; + }; + const line = maybe_line orelse break; + + const trimmed = std.mem.trim(u8, line, " \r\t"); + if (std.mem.eql(u8, trimmed, "q")) break; + + const move = parseMove(trimmed) orelse continue; // unknown → redraw + const before = board.cells; + switch (move) { + .up => board.slideUp(), + .down => board.slideDown(), + .left => board.slideLeft(), + .right => board.slideRight(), + } + if (!std.meta.eql(before, board.cells)) { + board.spawn(rand); + } + } + + try out.print("\nThanks for playing!\n", .{}); + try out.flush(); +} + +test { + _ = Board; } diff --git a/zig-out/bin/_2048zig b/zig-out/bin/_2048zig index dda88d0..8f25a73 100755 Binary files a/zig-out/bin/_2048zig and b/zig-out/bin/_2048zig differ